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
+12 -14
View File
@@ -8,6 +8,7 @@ use crate::platform::{ NativePlatform, Platform };
use crate::types::Point;
use super::repeat::{ ButtonRepeatState, KeyRepeatState };
use super::sources::Sources;
use super::surface::{ SurfaceFocus, SurfaceState };
use super::tooltip::{ TooltipPending, TooltipVisible };
@@ -50,11 +51,8 @@ pub struct AppData<A: App, P: Platform = NativePlatform<A>>
pub shift_pressed: bool,
pub ctrl_pressed: bool,
pub alt_pressed: bool,
/// Calloop handle for inserting timers / channels. Used by the
/// key-repeat machinery and any future feature that needs to
/// schedule work on the run loop without going through the message
/// queue.
pub loop_handle: calloop::LoopHandle<'static, Self>,
/// Timers and channels on the backend's run loop.
pub sources: Sources<Self>,
/// Repeat rate (events per second) advertised by the compositor in
/// `wl_keyboard.repeat_info`. `0` means "the compositor disabled
/// repeat" — we honour that and never start a repeat timer.
@@ -80,7 +78,7 @@ pub struct AppData<A: App, P: Platform = NativePlatform<A>>
/// with [`Self::last_press_pos`] it lets the press handler
/// detect a double-click on a TextEdit and turn it into a
/// word-select.
pub last_press_time: Option<std::time::Instant>,
pub last_press_time: Option<crate::time::Instant>,
/// Position of the previous press. See [`Self::last_press_time`].
pub last_press_pos: Option<Point>,
pub debug_layout: bool,
@@ -275,7 +273,7 @@ impl<A: App, P: Platform> AppData<A, P>
{
if let Some( ref mut a ) = self.a11y
{
let sf = self.main.scale_factor.max( 1 ) as f64;
let sf = self.main.scale_factor as f64;
a.set_window_bounds( ( w as f64 ) * sf, ( h as f64 ) * sf );
a.set_window_focus( true );
}
@@ -284,11 +282,11 @@ impl<A: App, P: Platform> AppData<A, P>
// callbacks (`on_drag_move`, `on_drop`) work in. A configure's size is
// logical, so multiply by the current buffer scale before handing the
// dimensions to the app.
let sf = self.main.scale_factor.max( 1 ) as u32;
crate::types::set_viewport_size( w * sf, h * sf );
crate::types::set_density( sf as f32 );
self.app.on_scale_changed( sf );
self.app.on_resize( w * sf, h * sf );
let ( pw, ph ) = ( self.main.physical_width(), self.main.physical_height() );
crate::types::set_viewport_size( pw, ph );
crate::types::set_density( self.main.scale_factor );
self.app.on_scale_changed( self.main.scale_factor.round().max( 1.0 ) as u32 );
self.app.on_resize( pw, ph );
// `on_resize` may flip app-state that the view depends on (apps that
// branch on the new dimensions, layout caches keyed by size, …), so
// drop the cached tree to force a rebuild on the next draw.
@@ -315,9 +313,9 @@ impl<A: App, P: Platform> AppData<A, P>
pub( crate ) fn next_long_press_wakeup( &self ) -> Option<std::time::Duration>
{
let dur = self.app.long_press_duration();
let now = std::time::Instant::now();
let now = crate::time::Instant::now();
let mut soonest: Option<std::time::Duration> = None;
let mut consider = |start: Option<std::time::Instant>|
let mut consider = |start: Option<crate::time::Instant>|
{
if let Some( s ) = start
{
+2 -2
View File
@@ -98,8 +98,8 @@ impl<A: App, P: Platform> AppData<A, P>
} );
let ss = self.surface( focus );
let sw = ss.width as f32 * ss.scale_factor.max( 1 ) as f32;
let sh = ss.height as f32 * ss.scale_factor.max( 1 ) as f32;
let sw = ss.width as f32 * ss.scale_factor;
let sh = ss.height as f32 * ss.scale_factor;
let mut x = pos.x;
let mut y = pos.y;
// Clamp inside the surface with a small breathing margin.
+1 -1
View File
@@ -130,7 +130,7 @@ impl<A: App, P: Platform> AppData<A, P>
{
if !matches!( focus, SurfaceFocus::Main ) { return None; }
if !self.main_is_toplevel() { return None; }
let sf = self.surface( focus ).scale_factor.max( 1 ) as f32;
let sf = self.surface( focus ).scale_factor;
let border = Self::RESIZE_BORDER_LOGICAL * sf;
let w = self.surface( focus ).physical_width() as f32;
let h = self.surface( focus ).physical_height() as f32;
+1 -1
View File
@@ -17,7 +17,7 @@ impl<A: App, P: Platform> AppData<A, P>
pub( crate ) fn check_long_press_deadlines( &mut self )
{
let dur = self.app.long_press_duration();
let now = std::time::Instant::now();
let now = crate::time::Instant::now();
// Returns `(lp_msg_opt, ds_msg_opt, origin, text_idx_opt)`:
// - `lp_msg_opt = Some` → user-set on_long_press fires; push msg
+5
View File
@@ -41,6 +41,9 @@ pub enum RunError
/// On Android, `ltk::run` was called before `android_main` handed
/// its `AndroidApp` to `ltk::set_android_app`.
NoAndroidApp,
/// In a browser, `ltk::run` was called where there is no page:
/// no `window` or `document`, as in a worker.
NoDocument,
}
impl std::fmt::Display for RunError
@@ -59,6 +62,8 @@ impl std::fmt::Display for RunError
write!( f, "Wayland protocol `{name}` unavailable: {detail}" ),
Self::NoAndroidApp =>
write!( f, "no AndroidApp: call ltk::set_android_app from android_main before ltk::run" ),
Self::NoDocument =>
write!( f, "no window or document: ltk::run runs on a page's main thread" ),
}
}
}
+6 -8
View File
@@ -44,7 +44,7 @@ pub( crate ) fn draw_frame<A: App, P: Platform>( data: &mut AppData<A, P> ) -> b
Some( SurfaceFocus::Main ),
);
data.main.needs_redraw = false;
data.main.last_draw = std::time::Instant::now();
data.main.last_draw = crate::time::Instant::now();
if !data.first_frame_committed
{
data.first_frame_committed = true;
@@ -60,8 +60,7 @@ pub( crate ) fn draw_frame<A: App, P: Platform>( data: &mut AppData<A, P> ) -> b
for ( _, ss ) in data.secondaries.iter_mut()
{
if !ss.configured || !ss.needs_redraw || ss.frame_pending { continue; }
let scale = ss.scale_factor.max( 1 ) as u32;
let view = data.app.secondary_view( ss.width * scale, ss.height * scale )
let view = data.app.secondary_view( ss.physical_width(), ss.physical_height() )
.unwrap_or_else( || crate::spacer().into() );
// No frame callback: these surfaces are static between invalidations,
// so there is no per-frame pacing to keep — and a callback that never
@@ -69,7 +68,7 @@ pub( crate ) fn draw_frame<A: App, P: Platform>( data: &mut AppData<A, P> ) -> b
draw_surface( platform, ss, &view, main_bg, None, None, debug_layout, None );
ss.needs_redraw = false;
ss.frame_pending = false;
ss.last_draw = std::time::Instant::now();
ss.last_draw = crate::time::Instant::now();
}
for spec in overlays
@@ -95,7 +94,7 @@ pub( crate ) fn draw_frame<A: App, P: Platform>( data: &mut AppData<A, P> ) -> b
Some( focus ),
);
ss.needs_redraw = false;
ss.last_draw = std::time::Instant::now();
ss.last_draw = crate::time::Instant::now();
data.app.on_overlay_frame_committed( spec.id );
if std::mem::take( &mut ss.frame.blur_changed )
{
@@ -124,12 +123,11 @@ fn draw_surface<P: Platform, Msg: Clone>(
frame: Option<SurfaceFocus>,
)
{
let scale = ss.scale_factor.max( 1 ) as u32;
let scale = ss.scale_factor;
let w = ss.width;
let h = ss.height;
if w == 0 || h == 0 { return; }
let pw = w * scale;
let ph = h * scale;
let ( pw, ph ) = ( ss.physical_width(), ss.physical_height() );
let canvas_ready = ss.canvas.as_ref()
.map( |c| c.size() == ( pw, ph ) )
+3
View File
@@ -12,8 +12,10 @@ pub( crate ) mod osk;
pub( crate ) mod perf;
pub( crate ) mod repeat;
pub( crate ) mod setup;
pub( crate ) mod sources;
pub( crate ) mod surface;
pub( crate ) mod text_editing;
#[ cfg( not( target_arch = "wasm32" ) ) ]
pub( crate ) mod text_scale;
pub( crate ) mod tooltip;
@@ -26,6 +28,7 @@ pub( crate ) mod invalidation;
mod tests;
pub( crate ) use app_data::AppData;
pub( crate ) use sources::Sources;
pub( crate ) use surface::{ FrameState, SurfaceFocus, SurfaceState };
pub use error::RunError;
#[ cfg( feature = "test-support" ) ]
+5 -5
View File
@@ -3,7 +3,7 @@
//! The built-in keyboard under a focused text field, for a session whose compositor offers no input method.
use std::time::{ Duration, Instant };
use std::time::Duration;
use super::app_data::AppData;
use super::surface::{ Insets, SurfaceFocus };
@@ -12,6 +12,7 @@ use crate::input::event::KeyInput;
use crate::osk::{ Event, Modifiers, Panel };
use crate::platform::Platform;
use crate::Keysym;
use crate::time::Instant;
use crate::tree::find_handlers;
use crate::types::{ Point, Rect };
@@ -51,12 +52,11 @@ impl<A: App, P: Platform> AppData<A, P>
let ss = self.surface_mut( focus );
if ss.osk.is_none()
{
let lang = std::env::var( "LANG" ).unwrap_or_default();
ss.osk = Some( Panel::for_language( &lang ) );
ss.osk = Some( Panel::for_language( &crate::platform::language() ) );
}
if let Some( panel ) = ss.osk.as_mut() { panel.set_purpose( purpose ); }
let sf = ss.scale_factor.max( 1 ) as f32;
let ( pw, ph ) = ( ss.width * ss.scale_factor.max( 1 ) as u32, ss.height * ss.scale_factor.max( 1 ) as u32 );
let sf = ss.scale_factor;
let ( pw, ph ) = ( ss.physical_width(), ss.physical_height() );
place_osk( &mut ss.osk, pw, ph, sf, ss.insets );
ss.request_redraw();
}
+3 -1
View File
@@ -13,7 +13,9 @@
//! [`App::cap_software_animation`](crate::App::cap_software_animation)).
use std::sync::OnceLock;
use std::time::{ Duration, Instant };
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`.
+7 -8
View File
@@ -1,10 +1,10 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use calloop::RegistrationToken;
use crate::input::event::KeyInput;
use super::sources::TimerToken;
/// Snapshot of the key whose repeat timer is currently armed.
///
/// Stored on [`super::app_data::AppData::key_repeat`] between press and
@@ -17,7 +17,7 @@ use crate::input::event::KeyInput;
pub( crate ) struct KeyRepeatState
{
pub event: KeyInput,
pub token: RegistrationToken,
pub token: TimerToken,
}
/// Snapshot of the button whose press-and-hold repeat timer is
@@ -42,10 +42,9 @@ pub( crate ) struct KeyRepeatState
/// wrong widget's message.
pub( crate ) struct ButtonRepeatState
{
/// calloop registration handle so [`super::app_data::AppData::stop_button_repeat`]
/// can remove the timer from the event loop without leaking the
/// source. The surface focus and pressed `flat_idx` live in
/// the timer's closure capture instead of here — the only
/// Lets [`super::app_data::AppData::stop_button_repeat`] remove the
/// timer from the run loop. The surface focus and pressed `flat_idx`
/// live in the timer's closure capture instead of here — the only
/// caller that needs to act on them is the closure itself.
pub token: RegistrationToken,
pub token: TimerToken,
}
+22 -30
View File
@@ -5,10 +5,6 @@
use std::collections::HashMap;
use calloop::LoopHandle;
use calloop::channel::{ self, Event };
use calloop::timer::{ TimeoutAction, Timer };
use crate::a11y::A11yState;
use crate::app::App;
use crate::platform::Platform;
@@ -16,11 +12,12 @@ use crate::types::Point;
use super::{ AppData, RunError, SurfaceFocus, SurfaceState };
use super::perf::PerfState;
use super::sources::Sources;
impl<A: App, P: Platform> AppData<A, P>
{
/// `app` on `platform` with `main` as its only surface, nothing focused, pressed or queued.
pub( crate ) fn new( app: A, platform: P, main: SurfaceState<A::Message, P::Surface>, loop_handle: LoopHandle<'static, Self>, builtin_osk: bool, a11y: Option<A11yState> ) -> Self
pub( crate ) fn new( app: A, platform: P, main: SurfaceState<A::Message, P::Surface>, sources: Sources<Self>, builtin_osk: bool, a11y: Option<A11yState> ) -> Self
{
let fullscreen = app.start_fullscreen();
Self
@@ -39,7 +36,7 @@ impl<A: App, P: Platform> AppData<A, P>
shift_pressed: false,
ctrl_pressed: false,
alt_pressed: false,
loop_handle,
sources,
compositor_repeat_rate: 0,
compositor_repeat_delay: 0,
key_repeat: None,
@@ -74,44 +71,39 @@ impl<A: App, P: Platform> AppData<A, P>
/// Let the app send messages from any thread, follow the desktop's text scale and wake on [`App::poll_interval`].
pub( crate ) fn install_sources( &mut self ) -> Result<(), RunError>
{
let handle = self.loop_handle.clone();
// Only queued: the run loop's `App::invalidate_after` decides which surfaces the message redraws.
let ( sender, messages ) = channel::channel::<A::Message>();
handle.insert_source( messages, |event, _, data: &mut Self|
{
if let Event::Msg( msg ) = event { data.pending_msgs.push( msg ); }
} )
.map_err( |e| RunError::EventLoop( format!( "channel insert_source: {e:?}" ) ) )?;
let sender = self.sources.channel( |msg, data: &mut Self| data.pending_msgs.push( msg ) )
.map_err( |e| RunError::EventLoop( format!( "channel insert_source: {e}" ) ) )?;
self.app.set_channel_sender( sender );
// Font resolution multiplies by `text_scale()` at paint time, so a new factor only needs a repaint.
let ( tx, factors ) = channel::channel::<f32>();
handle.insert_source( factors, |event, _, data: &mut Self|
#[ cfg( not( target_arch = "wasm32" ) ) ]
{
let Event::Msg( factor ) = event else { return };
if ( factor - crate::types::text_scale() ).abs() < f32::EPSILON { return; }
crate::types::set_text_scale( factor );
data.dirty_caches();
data.main.request_redraw();
for ss in data.overlays.values_mut()
let tx = self.sources.channel( |factor: f32, data: &mut Self|
{
ss.request_redraw();
}
} )
.map_err( |e| RunError::EventLoop( format!( "text-scale insert_source: {e:?}" ) ) )?;
super::text_scale::spawn_watcher( tx );
if ( factor - crate::types::text_scale() ).abs() < f32::EPSILON { return; }
crate::types::set_text_scale( factor );
data.dirty_caches();
data.main.request_redraw();
for ss in data.overlays.values_mut()
{
ss.request_redraw();
}
} )
.map_err( |e| RunError::EventLoop( format!( "text-scale insert_source: {e}" ) ) )?;
super::text_scale::spawn_watcher( tx );
}
if let Some( dur ) = self.app.poll_interval()
{
self.perf.warn_poll_interval( dur );
handle.insert_source( Timer::from_duration( dur ), |_, _, data: &mut Self|
self.sources.insert_timer( dur, |data: &mut Self|
{
let msgs = data.app.poll_external();
data.pending_msgs.extend( msgs );
TimeoutAction::ToDuration( data.app.poll_interval().unwrap_or( std::time::Duration::from_secs( 1 ) ) )
Some( data.app.poll_interval().unwrap_or( std::time::Duration::from_secs( 1 ) ) )
} )
.map_err( |e| RunError::EventLoop( format!( "poll timer insert_source: {e:?}" ) ) )?;
.map_err( |e| RunError::EventLoop( format!( "poll timer insert_source: {e}" ) ) )?;
}
Ok( () )
}
+190
View File
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The timers and channels the runtime puts on the backend's run loop, held by `AppData::sources`: calloop's natively; in a browser, a queue the web backend runs from its own loop.
use std::time::Duration;
#[ cfg( not( target_arch = "wasm32" ) ) ]
use calloop::channel::Event;
#[ cfg( not( target_arch = "wasm32" ) ) ]
use calloop::timer::{ TimeoutAction, Timer };
#[ cfg( not( target_arch = "wasm32" ) ) ]
use calloop::{ LoopHandle, RegistrationToken };
use crate::app::ChannelSender;
#[ cfg( not( target_arch = "wasm32" ) ) ]
pub( crate ) struct Sources<D: 'static>
{
handle: LoopHandle<'static, D>,
}
/// Names an armed timer to [`Sources::remove`].
#[ cfg( not( target_arch = "wasm32" ) ) ]
pub( crate ) struct TimerToken( RegistrationToken );
#[ cfg( not( target_arch = "wasm32" ) ) ]
impl<D: 'static> Sources<D>
{
pub( crate ) fn new( handle: LoopHandle<'static, D> ) -> Self
{
Self { handle }
}
/// Run `tick` after `delay`, and again after every duration it returns, until it returns `None`.
pub( crate ) fn insert_timer( &self, delay: Duration, mut tick: impl FnMut( &mut D ) -> Option<Duration> + 'static ) -> Result<TimerToken, String>
{
self.handle.insert_source( Timer::from_duration( delay ), move |_, _, data|
{
match tick( data )
{
Some( next ) => TimeoutAction::ToDuration( next ),
None => TimeoutAction::Drop,
}
} )
.map( TimerToken )
.map_err( |e| format!( "{e:?}" ) )
}
pub( crate ) fn remove( &self, token: TimerToken )
{
self.handle.remove( token.0 );
}
/// A sender whose messages reach `on_msg` on the run loop.
pub( crate ) fn channel<T: 'static>( &self, mut on_msg: impl FnMut( T, &mut D ) + 'static ) -> Result<ChannelSender<T>, String>
{
let ( sender, messages ) = calloop::channel::channel::<T>();
self.handle.insert_source( messages, move |event, _, data|
{
if let Event::Msg( msg ) = event { on_msg( msg, data ); }
} )
.map_err( |e| format!( "{e:?}" ) )?;
Ok( ChannelSender::from_calloop( sender ) )
}
}
#[ cfg( target_arch = "wasm32" ) ]
pub( crate ) type Tick<D> = Box<dyn FnMut( &mut D ) -> Option<Duration>>;
#[ cfg( target_arch = "wasm32" ) ]
pub( crate ) type Drain<D> = Box<dyn FnMut( &mut D )>;
#[ cfg( target_arch = "wasm32" ) ]
struct Armed<D>
{
id: u64,
due: crate::time::Instant,
tick: Tick<D>,
}
/// The backend takes the due timers out with [`Self::take_due`], runs them on its `AppData` and hands them back to [`Self::rearm`]; likewise the channels with [`Self::take_channels`] and [`Self::restore_channels`].
#[ cfg( target_arch = "wasm32" ) ]
pub( crate ) struct Sources<D: 'static>
{
wake: fn(),
next_id: std::cell::Cell<u64>,
armed: std::cell::RefCell<Vec<Armed<D>>>,
/// Taken out by `take_due` and not handed back yet.
running: std::cell::RefCell<Vec<u64>>,
/// Removed while running, so `rearm` drops them.
cancelled: std::cell::RefCell<Vec<u64>>,
channels: std::cell::RefCell<Vec<Drain<D>>>,
}
#[ cfg( target_arch = "wasm32" ) ]
pub( crate ) struct TimerToken( u64 );
#[ cfg( target_arch = "wasm32" ) ]
impl<D: 'static> Sources<D>
{
/// `wake` asks the backend for an iteration once a message is sent.
pub( crate ) fn new( wake: fn() ) -> Self
{
Self
{
wake,
next_id: std::cell::Cell::new( 0 ),
armed: std::cell::RefCell::new( Vec::new() ),
running: std::cell::RefCell::new( Vec::new() ),
cancelled: std::cell::RefCell::new( Vec::new() ),
channels: std::cell::RefCell::new( Vec::new() ),
}
}
/// Run `tick` after `delay`, and again after every duration it returns, until it returns `None`.
pub( crate ) fn insert_timer( &self, delay: Duration, tick: impl FnMut( &mut D ) -> Option<Duration> + 'static ) -> Result<TimerToken, String>
{
let id = self.next_id.get();
self.next_id.set( id + 1 );
self.armed.borrow_mut().push( Armed { id, due: crate::time::Instant::now() + delay, tick: Box::new( tick ) } );
Ok( TimerToken( id ) )
}
pub( crate ) fn remove( &self, token: TimerToken )
{
let mut armed = self.armed.borrow_mut();
if let Some( i ) = armed.iter().position( |t| t.id == token.0 )
{
armed.remove( i );
} else if self.running.borrow().contains( &token.0 ) {
self.cancelled.borrow_mut().push( token.0 );
}
}
/// A sender whose messages reach `on_msg` when the backend drains the channels.
pub( crate ) fn channel<T: 'static>( &self, mut on_msg: impl FnMut( T, &mut D ) + 'static ) -> Result<ChannelSender<T>, String>
{
let ( sender, messages ) = crate::channel::channel::<T>( self.wake );
self.channels.borrow_mut().push( Box::new( move |data|
{
while let Ok( msg ) = messages.try_recv() { on_msg( msg, data ); }
} ) );
Ok( sender )
}
/// The timers due by `now`, with their ids.
pub( crate ) fn take_due( &self, now: crate::time::Instant ) -> Vec<( u64, Tick<D> )>
{
let ( due, later ): ( Vec<_>, Vec<_> ) = std::mem::take( &mut *self.armed.borrow_mut() ).into_iter().partition( |t| t.due <= now );
self.armed.borrow_mut().extend( later );
self.running.borrow_mut().extend( due.iter().map( |t| t.id ) );
due.into_iter().map( |t| ( t.id, t.tick ) ).collect()
}
/// Arm a timer [`Self::take_due`] gave out again after `next`, unless it returned `None` or was removed meanwhile.
pub( crate ) fn rearm( &self, id: u64, tick: Tick<D>, next: Option<Duration> )
{
self.running.borrow_mut().retain( |r| *r != id );
let mut cancelled = self.cancelled.borrow_mut();
if let Some( i ) = cancelled.iter().position( |c| *c == id )
{
cancelled.remove( i );
return;
}
if let Some( next ) = next
{
self.armed.borrow_mut().push( Armed { id, due: crate::time::Instant::now() + next, tick } );
}
}
/// When the next timer is due.
pub( crate ) fn next_due( &self ) -> Option<crate::time::Instant>
{
self.armed.borrow().iter().map( |t| t.due ).min()
}
pub( crate ) fn take_channels( &self ) -> Vec<Drain<D>>
{
std::mem::take( &mut *self.channels.borrow_mut() )
}
/// Hand back what [`Self::take_channels`] took, keeping any channel created meanwhile.
pub( crate ) fn restore_channels( &self, mut drains: Vec<Drain<D>> )
{
let mut channels = self.channels.borrow_mut();
drains.append( &mut channels );
*channels = drains;
}
}
+17 -10
View File
@@ -134,7 +134,7 @@ pub( crate ) struct SurfaceState<Msg: Clone, S = NativeSurface>
/// `false` so the draw path can take the partial-redraw fast path.
/// Always reset to `false` at the end of a successful draw.
pub content_dirty: bool,
pub last_draw: std::time::Instant,
pub last_draw: crate::time::Instant,
pub focused_idx: Option<usize>,
pub focused_id: Option<WidgetId>,
pub hovered_idx: Option<usize>,
@@ -174,9 +174,10 @@ pub( crate ) struct SurfaceState<Msg: Clone, S = NativeSurface>
pub titlebar_title: String,
/// Rect of the close button in the title bar (for hit testing).
pub titlebar_close_rect: Rect,
/// Compositor scale factor (integer, e.g. 1 or 2). Used to create
/// high-DPI buffers and set `dpi_scale` on the canvas.
pub scale_factor: i32,
/// Physical pixels per logical pixel, set by the backend: whole on
/// Wayland and Android, the browser's `devicePixelRatio` on the web.
/// Used to size the buffers and set `dpi_scale` on the canvas.
pub scale_factor: f32,
pub layer_anchor: Option<crate::app::Anchor>,
/// `true` after the surface has been committed and we are waiting for the
/// matching `wl_surface.frame` callback to fire. Drawing is gated on this
@@ -203,7 +204,7 @@ impl<Msg: Clone, S> SurfaceState<Msg, S>
configured: false,
needs_redraw: false,
content_dirty: true,
last_draw: std::time::Instant::now() - std::time::Duration::from_millis( 100 ),
last_draw: crate::time::Instant::now().checked_sub( std::time::Duration::from_millis( 100 ) ).unwrap_or_else( crate::time::Instant::now ),
focused_idx: None,
focused_id: None,
hovered_idx: None,
@@ -218,7 +219,7 @@ impl<Msg: Clone, S> SurfaceState<Msg, S>
insets: Insets::default(),
titlebar_title,
titlebar_close_rect: Rect::default(),
scale_factor: 1,
scale_factor: 1.0,
layer_anchor: None,
frame_pending: false,
frame_tick_due: false,
@@ -228,23 +229,29 @@ impl<Msg: Clone, S> SurfaceState<Msg, S>
/// Convert a logical (surface-local) position to physical pixel coordinates.
pub( crate ) fn to_physical( &self, x: f64, y: f64 ) -> Point
{
let sf = self.scale_factor.max( 1 ) as f32;
let sf = self.scale_factor;
Point { x: x as f32 * sf, y: y as f32 * sf }
}
/// Surface width in physical pixels (logical × buffer scale). Matches the
/// Surface width in physical pixels (logical × scale, rounded). Matches the
/// coordinate space used by pointer/touch callbacks and layout, so swipe
/// thresholds can be compared against physical `dx` / `start.x` without
/// unit mixing.
pub( crate ) fn physical_width( &self ) -> u32
{
self.width * self.scale_factor.max( 1 ) as u32
( self.width as f32 * self.scale_factor ).round() as u32
}
/// Surface height in physical pixels. See [`Self::physical_width`].
pub( crate ) fn physical_height( &self ) -> u32
{
self.height * self.scale_factor.max( 1 ) as u32
( self.height as f32 * self.scale_factor ).round() as u32
}
/// The scale as a whole number, for protocols that take no other: Wayland's buffer scale.
pub( crate ) fn buffer_scale( &self ) -> i32
{
( self.scale_factor.round() as i32 ).max( 1 )
}
/// Request a redraw and mark the surface's content as dirty.
+2 -2
View File
@@ -15,7 +15,7 @@ use crate::types::Rect;
use crate::widget::WidgetHandlers;
use crate::{ button, column, text_edit, Element, Keysym };
use super::{ AppData, SurfaceFocus, SurfaceState };
use super::{ AppData, Sources, SurfaceFocus, SurfaceState };
use super::surface::Insets;
const WIDTH: u32 = 400;
@@ -79,7 +79,7 @@ impl Harness
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, event_loop.handle(), false, None );
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 }
+1 -1
View File
@@ -33,7 +33,7 @@ impl<A: App, P: Platform> AppData<A, P>
{
notches * WHEEL_LINES * window.metrics.line_h
} else {
logical_px * ss.scale_factor.max( 1 ) as f32
logical_px * ss.scale_factor
};
if !window.can_scroll( delta_px ) { return false; }
let ( next, moved ) = scroll.scrolled( window.first_line, window.max_first, cursor_pos, delta_px, window.metrics.line_h );
+1 -1
View File
@@ -50,7 +50,7 @@ impl<A: App, P: Platform> AppData<A, P>
{
const WINDOW_MS: u128 = 400;
const RADIUS: f32 = 6.0;
let now = std::time::Instant::now();
let now = crate::time::Instant::now();
let is_double = match ( self.last_press_time, self.last_press_pos )
{
( Some( t ), Some( p ) ) =>
+1 -1
View File
@@ -36,7 +36,7 @@ fn unblock_signals( cmd: &mut Command ) -> &mut Command
/// Spawn the watcher thread. `tx` delivers each observed factor to the
/// run loop; the initial `gsettings get` value is sent first.
pub( super ) fn spawn_watcher( tx: calloop::channel::Sender<f32> )
pub( super ) fn spawn_watcher( tx: crate::app::ChannelSender<f32> )
{
let _ = std::thread::Builder::new()
.name( "ltk-text-scale".into() )
+6 -7
View File
@@ -14,7 +14,7 @@ pub struct TooltipPending
{
pub focus: SurfaceFocus,
pub flat_idx: usize,
pub deadline: std::time::Instant,
pub deadline: crate::time::Instant,
pub text: String,
pub anchor: Rect,
}
@@ -47,7 +47,7 @@ impl<A: App, P: Platform> AppData<A, P>
{
focus,
flat_idx,
deadline: std::time::Instant::now() + TOOLTIP_DELAY,
deadline: crate::time::Instant::now() + TOOLTIP_DELAY,
text,
anchor,
} );
@@ -68,13 +68,13 @@ impl<A: App, P: Platform> AppData<A, P>
pub( crate ) fn next_tooltip_wakeup( &self ) -> Option<std::time::Duration>
{
let p = self.tooltip_pending.as_ref()?;
Some( p.deadline.saturating_duration_since( std::time::Instant::now() ) )
Some( p.deadline.saturating_duration_since( crate::time::Instant::now() ) )
}
pub( crate ) fn check_tooltip_deadline( &mut self )
{
let Some( p ) = self.tooltip_pending.as_ref() else { return };
if std::time::Instant::now() < p.deadline { return; }
if crate::time::Instant::now() < p.deadline { return; }
let p = self.tooltip_pending.take().unwrap();
self.tooltip_visible = Some( TooltipVisible
{
@@ -106,11 +106,10 @@ impl<A: App, P: Platform> AppData<A, P>
// the surface exists; before that, main's is the best predictor.
let sf = self.overlays.get( &id )
.map( |ss| ss.scale_factor )
.unwrap_or( self.main.scale_factor )
.max( 1 ) as f32;
.unwrap_or( self.main.scale_factor );
let ( ox, oy ) = self.surface_offset_for( v.focus );
let src_scale = self.try_surface( v.focus )?.scale_factor.max( 1 ) as f32;
let src_scale = self.try_surface( v.focus )?.scale_factor;
let anchor_x = ( ox + v.anchor.x / src_scale ) * sf;
let anchor_y = ( oy + v.anchor.y / src_scale ) * sf;
let anchor_w = v.anchor.width / src_scale * sf;