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.
267 lines
7.5 KiB
Rust
267 lines
7.5 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||
|
||
//! The runtime driven on [`MockPlatform`]: input through the backend-neutral entry points, a step of the run loop, and what reached the platform.
|
||
|
||
use std::sync::MutexGuard;
|
||
|
||
use calloop::EventLoop;
|
||
|
||
use crate::app::App;
|
||
use crate::input::event::{ KeyInput, PointerButton, PointerInput, PointerKind };
|
||
use crate::osk::Purpose;
|
||
use crate::platform::mock::{ MockPlatform, Request };
|
||
use crate::types::Rect;
|
||
use crate::widget::WidgetHandlers;
|
||
use crate::{ button, column, text_edit, Element, Keysym };
|
||
|
||
use super::{ AppData, Sources, SurfaceFocus, SurfaceState };
|
||
use super::surface::Insets;
|
||
|
||
const WIDTH: u32 = 400;
|
||
const HEIGHT: u32 = 300;
|
||
|
||
#[ derive( Clone, Debug, PartialEq ) ]
|
||
enum Msg
|
||
{
|
||
Pressed,
|
||
Typed( String ),
|
||
}
|
||
|
||
#[ derive( Default ) ]
|
||
struct TestApp
|
||
{
|
||
value: String,
|
||
secure: bool,
|
||
purpose: Purpose,
|
||
presses: u32,
|
||
}
|
||
|
||
impl App for TestApp
|
||
{
|
||
type Message = Msg;
|
||
|
||
fn app_id( &self ) -> &str { "net.liberux.ltk.Test" }
|
||
fn save_state( &self ) -> Option<Vec<u8>> { None }
|
||
fn restore_state( &mut self, _state: Vec<u8> ) {}
|
||
|
||
fn view( &self ) -> Element<Msg>
|
||
{
|
||
column()
|
||
.push( button( "OK" ).on_press( Msg::Pressed ) )
|
||
.push( text_edit( "", self.value.clone() ).secure( self.secure ).purpose( self.purpose ).on_change( Msg::Typed ) )
|
||
.into()
|
||
}
|
||
|
||
fn update( &mut self, msg: Msg )
|
||
{
|
||
match msg
|
||
{
|
||
Msg::Pressed => self.presses += 1,
|
||
Msg::Typed( text ) => self.value = text,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Field order matters: `data` holds a handle into `event_loop` and drops first.
|
||
struct Harness
|
||
{
|
||
data: AppData<TestApp, MockPlatform>,
|
||
_event_loop: EventLoop<'static, AppData<TestApp, MockPlatform>>,
|
||
_globals: MutexGuard<'static, ()>,
|
||
}
|
||
|
||
impl Harness
|
||
{
|
||
/// `app` on a configured `WIDTH × HEIGHT` main surface at scale 1, not drawn yet.
|
||
fn new( app: TestApp ) -> Self
|
||
{
|
||
let globals = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
|
||
let event_loop: EventLoop<'static, AppData<TestApp, MockPlatform>> = EventLoop::try_new().expect( "event loop" );
|
||
let main = SurfaceState::new( (), 0.0, String::new() );
|
||
let mut data = AppData::new( app, MockPlatform::default(), main, Sources::new( event_loop.handle() ), false, None );
|
||
data.main.set_size( WIDTH, HEIGHT );
|
||
data.on_main_resized( WIDTH, HEIGHT );
|
||
Self { data, _event_loop: event_loop, _globals: globals }
|
||
}
|
||
|
||
/// One iteration of the run loop's backend-neutral part. Returns what reached the platform since the last step.
|
||
fn step( &mut self ) -> Vec<Request>
|
||
{
|
||
self.data.process_messages();
|
||
self.data.draw_due();
|
||
self.data.platform.take_requests()
|
||
}
|
||
|
||
/// What the backend does when the system says the last frame is on screen.
|
||
fn frame_done( &mut self )
|
||
{
|
||
self.data.main.frame_pending = false;
|
||
}
|
||
|
||
fn rect_of( &self, pick: impl Fn( &WidgetHandlers<Msg> ) -> bool ) -> Rect
|
||
{
|
||
self.data.main.frame.widget_rects.iter()
|
||
.find( |w| pick( &w.handlers ) )
|
||
.map( |w| w.rect )
|
||
.expect( "widget laid out" )
|
||
}
|
||
|
||
fn button_rect( &self ) -> Rect
|
||
{
|
||
self.rect_of( |h| matches!( h, WidgetHandlers::Button { .. } ) )
|
||
}
|
||
|
||
fn field_rect( &self ) -> Rect
|
||
{
|
||
self.rect_of( |h| h.is_text_input() )
|
||
}
|
||
|
||
fn pointer( &mut self, kind: PointerKind, r: Rect )
|
||
{
|
||
let position = ( ( r.x + r.width / 2.0 ) as f64, ( r.y + r.height / 2.0 ) as f64 );
|
||
self.data.pointer_input( &PointerInput { focus: SurfaceFocus::Main, position, kind } );
|
||
}
|
||
|
||
fn hover( &mut self, r: Rect )
|
||
{
|
||
self.pointer( PointerKind::Enter, r );
|
||
self.pointer( PointerKind::Motion, r );
|
||
}
|
||
|
||
fn click( &mut self, r: Rect )
|
||
{
|
||
self.hover( r );
|
||
self.pointer( PointerKind::Press( PointerButton::Left ), r );
|
||
self.pointer( PointerKind::Release( PointerButton::Left ), r );
|
||
}
|
||
|
||
fn key( &mut self, keysym: Keysym, text: &str )
|
||
{
|
||
let key = KeyInput::synthetic( keysym, Some( text.to_string() ) );
|
||
self.data.key_press( key.clone() );
|
||
self.data.key_release( &key );
|
||
}
|
||
}
|
||
|
||
/// The damage of every presentation among `requests`.
|
||
fn presented( requests: &[Request] ) -> Vec<Vec<Rect>>
|
||
{
|
||
requests.iter().filter_map( |r| match r
|
||
{
|
||
Request::Present { damage, .. } => Some( damage.clone() ),
|
||
_ => None,
|
||
} ).collect()
|
||
}
|
||
|
||
#[ test ]
|
||
fn the_first_frame_is_presented_whole_and_asks_for_the_next()
|
||
{
|
||
let mut h = Harness::new( TestApp::default() );
|
||
let requests = h.step();
|
||
assert!( requests.contains( &Request::Present { frame: Some( SurfaceFocus::Main ), damage: Vec::new(), size: ( WIDTH, HEIGHT ) } ) );
|
||
assert!( h.data.main.frame_pending );
|
||
assert!( !h.data.main.content_dirty );
|
||
}
|
||
|
||
#[ test ]
|
||
fn nothing_is_drawn_while_a_frame_is_pending_or_when_nothing_changed()
|
||
{
|
||
let mut h = Harness::new( TestApp::default() );
|
||
h.step();
|
||
let button = h.button_rect();
|
||
h.hover( button );
|
||
assert!( presented( &h.step() ).is_empty() );
|
||
h.frame_done();
|
||
assert_eq!( presented( &h.step() ).len(), 1 );
|
||
h.frame_done();
|
||
assert!( presented( &h.step() ).is_empty() );
|
||
}
|
||
|
||
#[ test ]
|
||
fn a_click_on_a_button_reaches_update()
|
||
{
|
||
let mut h = Harness::new( TestApp::default() );
|
||
h.step();
|
||
let button = h.button_rect();
|
||
h.click( button );
|
||
h.step();
|
||
assert_eq!( h.data.app.presses, 1 );
|
||
}
|
||
|
||
#[ test ]
|
||
fn hovering_a_widget_presents_only_its_rect()
|
||
{
|
||
let mut h = Harness::new( TestApp::default() );
|
||
h.step();
|
||
h.frame_done();
|
||
let button = h.button_rect();
|
||
h.hover( button );
|
||
let damage = presented( &h.step() );
|
||
assert_eq!( damage.len(), 1 );
|
||
let rects = &damage[ 0 ];
|
||
assert!( !rects.is_empty(), "a hover should repaint part of the surface, not all of it" );
|
||
let area: f32 = rects.iter().map( |r| r.width * r.height ).sum();
|
||
assert!( area < ( WIDTH * HEIGHT ) as f32 / 2.0 );
|
||
assert!( rects.iter().all( |r| r.x >= 0.0 && r.y >= 0.0 && r.x + r.width <= WIDTH as f32 && r.y + r.height <= HEIGHT as f32 ) );
|
||
}
|
||
|
||
#[ test ]
|
||
fn a_failed_presentation_leaves_the_next_frame_to_repaint_whole()
|
||
{
|
||
let mut h = Harness::new( TestApp::default() );
|
||
h.data.platform.fail_present = true;
|
||
assert!( presented( &h.step() ).is_empty() );
|
||
assert!( !h.data.main.frame_pending );
|
||
assert!( h.data.main.content_dirty );
|
||
|
||
h.data.platform.fail_present = false;
|
||
let button = h.button_rect();
|
||
h.hover( button );
|
||
assert_eq!( presented( &h.step() ), vec![ Vec::new() ] );
|
||
}
|
||
|
||
#[ test ]
|
||
fn a_secure_field_asks_the_input_method_for_a_password()
|
||
{
|
||
let mut h = Harness::new( TestApp { secure: true, ..TestApp::default() } );
|
||
h.step();
|
||
let field = h.field_rect();
|
||
h.click( field );
|
||
assert!( h.step().contains( &Request::TextInput { secure: true, purpose: Purpose::Normal } ) );
|
||
}
|
||
|
||
#[ test ]
|
||
fn a_field_hands_its_purpose_to_the_input_method()
|
||
{
|
||
let mut h = Harness::new( TestApp { purpose: Purpose::Terminal, ..TestApp::default() } );
|
||
h.step();
|
||
let field = h.field_rect();
|
||
h.click( field );
|
||
assert!( h.step().contains( &Request::TextInput { secure: false, purpose: Purpose::Terminal } ) );
|
||
}
|
||
|
||
#[ test ]
|
||
fn the_view_is_laid_out_inside_the_insets()
|
||
{
|
||
let mut h = Harness::new( TestApp::default() );
|
||
h.data.main.insets = Insets { top: 40.0, right: 0.0, bottom: 30.0, left: 10.0 };
|
||
h.step();
|
||
let button = h.button_rect();
|
||
assert!( button.y >= 40.0 && button.x >= 10.0, "button at {button:?}" );
|
||
}
|
||
|
||
#[ test ]
|
||
fn copying_a_selection_publishes_the_clipboard()
|
||
{
|
||
let mut h = Harness::new( TestApp { value: "hola".to_string(), ..TestApp::default() } );
|
||
h.step();
|
||
let field = h.field_rect();
|
||
h.click( field );
|
||
h.data.modifiers_changed( true, false, false );
|
||
h.key( Keysym::a, "a" );
|
||
h.key( Keysym::c, "c" );
|
||
assert!( h.step().contains( &Request::Clipboard( "hola".to_string() ) ) );
|
||
assert_eq!( h.data.clipboard, "hola" );
|
||
}
|