Files
ltk/src/event_loop/tests.rs
T
admin 20dc21448e
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
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.
2026-10-19 20:20:12 +02:00

267 lines
7.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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" );
}