A browser backend: ltk runs on a page's canvas through WebGL2, on a core that builds for wasm32, and a surface's scale may be fractional
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

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.
This commit is contained in:
2026-10-19 20:20:12 +02:00
parent 4c7e73d492
commit 20dc21448e
81 changed files with 2267 additions and 303 deletions
+342
View File
@@ -0,0 +1,342 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! `ltk::run` in a browser.
//!
//! A page's main thread must not block, so `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. Page events and `ChannelSender::send` only queue and schedule, never touching `AppData`, so nothing they set off can run inside an iteration.
use std::cell::{ Cell, RefCell };
use std::collections::VecDeque;
use std::rc::Rc;
use std::time::Duration;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::{ JsCast, JsValue };
use web_sys::{ CanvasRenderingContext2d, Document, Event, EventTarget, HtmlCanvasElement, WebGl2RenderingContext };
use crate::app::App;
use crate::event_loop::{ AppData, RunError, Sources, SurfaceState };
use super::input::WebInput;
use super::{ WebPlatform, WebSurfaceState };
/// What the page reports between iterations.
pub( super ) enum PageEvent
{
Resized,
Input( WebInput ),
ContextLost,
ContextRestored,
}
/// An event listener, registered while it lives in `WebPlatform::listeners`.
pub( crate ) struct Listener
{
target: EventTarget,
kind: &'static str,
closure: Closure<dyn FnMut( Event )>,
}
impl Listener
{
pub( super ) fn new( target: &EventTarget, kind: &'static str, handle: impl FnMut( Event ) + 'static ) -> Result<Self, RunError>
{
let closure = Closure::<dyn FnMut( Event )>::new( handle );
target.add_event_listener_with_callback( kind, closure.as_ref().unchecked_ref() ).map_err( |_| RunError::NoDocument )?;
Ok( Self { target: target.clone(), kind, closure } )
}
pub( super ) fn remove( &self )
{
let _ = self.target.remove_event_listener_with_callback( self.kind, self.closure.as_ref().unchecked_ref() );
}
}
/// What an iteration asks of the next.
struct Next
{
frame: bool,
after: Option<Duration>,
exit: bool,
}
struct Loop
{
iterate: RefCell<Box<dyn FnMut( bool ) -> Next>>,
/// The pending timeout and when it fires, in milliseconds since the epoch.
timeout: Cell<Option<( i32, f64 )>>,
frame: Cell<Option<i32>>,
ended: Cell<bool>,
on_timeout: Closure<dyn FnMut()>,
on_frame: Closure<dyn FnMut( f64 )>,
}
thread_local!
{
static LOOP: RefCell<Option<Rc<Loop>>> = const { RefCell::new( None ) };
static EVENTS: RefCell<VecDeque<PageEvent>> = const { RefCell::new( VecDeque::new() ) };
static CANVAS: RefCell<Option<HtmlCanvasElement>> = const { RefCell::new( None ) };
}
/// Draw the app on `canvas` rather than on one ltk lays over the whole page. Call it before `ltk::run`.
pub fn set_canvas( canvas: HtmlCanvasElement )
{
CANVAS.with( |c| *c.borrow_mut() = Some( canvas ) );
}
pub( crate ) fn run<A: App>( app: A )
{
if let Err( e ) = try_run( app )
{
panic!( "ltk::run failed during init: {e}" );
}
}
/// Set the app up on the page and return; the browser runs it from then on.
pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
{
super::log::install_panic_hook();
let window = web_sys::window().ok_or( RunError::NoDocument )?;
let document = window.document().ok_or( RunError::NoDocument )?;
let ( canvas, own ) = match CANVAS.with( |c| c.borrow_mut().take() )
{
Some( canvas ) => ( canvas, false ),
None => ( page_canvas( &document )?, true ),
};
canvas.set_tab_index( 0 );
let style = canvas.style();
let _ = style.set_property( "touch-action", "none" );
let _ = style.set_property( "outline", "none" );
let webgl = webgl2( &canvas );
let ctx2d = if webgl.is_none() { context_2d( &canvas ) } else { None };
if webgl.is_none() { web_sys::console::warn_1( &"[ltk] no WebGL2 context: drawing in software".into() ); }
crate::render::set_software_render( webgl.is_none() );
let mut listeners = super::input::listen( &canvas )?;
listeners.push( Listener::new( &window, "resize", |_| push( PageEvent::Resized ) )? );
if webgl.is_some()
{
// Without `preventDefault` the browser never gives the context back.
listeners.push( Listener::new( &canvas, "webglcontextlost", |event|
{
event.prevent_default();
push( PageEvent::ContextLost );
} )? );
listeners.push( Listener::new( &canvas, "webglcontextrestored", |_| push( PageEvent::ContextRestored ) )? );
}
let inset_probe = if own { Some( inset_probe( &document )? ) } else { None };
if own { let _ = canvas.focus(); }
let coarse = window.match_media( "(pointer: coarse)" ).ok().flatten().is_some_and( |m| m.matches() );
let platform = WebPlatform::new( canvas, webgl, ctx2d, listeners, inset_probe );
let main = SurfaceState::new( WebSurfaceState::default(), 0.0, String::new() );
// ltk's own keyboard where the page is used by touch, since the page cannot raise the system's for a canvas.
let mut data = AppData::new( app, platform, main, Sources::new( wake ), coarse, None );
data.install_sources()?;
data.fit_canvas_element();
let mut data = Some( data );
let iterate: Box<dyn FnMut( bool ) -> Next> = Box::new( move |in_frame|
{
let Some( app ) = data.as_mut() else { return Next { frame: false, after: None, exit: true } };
let next = app.iterate( in_frame );
if next.exit { data = None; }
next
} );
let lp = Loop
{
iterate: RefCell::new( iterate ),
timeout: Cell::new( None ),
frame: Cell::new( None ),
ended: Cell::new( false ),
on_timeout: Closure::new( || run_iteration( false ) ),
on_frame: Closure::new( |_: f64| run_iteration( true ) ),
};
LOOP.with( |l| *l.borrow_mut() = Some( Rc::new( lp ) ) );
wake();
Ok( () )
}
/// Ask for an iteration as soon as the page is idle.
pub( super ) fn wake()
{
with_loop( |lp| lp.schedule_in( 0.0 ) );
}
pub( super ) fn push( event: PageEvent )
{
EVENTS.with( |q| q.borrow_mut().push_back( event ) );
wake();
}
fn with_loop( f: impl FnOnce( &Loop ) )
{
if let Some( lp ) = LOOP.with( |l| l.borrow().clone() ) { f( &lp ); }
}
fn run_iteration( in_frame: bool )
{
with_loop( |lp|
{
if in_frame { lp.frame.set( None ); } else { lp.timeout.set( None ); }
if lp.ended.get() { return; }
let Ok( mut iterate ) = lp.iterate.try_borrow_mut() else { return };
let next = ( *iterate )( in_frame );
drop( iterate );
if next.exit
{
lp.end();
return;
}
if next.frame { lp.request_frame(); }
if let Some( after ) = next.after { lp.schedule_in( after.as_secs_f64() * 1000.0 ); }
} );
}
impl Loop
{
/// Run an iteration in `ms` milliseconds, unless one is already due sooner.
fn schedule_in( &self, ms: f64 )
{
if self.ended.get() { return; }
let Some( window ) = web_sys::window() else { return };
let at = js_sys::Date::now() + ms;
if let Some( ( id, due ) ) = self.timeout.get()
{
if due <= at { return; }
window.clear_timeout_with_handle( id );
}
let delay = ms.ceil().clamp( 0.0, i32::MAX as f64 ) as i32;
if let Ok( id ) = window.set_timeout_with_callback_and_timeout_and_arguments_0( self.on_timeout.as_ref().unchecked_ref(), delay )
{
self.timeout.set( Some( ( id, at ) ) );
}
}
fn request_frame( &self )
{
if self.frame.get().is_some() { return; }
let Some( window ) = web_sys::window() else { return };
if let Ok( id ) = window.request_animation_frame( self.on_frame.as_ref().unchecked_ref() )
{
self.frame.set( Some( id ) );
}
}
/// Run no more iterations. The closures stay alive, since one of them is running.
fn end( &self )
{
self.ended.set( true );
let Some( window ) = web_sys::window() else { return };
if let Some( ( id, _ ) ) = self.timeout.take() { window.clear_timeout_with_handle( id ); }
if let Some( id ) = self.frame.take() { let _ = window.cancel_animation_frame( id ); }
}
}
impl<A: App> AppData<A>
{
/// What the page reported, the due timers and messages, then the runtime's steps; drawing only in an animation frame.
fn iterate( &mut self, in_frame: bool ) -> Next
{
while let Some( event ) = EVENTS.with( |q| q.borrow_mut().pop_front() )
{
match event
{
PageEvent::Resized => self.fit_canvas_element(),
PageEvent::Input( input ) => self.web_input( input ),
PageEvent::ContextLost => self.webgl_context_lost(),
PageEvent::ContextRestored => self.webgl_context_restored(),
}
}
self.dispatch_sources();
if in_frame
{
if let Some( focus ) = self.platform.frame_due.take() { self.frame_presented( focus, false ); }
}
self.before_draw();
let drawn = in_frame && self.draw_due();
self.after_draw( drawn );
if self.exit_requested
{
self.leave_page();
return Next { frame: false, after: None, exit: true };
}
let main = &self.main;
let frame = ( main.configured && main.needs_redraw && !main.frame_pending ) || self.platform.frame_due.get().is_some();
let now = crate::time::Instant::now();
let after = [ self.next_wakeup(), self.sources.next_due().map( |due| due.saturating_duration_since( now ) ) ]
.into_iter()
.flatten()
.min();
Next { frame, after, exit: false }
}
fn dispatch_sources( &mut self )
{
for ( id, mut tick ) in self.sources.take_due( crate::time::Instant::now() )
{
let next = tick( self );
self.sources.rearm( id, tick, next );
}
let mut drains = self.sources.take_channels();
for drain in &mut drains
{
drain( self );
}
self.sources.restore_channels( drains );
}
/// Stop listening to the page and tell it, with an `ltk-exit` event on the canvas, that the app has ended.
fn leave_page( &mut self )
{
for listener in &self.platform.listeners
{
listener.remove();
}
if let Some( probe ) = self.platform.inset_probe.take() { probe.remove(); }
if let Ok( event ) = Event::new( "ltk-exit" )
{
let _ = self.platform.canvas.dispatch_event( &event );
}
}
}
/// A canvas over the whole page, for an app that named none.
fn page_canvas( document: &Document ) -> Result<HtmlCanvasElement, RunError>
{
let canvas: HtmlCanvasElement = document.create_element( "canvas" ).ok()
.and_then( |e| e.dyn_into().ok() )
.ok_or( RunError::NoDocument )?;
let _ = canvas.set_attribute( "style", "position: fixed; inset: 0; width: 100%; height: 100%; display: block; touch-action: none" );
document.body().ok_or( RunError::NoDocument )?.append_child( &canvas ).map_err( |_| RunError::NoDocument )?;
Ok( canvas )
}
/// An invisible element padded by the page's safe-area insets, read back as numbers by `fit_canvas_element`.
fn inset_probe( document: &Document ) -> Result<web_sys::Element, RunError>
{
let probe = document.create_element( "div" ).map_err( |_| RunError::NoDocument )?;
let _ = probe.set_attribute( "style", "position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; \
padding: env( safe-area-inset-top ) env( safe-area-inset-right ) env( safe-area-inset-bottom ) env( safe-area-inset-left )" );
document.body().ok_or( RunError::NoDocument )?.append_child( &probe ).map_err( |_| RunError::NoDocument )?;
Ok( probe )
}
/// The WebGL2 context ltk's GPU canvas draws through: premultiplied alpha as it writes it, and no depth, stencil or multisampling, which it does not use.
fn webgl2( canvas: &HtmlCanvasElement ) -> Option<WebGl2RenderingContext>
{
let options = js_sys::Object::new();
for ( key, value ) in [ ( "alpha", true ), ( "premultipliedAlpha", true ), ( "antialias", false ), ( "depth", false ), ( "stencil", false ), ( "preserveDrawingBuffer", false ) ]
{
js_sys::Reflect::set( &options, &JsValue::from_str( key ), &JsValue::from_bool( value ) ).ok()?;
}
canvas.get_context_with_context_options( "webgl2", &options ).ok()??.dyn_into().ok()
}
fn context_2d( canvas: &HtmlCanvasElement ) -> Option<CanvasRenderingContext2d>
{
canvas.get_context( "2d" ).ok()??.dyn_into().ok()
}