ltk ran on Wayland and Android only. Running crustace and its apps in a browser needs, first, ltk apps running there; a window manager for the page is the next step. The runtime was already split from Wayland by the platform layer, so what kept it out of a browser was what the core and the runtime took from the operating system: calloop's epoll loop, EGL, a monotonic clock, a file system, threads and environment variables. The core builds for wasm32-unknown-unknown. `ltk::ChannelSender` is ltk's own type instead of a re-export of calloop's `Sender`, with the same `send` and `Clone`, so apps that only send and clone it build unchanged. The runtime arms its timers and channels through `event_loop/sources.rs` instead of a calloop `LoopHandle`: calloop's natively, and in a browser a queue the backend runs, which takes the due timers out, runs them on `AppData` and hands them back, and does not arm again one removed meanwhile. calloop, khronos-egl and raw-window-handle are dependencies off wasm32 only, where `egl_context.rs`, `UiSurface::try_new_gles` and `from_current_gles_loader` and the native GL names of `BorrowedGlesTexture` are left out; `sub_canvas` clones the uniform locations, which are `Copy` natively and only `Clone` on wasm. The monotonic clock is `crate::time::Instant`, `web_time`'s on wasm32, where `chrono` takes its `wasmbind` feature; the one subtraction that could go below the clock's origin, a surface's first `last_draw`, saturates. Themes, fonts, keyboard layouts and images are read through `vfs.rs`, the file system natively and on wasm32 files held in memory, so a wallpaper or an `Image::from_path` is now decoded from the file's bytes and its format told from its contents rather than its extension. The text-scale watcher, which runs gsettings on a thread, is left out on wasm32, and the built-in keyboard takes its language from `platform::language()`, `LANG` or the page's. criterion is a dev-dependency off wasm32 only, since rayon does not build there. The browser backend is the `web` feature, `platform/web/`, with wasm-bindgen, wasm-bindgen-futures, js-sys and web-sys. `build.rs` refuses it with another backend or for another target. A page's main thread must not block, so `ltk::run` sets the app up and returns, and the browser drives the loop: `AppData` lives in a closure a thread-local loop holds, which a `setTimeout` runs for messages, timers and page events and a `requestAnimationFrame` runs to draw. Event listeners and `ChannelSender::send` only queue and schedule, never touching `AppData`, so nothing they set off runs inside an iteration. The app draws on a canvas laid over the whole page, or the one `ltk::web::set_canvas` gives, through WebGL2 with the GLES canvas unchanged, its frame kept in its FBO, or without WebGL2 through `putImageData`. A lost context drops the GPU canvases and draws nothing until it is restored on a new `glow::Context`, so the glyph atlas and the caches of the old one are not reused. The main surface's logical size is the canvas's CSS size; the window's `resize` refits it, and on ltk's own canvas a hidden element padded by `env( safe-area-inset-* )` gives the insets. Input comes from Pointer Events, a mouse as the pointer and fingers and pens as touches, the wheel, in detents when it scrolls lines, and keys, whose keysym comes from `KeyboardEvent.key` and code from `.code` as the Linux input code Wayland reports; keys repeat on the browser's clock, AltGr types its character, and on macOS Command is Ctrl. ltk takes every key the browser lets a page have, and while fullscreen those `navigator.keyboard.lock()` adds in Chromium. Copies go to `navigator.clipboard.writeText`; a page reads the clipboard only from a `paste` event, so Ctrl+V is let through to the browser and held until its `paste` has put the text in `AppData::clipboard`, and a paste from the browser's menu comes with a Ctrl+V of its own. The cursor's shape is the canvas's CSS `cursor`. The built-in keyboard serves text where the page is used by touch. Panics go to the browser's console. When the app exits, the listeners are removed, the canvas receives an `ltk-exit` event and the app is dropped. The new `RunError::NoDocument` is returned where there is no page, as in a worker. Resources are the tree an Android package carries. `scripts/web-assets.sh` builds it through `android-assets.sh`, and `ltk::web::load_resources( base ).await` fetches its `MANIFEST` and every file it lists, with the manifest's hash in their URLs so a changed tree is not served from the browser's cache, into memory under `/ltk`, which becomes the resource directory. The per-script fallback fonts are read through `resources::read_font`, so a path that does not exist is found by file name in the resource directory's `fonts/`, where a page bundles them. A surface's scale may be fractional. `SurfaceState::scale_factor` is an `f32`, which `Platform::prepare_canvas`, `Presentation::scale` and the paint path take as it is; a surface's physical size is its logical size times the scale, rounded once, and `buffer_scale()` gives the whole number Wayland's buffer scale and regions need. Wayland and Android still set whole scales, so nothing changes there; the browser backend sets the page's `devicePixelRatio`, so the canvas holds the display's own pixels at any zoom. `App::on_scale_changed` keeps its `u32`, the scale rounded, and `App::on_resize` gives the exact physical size. The fallback-theme banner placed its text as if `draw_text` took the top of the line rather than its baseline, so most of the text fell above the banner, out of the surface; it is now centred as the title bar's is. Building. `make web-check` typechecks the backend. `make web-deps` installs Debian's std for wasm32 and wasm-bindgen-cli, whose version must be the one of the wasm-bindgen crate in `Cargo.lock`: the crate is pinned to 0.2.117, the last whose CLI builds with Debian's rustc 1.85, and since the lock the CLI was published with needs a newer rustc and `cargo install` ignores rust-version when it resolves, the CLI is built from its sources with a lock resolved for the rust-version it declares, into `target/tools`, where the Makefile finds it. `make web-assets` builds the resources with the in-tree default theme and the fallback fonts `WEB_FONTS` names. `examples/web_hello.rs`, a counter and a text field, and `web/index.html` are the smoke test: `make web-example` builds them into `target/web/` and `make web-serve` serves them. `make install` now copies `web/` into the registry. `README.md`, `docs/architecture.md` and `CHANGELOG.md` describe the backend, and `docs/cookbook.md` points to `ChannelSender`'s new home. Not done. In a browser there are no overlays, secondary surfaces or subsurfaces, no dead keys or input methods, no accessibility tree and no primary selection, and a canvas that changes size while the window does not is not refitted. Pasting relies on the browser sending `paste` to a focused canvas, which Chrome and Firefox do. The page fetches its whole resource tree, about 7 MB, before the app starts. There are no tests for the browser backend, `Sources`' queue or `vfs.rs`'s in-memory files, and fractional scales may leave one-pixel artefacts where edges fall between pixels.
210 lines
6.5 KiB
Rust
210 lines
6.5 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
|
|
|
//! Runtime performance guardrails.
|
|
//!
|
|
//! The idle/redraw model is efficient only if the app cooperates — a stuck
|
|
//! [`App::is_animating`](crate::App::is_animating), an aggressive
|
|
//! [`poll_interval`](crate::App::poll_interval), or continuous animation on
|
|
//! the software backend all quietly burn CPU/battery. The docs warn about
|
|
//! these, but the runtime can also **detect** them (opt-in dev diagnostics,
|
|
//! `LTK_PERF_WARN=1`) and **mitigate** one of them (cap animation to ~30 Hz
|
|
//! on the software renderer, opt-out via
|
|
//! [`App::cap_software_animation`](crate::App::cap_software_animation)).
|
|
|
|
use std::sync::OnceLock;
|
|
use std::time::Duration;
|
|
|
|
use crate::time::Instant;
|
|
|
|
/// A settled animation should return `false` well before this; staying `true`
|
|
/// this long is almost always a forgotten `is_animating = false`.
|
|
const STUCK_THRESHOLD: Duration = Duration::from_secs( 10 );
|
|
/// Continuous software-rendered animation past this is worth flagging on
|
|
/// mobile, where it means sustained CPU with no GPU offload.
|
|
const SW_ANIM_THRESHOLD: Duration = Duration::from_secs( 3 );
|
|
/// Minimum gap between software-backend animation re-rasters (≈ 30 Hz).
|
|
const SOFTWARE_ANIM_MIN_INTERVAL: Duration = Duration::from_millis( 33 );
|
|
/// A `poll_interval` shorter than this defeats the event-driven idle model.
|
|
const POLL_WARN_THRESHOLD: Duration = Duration::from_millis( 100 );
|
|
|
|
/// `true` when `LTK_PERF_WARN` is set to a non-empty, non-`0` value. Cached.
|
|
fn perf_warn_enabled() -> bool
|
|
{
|
|
static ENABLED: OnceLock<bool> = OnceLock::new();
|
|
*ENABLED.get_or_init( ||
|
|
std::env::var( "LTK_PERF_WARN" )
|
|
.map( |v| !v.is_empty() && v != "0" )
|
|
.unwrap_or( false )
|
|
)
|
|
}
|
|
|
|
/// Wall time of one GLES raster, split at submission and at GPU completion.
|
|
/// Opt-in via `LTK_PERF_RASTER`; the `glFinish` it adds stalls the pipeline.
|
|
pub( crate ) struct RasterTrace
|
|
{
|
|
start: Instant,
|
|
}
|
|
|
|
impl RasterTrace
|
|
{
|
|
pub( crate ) fn begin() -> Option<Self>
|
|
{
|
|
static ENABLED: OnceLock<bool> = OnceLock::new();
|
|
let enabled = *ENABLED.get_or_init( ||
|
|
std::env::var( "LTK_PERF_RASTER" )
|
|
.map( |v| !v.is_empty() && v != "0" )
|
|
.unwrap_or( false )
|
|
);
|
|
enabled.then( || Self { start: Instant::now() } )
|
|
}
|
|
|
|
pub( crate ) fn end( self, gl: &glow::Context, label: &str, pw: u32, ph: u32 )
|
|
{
|
|
use glow::HasContext;
|
|
let submitted = self.start.elapsed();
|
|
unsafe { gl.finish(); }
|
|
let done = self.start.elapsed();
|
|
eprintln!(
|
|
"[ltk][raster] {label} {pw}x{ph} cpu={:.2}ms gpu_done={:.2}ms",
|
|
submitted.as_secs_f64() * 1000.0,
|
|
done.as_secs_f64() * 1000.0,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Per-runtime performance-guard state. Lives on `AppData`.
|
|
pub struct PerfState
|
|
{
|
|
/// When the current continuous animation began (`None` while idle).
|
|
animating_since: Option<Instant>,
|
|
/// Last software-backend animation re-raster, for the 30 Hz cap.
|
|
last_anim_draw: Instant,
|
|
warned_stuck: bool,
|
|
warned_software_anim: bool,
|
|
warned_poll: bool,
|
|
}
|
|
|
|
impl PerfState
|
|
{
|
|
pub fn new() -> Self
|
|
{
|
|
Self
|
|
{
|
|
animating_since: None,
|
|
// Start in the past so the first animated frame is never throttled.
|
|
// `checked_sub` guards against underflow shortly after boot.
|
|
last_anim_draw: Instant::now().checked_sub( SOFTWARE_ANIM_MIN_INTERVAL ).unwrap_or_else( Instant::now ),
|
|
warned_stuck: false,
|
|
warned_software_anim: false,
|
|
warned_poll: false,
|
|
}
|
|
}
|
|
|
|
/// Reset when the app stops animating, so the next animation starts fresh
|
|
/// (and can warn again if it, too, gets stuck).
|
|
pub fn animation_stopped( &mut self )
|
|
{
|
|
self.animating_since = None;
|
|
self.warned_stuck = false;
|
|
self.warned_software_anim = false;
|
|
}
|
|
|
|
/// Called on every animated main-surface frame callback. Runs the opt-in
|
|
/// diagnostics and applies the software animation-rate cap. Returns `true`
|
|
/// if this frame should actually re-raster, `false` to throttle-skip it
|
|
/// (the caller keeps the vsync cadence with a bare frame callback).
|
|
pub fn animated_frame( &mut self, software: bool, cap_software: bool ) -> bool
|
|
{
|
|
let since = *self.animating_since.get_or_insert_with( Instant::now );
|
|
|
|
if perf_warn_enabled()
|
|
{
|
|
if !self.warned_stuck && since.elapsed() >= STUCK_THRESHOLD
|
|
{
|
|
eprintln!(
|
|
"[ltk][perf] App::is_animating() has stayed true for {}s — a settled \
|
|
animation must return false, or the loop redraws at the display rate \
|
|
forever (battery drain).",
|
|
STUCK_THRESHOLD.as_secs(),
|
|
);
|
|
self.warned_stuck = true;
|
|
}
|
|
if software && !self.warned_software_anim && since.elapsed() >= SW_ANIM_THRESHOLD
|
|
{
|
|
eprintln!(
|
|
"[ltk][perf] continuous animation on the software renderer — sustained \
|
|
CPU with no GPU offload (costly on mobile). Prefer event-driven redraws, \
|
|
or a GLES-capable compositor.",
|
|
);
|
|
self.warned_software_anim = true;
|
|
}
|
|
}
|
|
|
|
if software && cap_software && self.last_anim_draw.elapsed() < SOFTWARE_ANIM_MIN_INTERVAL
|
|
{
|
|
return false;
|
|
}
|
|
self.last_anim_draw = Instant::now();
|
|
true
|
|
}
|
|
|
|
/// Warn once (under `LTK_PERF_WARN`) if the app's `poll_interval` is short
|
|
/// enough to defeat the idle model.
|
|
pub fn warn_poll_interval( &mut self, dur: Duration )
|
|
{
|
|
if perf_warn_enabled() && !self.warned_poll && dur < POLL_WARN_THRESHOLD
|
|
{
|
|
eprintln!(
|
|
"[ltk][perf] poll_interval() of {}ms defeats the event-driven idle model — \
|
|
wake the loop from your worker with set_channel_sender instead of polling.",
|
|
dur.as_millis(),
|
|
);
|
|
self.warned_poll = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[ cfg( test ) ]
|
|
mod tests
|
|
{
|
|
use super::*;
|
|
|
|
#[ test ]
|
|
fn gles_animation_is_never_capped()
|
|
{
|
|
let mut p = PerfState::new();
|
|
// Two back-to-back frames on the GPU backend both re-raster.
|
|
assert!( p.animated_frame( false, true ) );
|
|
assert!( p.animated_frame( false, true ) );
|
|
}
|
|
|
|
#[ test ]
|
|
fn software_animation_is_capped_between_rerasters()
|
|
{
|
|
let mut p = PerfState::new();
|
|
// First software frame draws; an immediate second is throttled
|
|
// (< 33 ms since the last re-raster).
|
|
assert!( p.animated_frame( true, true ) );
|
|
assert!( !p.animated_frame( true, true ) );
|
|
}
|
|
|
|
#[ test ]
|
|
fn software_cap_opt_out_keeps_full_rate()
|
|
{
|
|
let mut p = PerfState::new();
|
|
assert!( p.animated_frame( true, false ) );
|
|
assert!( p.animated_frame( true, false ) );
|
|
}
|
|
|
|
#[ test ]
|
|
fn stopping_resets_stuck_warning_state()
|
|
{
|
|
let mut p = PerfState::new();
|
|
let _ = p.animated_frame( false, true );
|
|
assert!( p.animating_since.is_some() );
|
|
p.animation_stopped();
|
|
assert!( p.animating_since.is_none() );
|
|
}
|
|
}
|