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:
+12
-14
@@ -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
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" ),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ) )
|
||||
|
||||
@@ -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" ) ]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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( () )
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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 ) ) =>
|
||||
|
||||
@@ -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() )
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user