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
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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user