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:
@@ -92,7 +92,7 @@ impl<A: App> Platform for AndroidPlatform<A>
|
||||
self.egl_context.is_none() || surface.egl_surface.is_none()
|
||||
}
|
||||
|
||||
fn prepare_canvas( &self, surface: &mut AndroidSurfaceState, canvas: &mut Option<Canvas>, pw: u32, ph: u32, scale: u32 ) -> bool
|
||||
fn prepare_canvas( &self, surface: &mut AndroidSurfaceState, canvas: &mut Option<Canvas>, pw: u32, ph: u32, scale: f32 ) -> bool
|
||||
{
|
||||
present::prepare_canvas( self, surface, canvas, pw, ph, scale )
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ pub( super ) fn prepare_canvas<A: App>
|
||||
canvas: &mut Option<Canvas>,
|
||||
pw: u32,
|
||||
ph: u32,
|
||||
scale: u32,
|
||||
scale: f32,
|
||||
) -> bool
|
||||
{
|
||||
if let ( Some( ctx ), Some( es ) ) = ( platform.egl_context.as_ref(), surface.egl_surface.as_ref() )
|
||||
|
||||
@@ -15,7 +15,7 @@ use ndk::looper::{ FdEvent, ForeignLooper };
|
||||
|
||||
use crate::app::App;
|
||||
use crate::egl_context::{ log_software_fallback, EglContext };
|
||||
use crate::event_loop::{ AppData, RunError, SurfaceFocus, SurfaceState };
|
||||
use crate::event_loop::{ AppData, RunError, Sources, SurfaceFocus, SurfaceState };
|
||||
|
||||
use super::{ AndroidPlatform, AndroidSurfaceState };
|
||||
|
||||
@@ -96,7 +96,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
||||
let platform = AndroidPlatform::new( activity.clone(), egl_context );
|
||||
let main = SurfaceState::new( AndroidSurfaceState::default(), 0.0, String::new() );
|
||||
// ltk's own keyboard: a NativeActivity gets only key events from the system's, so composed text and suggestions would be lost.
|
||||
let mut data = AppData::new( app, platform, main, event_loop.handle(), true, None );
|
||||
let mut data = AppData::new( app, platform, main, Sources::new( event_loop.handle() ), true, None );
|
||||
data.install_sources()?;
|
||||
|
||||
while !data.exit_requested
|
||||
|
||||
@@ -79,9 +79,9 @@ impl<A: App> AppData<A>
|
||||
.unwrap_or_else( || content_edges( self.platform.activity.content_rect(), ( ww, wh ) ) );
|
||||
self.main.insets = logical_insets( edges, self.platform.px_per_logical );
|
||||
|
||||
if scale as i32 != self.main.scale_factor
|
||||
if scale as f32 != self.main.scale_factor
|
||||
{
|
||||
self.main.scale_factor = scale as i32;
|
||||
self.main.scale_factor = scale as f32;
|
||||
if let Some( ref mut canvas ) = self.main.canvas { canvas.set_dpi_scale( scale as f32 ); }
|
||||
}
|
||||
self.main.set_size( w, h );
|
||||
|
||||
@@ -104,7 +104,7 @@ impl Platform for MockPlatform
|
||||
true
|
||||
}
|
||||
|
||||
fn prepare_canvas( &self, _surface: &mut (), canvas: &mut Option<Canvas>, pw: u32, ph: u32, scale: u32 ) -> bool
|
||||
fn prepare_canvas( &self, _surface: &mut (), canvas: &mut Option<Canvas>, pw: u32, ph: u32, scale: f32 ) -> bool
|
||||
{
|
||||
fit_canvas( canvas, pw, ph, scale, || Canvas::new( pw, ph ) );
|
||||
true
|
||||
|
||||
+25
-4
@@ -7,12 +7,14 @@
|
||||
//!
|
||||
//! A backend also owns the run loop: it connects to the system, builds `AppData` with `AppData::new`, installs the shared event sources with `AppData::install_sources`, and on every wake-up calls `AppData::before_draw`, `AppData::draw_due` and `AppData::after_draw` around its own work.
|
||||
//!
|
||||
//! The backends are `wayland`, behind the `wayland` feature, and `android`, behind the `android` feature; `build.rs` refuses both at once. Each backend sets the two aliases below and `run` / `try_run` under its own `cfg`, and `build.rs` sets `cfg( backend )`, which builds the runtime, when one is enabled.
|
||||
//! The backends are `wayland`, `android` and `web`, each behind the feature of its name; `build.rs` refuses more than one. Each backend sets the two aliases below and `run` / `try_run` under its own `cfg`, and `build.rs` sets `cfg( backend )`, which builds the runtime, when one is enabled.
|
||||
|
||||
#[ cfg( feature = "android" ) ]
|
||||
pub( crate ) mod android;
|
||||
#[ cfg( feature = "wayland" ) ]
|
||||
pub( crate ) mod wayland;
|
||||
#[ cfg( feature = "web" ) ]
|
||||
pub( crate ) mod web;
|
||||
#[ cfg( test ) ]
|
||||
pub( crate ) mod mock;
|
||||
|
||||
@@ -20,6 +22,8 @@ pub( crate ) mod mock;
|
||||
pub( crate ) use android::run::{ run, try_run };
|
||||
#[ cfg( feature = "wayland" ) ]
|
||||
pub( crate ) use wayland::run::{ run, try_run };
|
||||
#[ cfg( feature = "web" ) ]
|
||||
pub( crate ) use web::run::{ run, try_run };
|
||||
|
||||
use crate::event_loop::SurfaceFocus;
|
||||
use crate::event_loop::cursor_shape::ResizeEdge;
|
||||
@@ -32,12 +36,29 @@ use crate::types::{ Color, CursorShape, Rect };
|
||||
pub( crate ) type NativePlatform<A> = wayland::WaylandPlatform<A>;
|
||||
#[ cfg( feature = "android" ) ]
|
||||
pub( crate ) type NativePlatform<A> = android::AndroidPlatform<A>;
|
||||
#[ cfg( feature = "web" ) ]
|
||||
pub( crate ) type NativePlatform<A> = web::WebPlatform<A>;
|
||||
|
||||
/// The backend's half of every surface, held by `SurfaceState::platform`.
|
||||
#[ cfg( feature = "wayland" ) ]
|
||||
pub( crate ) type NativeSurface = wayland::WaylandSurfaceState;
|
||||
#[ cfg( feature = "android" ) ]
|
||||
pub( crate ) type NativeSurface = android::AndroidSurfaceState;
|
||||
#[ cfg( feature = "web" ) ]
|
||||
pub( crate ) type NativeSurface = web::WebSurfaceState;
|
||||
|
||||
/// The user's language as `LANG` gives it (`es_ES.UTF-8`), or in a browser as the page does (`es-ES`).
|
||||
#[ cfg( not( feature = "web" ) ) ]
|
||||
pub( crate ) fn language() -> String
|
||||
{
|
||||
std::env::var( "LANG" ).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[ cfg( feature = "web" ) ]
|
||||
pub( crate ) fn language() -> String
|
||||
{
|
||||
web_sys::window().and_then( |w| w.navigator().language() ).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// What [`Platform::present`] needs to know about a painted frame. Rects are in physical pixels, top-left origin.
|
||||
pub( crate ) struct Presentation<'a>
|
||||
@@ -49,10 +70,10 @@ pub( crate ) struct Presentation<'a>
|
||||
pub opaque_region: Option<&'a [Rect]>,
|
||||
/// Where the surface takes input; `None` everywhere, empty nowhere.
|
||||
pub input_region: Option<&'a [Rect]>,
|
||||
/// Logical size and buffer scale.
|
||||
/// Logical size and the physical pixels per logical one.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub scale: u32,
|
||||
pub scale: f32,
|
||||
/// Report the next frame to the runtime as one of this surface; `None` for a surface that is not paced by frames.
|
||||
pub frame: Option<SurfaceFocus>,
|
||||
}
|
||||
@@ -72,7 +93,7 @@ pub( crate ) trait Platform: 'static
|
||||
fn precise_damage( &self, surface: &Self::Surface ) -> bool;
|
||||
|
||||
/// Make `canvas` ready to paint `surface` at `pw × ph` physical pixels, creating it on the first frame; `false` when the surface cannot be drawn now.
|
||||
fn prepare_canvas( &self, surface: &mut Self::Surface, canvas: &mut Option<Canvas>, pw: u32, ph: u32, scale: u32 ) -> bool;
|
||||
fn prepare_canvas( &self, surface: &mut Self::Surface, canvas: &mut Option<Canvas>, pw: u32, ph: u32, scale: f32 ) -> bool;
|
||||
|
||||
/// Show what was painted on `canvas` since [`Self::prepare_canvas`]; `false` when nothing reached the screen.
|
||||
fn present( &self, surface: &mut Self::Surface, canvas: &mut Canvas, frame: &Presentation<'_> ) -> bool;
|
||||
|
||||
@@ -57,8 +57,8 @@ fn apply_surface_scale<Msg: Clone>(
|
||||
new_factor: i32,
|
||||
) -> Option<( u32, u32 )>
|
||||
{
|
||||
if new_factor == ss.scale_factor { return None; }
|
||||
ss.scale_factor = new_factor;
|
||||
if new_factor as f32 == ss.scale_factor { return None; }
|
||||
ss.scale_factor = new_factor as f32;
|
||||
surface.set_buffer_scale( new_factor );
|
||||
if let Some( ref mut canvas ) = ss.canvas
|
||||
{
|
||||
@@ -279,7 +279,7 @@ impl<A: App> WindowHandler for AppData<A>
|
||||
if let Some( SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( window.wl_surface() )
|
||||
{
|
||||
let Some( ss ) = self.overlays.get_mut( &id ) else { return };
|
||||
let sf = ss.scale_factor.max( 1 ) as f32;
|
||||
let sf = ss.scale_factor;
|
||||
let ( rw, rh ) = ss.platform.last_requested_size;
|
||||
let fallback = ( ( rw as f32 / sf ).round().max( 1.0 ) as u32, ( rh as f32 / sf ).round().max( 1.0 ) as u32 );
|
||||
let w = configure.new_size.0.map( |v| v.get() ).unwrap_or( fallback.0 );
|
||||
|
||||
@@ -96,7 +96,7 @@ impl<A: App> Platform for WaylandPlatform<A>
|
||||
self.egl_context.is_none() || surface.egl_surface.is_none()
|
||||
}
|
||||
|
||||
fn prepare_canvas( &self, surface: &mut WaylandSurfaceState, canvas: &mut Option<Canvas>, pw: u32, ph: u32, scale: u32 ) -> bool
|
||||
fn prepare_canvas( &self, surface: &mut WaylandSurfaceState, canvas: &mut Option<Canvas>, pw: u32, ph: u32, scale: f32 ) -> bool
|
||||
{
|
||||
present::prepare_canvas( self, surface, canvas, pw, ph, scale )
|
||||
}
|
||||
|
||||
@@ -122,8 +122,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
||||
SurfaceKind::Window( ref w ) => Some( w.xdg_surface().clone() ),
|
||||
_ => None,
|
||||
};
|
||||
let parent_scale_i = data.main.scale_factor.max( 1 );
|
||||
let parent_scale = parent_scale_i as f32;
|
||||
let parent_scale = data.main.scale_factor;
|
||||
// `OverlaySpec::size` carries `Length`s resolved against the main
|
||||
// surface's physical viewport (the app's layout space), so an overlay
|
||||
// sized with `Length::widget( … )` scales with the display exactly like
|
||||
@@ -377,7 +376,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
||||
window.set_max_size( Some( ( w, h ) ) );
|
||||
window.commit();
|
||||
let mut ss = SurfaceState::new( WaylandSurfaceState::from( SurfaceKind::Window( window ) ), 0.0, String::new() );
|
||||
ss.scale_factor = parent_scale_i;
|
||||
ss.scale_factor = parent_scale;
|
||||
ss.platform.last_requested_size = resolved_size;
|
||||
overlays_m.insert( spec.id, ss );
|
||||
continue;
|
||||
@@ -407,7 +406,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
||||
// HiDPI buffer and lays out at the right size from frame one,
|
||||
// instead of rendering at scale 1 until `scale_factor_changed`
|
||||
// lands a frame or two later.
|
||||
ss.scale_factor = parent_scale_i;
|
||||
ss.scale_factor = parent_scale;
|
||||
ss.platform.last_requested_size = resolved_size;
|
||||
ss.platform.last_requested_zone = to_logical_zone( spec.exclusive_zone );
|
||||
ss.layer_anchor = Some( spec.anchor );
|
||||
|
||||
@@ -41,7 +41,7 @@ pub( super ) fn prepare_canvas<A: App>
|
||||
canvas: &mut Option<Canvas>,
|
||||
pw: u32,
|
||||
ph: u32,
|
||||
scale: u32,
|
||||
scale: f32,
|
||||
) -> bool
|
||||
{
|
||||
if let ( Some( ctx ), Some( es ) ) = ( platform.egl_context.as_ref(), surface.egl_surface.as_ref() )
|
||||
@@ -113,8 +113,9 @@ fn present_shm<A: App>
|
||||
/// The state that rides on the commit besides the buffer. The frame callback must be asked before the commit so the compositor ties it to this frame.
|
||||
fn describe<A: App>( platform: &WaylandPlatform<A>, wl_surface: &WlSurface, opaque_applied: &mut Option<Vec<Rect>>, frame: &Presentation<'_> )
|
||||
{
|
||||
sync_opaque_region( wl_surface, &platform.compositor_state, opaque_applied, frame.bg, frame.opaque_region, frame.width, frame.height, frame.scale );
|
||||
apply_input_region( wl_surface, &platform.compositor_state, frame.input_region, frame.scale );
|
||||
let scale = frame.scale.round().max( 1.0 ) as u32;
|
||||
sync_opaque_region( wl_surface, &platform.compositor_state, opaque_applied, frame.bg, frame.opaque_region, frame.width, frame.height, scale );
|
||||
apply_input_region( wl_surface, &platform.compositor_state, frame.input_region, scale );
|
||||
if let Some( focus ) = frame.frame
|
||||
{
|
||||
let _ = wl_surface.frame( &platform.qh, focus );
|
||||
|
||||
@@ -26,7 +26,7 @@ use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource;
|
||||
use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::event_loop::{ AppData, RunError, SurfaceState };
|
||||
use crate::event_loop::{ AppData, RunError, Sources, SurfaceState };
|
||||
|
||||
use super::{ LayerConfig, SurfaceKind, WaylandPlatform, WaylandSurfaceState };
|
||||
use super::overlays_reconcile::reconcile_overlays;
|
||||
@@ -339,7 +339,7 @@ pub( crate ) fn try_run<A: App>( mut app: A ) -> Result<(), RunError>
|
||||
let mut main_wl = WaylandSurfaceState::from( surface_kind );
|
||||
main_wl.titlebar_base = titlebar_height;
|
||||
let main = SurfaceState::new( main_wl, titlebar_height, titlebar_title );
|
||||
let mut data = AppData::new( app, platform, main, event_loop.handle(), builtin_osk, a11y );
|
||||
let mut data = AppData::new( app, platform, main, Sources::new( event_loop.handle() ), builtin_osk, a11y );
|
||||
data.install_sources()?;
|
||||
|
||||
if data.platform.session.store.is_some()
|
||||
|
||||
@@ -151,7 +151,7 @@ fn resolve_parent<A: App>( data: &AppData<A>, parent: SubsurfaceParent ) -> Opti
|
||||
if !ss.configured { return None; }
|
||||
if ss.width == 0 || ss.height == 0 { return None; }
|
||||
let surface = ss.platform.surface.try_wl_surface()?.clone();
|
||||
let scale = ss.scale_factor.max( 1 ) as u32;
|
||||
let scale = ss.buffer_scale() as u32;
|
||||
Some( ( surface, ss.width * scale, ss.height * scale, scale ) )
|
||||
}
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ impl<Msg: Clone> SurfaceState<Msg>
|
||||
h: u32,
|
||||
)
|
||||
{
|
||||
let scale = self.scale_factor.max( 1 ) as u32;
|
||||
let scale = self.buffer_scale() as u32;
|
||||
let pw = w.saturating_mul( scale );
|
||||
let ph = h.saturating_mul( scale );
|
||||
let pool_bytes = (pw as u64)
|
||||
@@ -264,7 +264,7 @@ impl<Msg: Clone> SurfaceState<Msg>
|
||||
}
|
||||
}
|
||||
|
||||
wl.surface.wl_surface().set_buffer_scale( self.scale_factor.max( 1 ) );
|
||||
wl.surface.wl_surface().set_buffer_scale( scale as i32 );
|
||||
self.set_size( w, h );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! The resources a page serves next to it: the tree `scripts/web-assets.sh` builds, the one an Android package carries, fetched into memory (`vfs.rs`) and used through [`crate::set_resource_dir`], so themes, fonts and keyboard layouts are read as from files anywhere else.
|
||||
//!
|
||||
//! `MANIFEST` lists the tree, one path per line relative to it; empty lines and lines starting with `#` are skipped, and the first line is `# ` and a hash of the contents.
|
||||
|
||||
use std::path::{ Component, Path };
|
||||
|
||||
use wasm_bindgen::{ JsCast, JsValue };
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{ Response, Window };
|
||||
|
||||
/// Where the fetched tree lives in memory, which is the resource directory.
|
||||
const ROOT: &str = "/ltk";
|
||||
|
||||
/// Fetch the resources served at `base`, a URL relative to the page such as `"ltk"`, and use them. Await it before `ltk::run`, so the theme is read from them; a failure leaves ltk on its built-in theme and font.
|
||||
///
|
||||
/// The files are fetched at once, each with the manifest's hash in its URL, so a changed tree is never served from the browser's cache; the manifest itself always comes from the server.
|
||||
pub async fn load_resources( base: &str ) -> Result<(), String>
|
||||
{
|
||||
let window = web_sys::window().ok_or( "no window" )?;
|
||||
let base = base.trim_end_matches( '/' );
|
||||
let manifest = fetch( &window, &format!( "{base}/MANIFEST?{}", js_sys::Date::now() ) ).await?;
|
||||
let manifest = String::from_utf8( manifest ).map_err( |e| format!( "MANIFEST: {e}" ) )?;
|
||||
let version = manifest.lines().next().and_then( |line| line.strip_prefix( "# " ) ).unwrap_or( "" );
|
||||
|
||||
let mut pending = Vec::new();
|
||||
for line in manifest.lines().map( str::trim ).filter( |l| !l.is_empty() && !l.starts_with( '#' ) )
|
||||
{
|
||||
if !Path::new( line ).components().all( |c| matches!( c, Component::Normal( _ ) ) )
|
||||
{
|
||||
return Err( format!( "{line}: not a path inside {base}/" ) );
|
||||
}
|
||||
let url = format!( "{base}/{}?{version}", String::from( js_sys::encode_uri( line ) ) );
|
||||
pending.push( ( line, window.fetch_with_str( &url ) ) );
|
||||
}
|
||||
for ( path, request ) in pending
|
||||
{
|
||||
let bytes = body( request ).await.map_err( |e| format!( "{path}: {e}" ) )?;
|
||||
crate::vfs::insert( Path::new( ROOT ).join( path ), bytes );
|
||||
}
|
||||
crate::resources::set_resource_dir( ROOT );
|
||||
Ok( () )
|
||||
}
|
||||
|
||||
async fn fetch( window: &Window, url: &str ) -> Result<Vec<u8>, String>
|
||||
{
|
||||
body( window.fetch_with_str( url ) ).await.map_err( |e| format!( "{url}: {e}" ) )
|
||||
}
|
||||
|
||||
async fn body( request: js_sys::Promise ) -> Result<Vec<u8>, String>
|
||||
{
|
||||
let response: Response = JsFuture::from( request ).await.map_err( describe )?.dyn_into().map_err( describe )?;
|
||||
if !response.ok() { return Err( format!( "HTTP {}", response.status() ) ); }
|
||||
let buffer = JsFuture::from( response.array_buffer().map_err( describe )? ).await.map_err( describe )?;
|
||||
Ok( js_sys::Uint8Array::new( &buffer ).to_vec() )
|
||||
}
|
||||
|
||||
fn describe( error: JsValue ) -> String
|
||||
{
|
||||
error.as_string().unwrap_or_else( || format!( "{error:?}" ) )
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Copies to the system clipboard through `navigator.clipboard`, which web-sys keeps behind its unstable APIs. Pasting needs no reading: the page's `paste` event carries the text (`input.rs`).
|
||||
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wasm_bindgen::{ JsCast, JsValue };
|
||||
|
||||
/// Offer `text` to other applications. The browser allows it only shortly after the user acted on the page, which a copy always follows.
|
||||
pub( super ) fn write( text: &str )
|
||||
{
|
||||
let Some( window ) = web_sys::window() else { return };
|
||||
let Ok( clipboard ) = js_sys::Reflect::get( &window.navigator(), &JsValue::from_str( "clipboard" ) ) else { return };
|
||||
if clipboard.is_undefined() { return; }
|
||||
let Ok( write_text ) = js_sys::Reflect::get( &clipboard, &JsValue::from_str( "writeText" ) ) else { return };
|
||||
let Some( write_text ) = write_text.dyn_ref::<js_sys::Function>() else { return };
|
||||
let Ok( promise ) = write_text.call1( &clipboard, &JsValue::from_str( text ) ) else { return };
|
||||
let refused = Closure::once_into_js( |_: JsValue| web_sys::console::warn_1( &"[ltk] the browser refused to write the clipboard".into() ) );
|
||||
if let Ok( catch ) = js_sys::Reflect::get( &promise, &JsValue::from_str( "catch" ) )
|
||||
{
|
||||
if let Some( catch ) = catch.dyn_ref::<js_sys::Function>() { let _ = catch.call1( &promise, &refused ); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use crate::types::CursorShape;
|
||||
|
||||
/// The value of CSS `cursor` for `shape`; the shapes are CSS's own.
|
||||
pub( super ) fn css( shape: CursorShape ) -> &'static str
|
||||
{
|
||||
use CursorShape as C;
|
||||
match shape
|
||||
{
|
||||
C::Default => "default",
|
||||
C::ContextMenu => "context-menu",
|
||||
C::Help => "help",
|
||||
C::Pointer => "pointer",
|
||||
C::Progress => "progress",
|
||||
C::Wait => "wait",
|
||||
C::Cell => "cell",
|
||||
C::Crosshair => "crosshair",
|
||||
C::Text => "text",
|
||||
C::VerticalText => "vertical-text",
|
||||
C::Alias => "alias",
|
||||
C::Copy => "copy",
|
||||
C::Move => "move",
|
||||
C::NoDrop => "no-drop",
|
||||
C::NotAllowed => "not-allowed",
|
||||
C::Grab => "grab",
|
||||
C::Grabbing => "grabbing",
|
||||
C::EResize => "e-resize",
|
||||
C::NResize => "n-resize",
|
||||
C::NeResize => "ne-resize",
|
||||
C::NwResize => "nw-resize",
|
||||
C::SResize => "s-resize",
|
||||
C::SeResize => "se-resize",
|
||||
C::SwResize => "sw-resize",
|
||||
C::WResize => "w-resize",
|
||||
C::EwResize => "ew-resize",
|
||||
C::NsResize => "ns-resize",
|
||||
C::NeswResize => "nesw-resize",
|
||||
C::NwseResize => "nwse-resize",
|
||||
C::ColResize => "col-resize",
|
||||
C::RowResize => "row-resize",
|
||||
C::AllScroll => "all-scroll",
|
||||
C::ZoomIn => "zoom-in",
|
||||
C::ZoomOut => "zoom-out",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! The canvas's pointer, wheel, key and focus events turned into [`crate::input::event`].
|
||||
//!
|
||||
//! A mouse is a pointer; fingers and pens are touches. Positions are `offsetX` / `offsetY`, in CSS pixels, which are the surface's logical ones. Keys repeat on the browser's clock, so the runtime's repeat stops at each of its repeats. On macOS Command is Ctrl, as its shortcuts expect. The listeners only queue what they translate; the loop hands it to the runtime. Dead keys and input methods are not composed yet.
|
||||
//!
|
||||
//! A page reads the clipboard only from a `paste` event, which the browser sends after a Ctrl+V it was let handle. So Ctrl+V is held back until its `paste` brings the text, and goes to the runtime after it.
|
||||
|
||||
use std::cell::{ Cell, RefCell };
|
||||
use std::rc::Rc;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use wasm_bindgen::{ JsCast, JsValue };
|
||||
use web_sys::{ ClipboardEvent, Event, HtmlCanvasElement, KeyboardEvent, PointerEvent, WheelEvent };
|
||||
|
||||
use crate::app::App;
|
||||
use crate::event_loop::{ AppData, RunError, SurfaceFocus };
|
||||
use crate::input::event::{ Axis, AxisSource, KeyInput, PointerButton, PointerInput, PointerKind, TouchInput, TouchPhase };
|
||||
use crate::Keysym;
|
||||
|
||||
use super::keys;
|
||||
use super::run::{ push, Listener, PageEvent };
|
||||
|
||||
/// Logical pixels per wheel detent, as libinput reports one.
|
||||
const WHEEL_STEP: f64 = 15.0;
|
||||
|
||||
/// Lines a browser scrolls per wheel detent in `DOM_DELTA_LINE` mode.
|
||||
const LINES_PER_NOTCH: f64 = 3.0;
|
||||
|
||||
const DOM_DELTA_LINE: u32 = 1;
|
||||
const DOM_DELTA_PAGE: u32 = 2;
|
||||
|
||||
/// The Linux input code of V, for a paste that comes with no key.
|
||||
const KEY_V: u32 = 47;
|
||||
|
||||
thread_local!
|
||||
{
|
||||
/// The Ctrl+V waiting for its `paste` event.
|
||||
static HELD_PASTE: RefCell<Option<WebInput>> = const { RefCell::new( None ) };
|
||||
}
|
||||
|
||||
pub( super ) enum KeyAction
|
||||
{
|
||||
Press,
|
||||
Repeat,
|
||||
Release,
|
||||
}
|
||||
|
||||
/// What a listener translated, for the loop to hand to the runtime.
|
||||
pub( super ) enum WebInput
|
||||
{
|
||||
Pointer( PointerInput ),
|
||||
Touch( TouchInput ),
|
||||
TouchCancel,
|
||||
Key
|
||||
{
|
||||
key: KeyInput,
|
||||
action: KeyAction,
|
||||
ctrl: bool,
|
||||
shift: bool,
|
||||
alt: bool,
|
||||
},
|
||||
Focus( bool ),
|
||||
/// What a `paste` brought, before the key that pastes it.
|
||||
Clipboard( String ),
|
||||
}
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
{
|
||||
pub( super ) fn web_input( &mut self, input: WebInput )
|
||||
{
|
||||
match input
|
||||
{
|
||||
WebInput::Pointer( pointer ) => self.pointer_input( &pointer ),
|
||||
WebInput::Touch( touch ) => self.touch_input( &touch ),
|
||||
WebInput::TouchCancel => { self.reset_touch_state(); }
|
||||
WebInput::Key { key, action, ctrl, shift, alt } =>
|
||||
{
|
||||
if ( ctrl, shift, alt ) != ( self.ctrl_pressed, self.shift_pressed, self.alt_pressed )
|
||||
{
|
||||
self.modifiers_changed( ctrl, shift, alt );
|
||||
}
|
||||
match action
|
||||
{
|
||||
KeyAction::Press => self.key_press( key ),
|
||||
KeyAction::Repeat => self.key_repeated( key ),
|
||||
KeyAction::Release => self.key_release( &key ),
|
||||
}
|
||||
}
|
||||
WebInput::Focus( true ) => self.keyboard_enter( SurfaceFocus::Main ),
|
||||
WebInput::Focus( false ) =>
|
||||
{
|
||||
// The keys held when the canvas lost the focus are released where the page does not see it.
|
||||
if self.ctrl_pressed || self.shift_pressed || self.alt_pressed { self.modifiers_changed( false, false, false ); }
|
||||
self.keyboard_leave();
|
||||
}
|
||||
WebInput::Clipboard( text ) => self.clipboard = text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Listen on `canvas` for every input the runtime takes.
|
||||
pub( super ) fn listen( canvas: &HtmlCanvasElement ) -> Result<Vec<Listener>, RunError>
|
||||
{
|
||||
let hovering = Rc::new( Cell::new( false ) );
|
||||
let mut listeners = Vec::new();
|
||||
|
||||
let ( target, over ) = ( canvas.clone(), Rc::clone( &hovering ) );
|
||||
listeners.push( Listener::new( canvas, "pointerdown", move |event| pointer_down( &target, &over, &event ) )? );
|
||||
let over = Rc::clone( &hovering );
|
||||
listeners.push( Listener::new( canvas, "pointermove", move |event| pointer_move( &over, &event ) )? );
|
||||
let over = Rc::clone( &hovering );
|
||||
listeners.push( Listener::new( canvas, "pointerup", move |event| pointer_up( &over, &event ) )? );
|
||||
listeners.push( Listener::new( canvas, "pointercancel", |event| pointer_cancel( &event ) )? );
|
||||
let over = Rc::clone( &hovering );
|
||||
listeners.push( Listener::new( canvas, "pointerleave", move |event| pointer_leave( &over, &event ) )? );
|
||||
let ( over, target ) = ( Rc::clone( &hovering ), canvas.clone() );
|
||||
listeners.push( Listener::new( canvas, "wheel", move |event| wheel( &target, &over, &event ) )? );
|
||||
listeners.push( Listener::new( canvas, "contextmenu", |event| event.prevent_default() )? );
|
||||
listeners.push( Listener::new( canvas, "keydown", |event| key( &event, true ) )? );
|
||||
listeners.push( Listener::new( canvas, "keyup", |event| key( &event, false ) )? );
|
||||
listeners.push( Listener::new( canvas, "focus", |_| queue( WebInput::Focus( true ) ) )? );
|
||||
listeners.push( Listener::new( canvas, "blur", |_| queue( WebInput::Focus( false ) ) )? );
|
||||
listeners.push( Listener::new( canvas, "paste", |event| paste( &event ) )? );
|
||||
Ok( listeners )
|
||||
}
|
||||
|
||||
fn queue( input: WebInput )
|
||||
{
|
||||
push( PageEvent::Input( input ) );
|
||||
}
|
||||
|
||||
/// Where `event` happened on the canvas, in its CSS pixels. Read as numbers, since web-sys rounds `offsetX` to an integer.
|
||||
fn position( event: &Event ) -> ( f64, f64 )
|
||||
{
|
||||
let get = |name: &str| js_sys::Reflect::get( event, &JsValue::from_str( name ) ).ok().and_then( |v| v.as_f64() ).unwrap_or( 0.0 );
|
||||
( get( "offsetX" ), get( "offsetY" ) )
|
||||
}
|
||||
|
||||
/// Milliseconds since the page loaded, which only differences are read of.
|
||||
fn time( event: &Event ) -> u32
|
||||
{
|
||||
event.time_stamp() as u32
|
||||
}
|
||||
|
||||
fn is_mouse( event: &PointerEvent ) -> bool
|
||||
{
|
||||
event.pointer_type() == "mouse"
|
||||
}
|
||||
|
||||
/// A mouse event, after the `Enter` the runtime expects first when the pointer was over the canvas before any `pointerenter`.
|
||||
fn mouse( hovering: &Cell<bool>, position: ( f64, f64 ), kind: PointerKind )
|
||||
{
|
||||
if !hovering.replace( true ) && kind != PointerKind::Enter
|
||||
{
|
||||
queue( WebInput::Pointer( PointerInput { focus: SurfaceFocus::Main, position, kind: PointerKind::Enter } ) );
|
||||
}
|
||||
queue( WebInput::Pointer( PointerInput { focus: SurfaceFocus::Main, position, kind } ) );
|
||||
}
|
||||
|
||||
fn button( button: i16 ) -> PointerButton
|
||||
{
|
||||
match button
|
||||
{
|
||||
0 => PointerButton::Left,
|
||||
1 => PointerButton::Middle,
|
||||
2 => PointerButton::Right,
|
||||
3 => PointerButton::Back,
|
||||
_ => PointerButton::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// The canvas takes the keyboard focus and every later event of the pointer, wherever it goes.
|
||||
fn pointer_down( canvas: &HtmlCanvasElement, hovering: &Cell<bool>, event: &Event )
|
||||
{
|
||||
let Some( e ) = event.dyn_ref::<PointerEvent>() else { return };
|
||||
event.prevent_default();
|
||||
let _ = canvas.focus();
|
||||
let _ = canvas.set_pointer_capture( e.pointer_id() );
|
||||
let position = position( event );
|
||||
if is_mouse( e )
|
||||
{
|
||||
mouse( hovering, position, PointerKind::Press( button( e.button() ) ) );
|
||||
} else {
|
||||
queue( WebInput::Touch( TouchInput { id: e.pointer_id(), time: time( event ), phase: TouchPhase::Down { focus: SurfaceFocus::Main, position } } ) );
|
||||
}
|
||||
}
|
||||
|
||||
/// A pen hovering moves with no button held, which is not a touch yet.
|
||||
fn pointer_move( hovering: &Cell<bool>, event: &Event )
|
||||
{
|
||||
let Some( e ) = event.dyn_ref::<PointerEvent>() else { return };
|
||||
let position = position( event );
|
||||
if is_mouse( e )
|
||||
{
|
||||
mouse( hovering, position, PointerKind::Motion );
|
||||
} else if e.buttons() != 0 {
|
||||
queue( WebInput::Touch( TouchInput { id: e.pointer_id(), time: time( event ), phase: TouchPhase::Motion { position } } ) );
|
||||
}
|
||||
}
|
||||
|
||||
fn pointer_up( hovering: &Cell<bool>, event: &Event )
|
||||
{
|
||||
let Some( e ) = event.dyn_ref::<PointerEvent>() else { return };
|
||||
if is_mouse( e )
|
||||
{
|
||||
mouse( hovering, position( event ), PointerKind::Release( button( e.button() ) ) );
|
||||
} else {
|
||||
queue( WebInput::Touch( TouchInput { id: e.pointer_id(), time: time( event ), phase: TouchPhase::Up } ) );
|
||||
}
|
||||
}
|
||||
|
||||
fn pointer_cancel( event: &Event )
|
||||
{
|
||||
let Some( e ) = event.dyn_ref::<PointerEvent>() else { return };
|
||||
if !is_mouse( e ) { queue( WebInput::TouchCancel ); }
|
||||
}
|
||||
|
||||
fn pointer_leave( hovering: &Cell<bool>, event: &Event )
|
||||
{
|
||||
let Some( e ) = event.dyn_ref::<PointerEvent>() else { return };
|
||||
if !is_mouse( e ) || !hovering.replace( false ) { return; }
|
||||
queue( WebInput::Pointer( PointerInput { focus: SurfaceFocus::Main, position: position( event ), kind: PointerKind::Leave } ) );
|
||||
}
|
||||
|
||||
/// A wheel scrolls lines, in detents; a touchpad scrolls pixels. The page does not scroll or zoom under the canvas.
|
||||
fn wheel( canvas: &HtmlCanvasElement, hovering: &Cell<bool>, event: &Event )
|
||||
{
|
||||
let Some( e ) = event.dyn_ref::<WheelEvent>() else { return };
|
||||
event.prevent_default();
|
||||
let ( horizontal, vertical, source ) = match e.delta_mode()
|
||||
{
|
||||
DOM_DELTA_LINE =>
|
||||
{
|
||||
let notches = |lines: f64| Axis { distance: lines / LINES_PER_NOTCH * WHEEL_STEP, notches: ( lines / LINES_PER_NOTCH ) as f32 };
|
||||
( notches( e.delta_x() ), notches( e.delta_y() ), AxisSource::Wheel )
|
||||
}
|
||||
DOM_DELTA_PAGE =>
|
||||
{
|
||||
let ( w, h ) = ( canvas.client_width() as f64, canvas.client_height() as f64 );
|
||||
( Axis { distance: e.delta_x() * w, notches: 0.0 }, Axis { distance: e.delta_y() * h, notches: 0.0 }, AxisSource::Continuous )
|
||||
}
|
||||
_ => ( Axis { distance: e.delta_x(), notches: 0.0 }, Axis { distance: e.delta_y(), notches: 0.0 }, AxisSource::Continuous ),
|
||||
};
|
||||
mouse( hovering, position( event ), PointerKind::Axis { horizontal, vertical, source } );
|
||||
}
|
||||
|
||||
fn is_mac() -> bool
|
||||
{
|
||||
static MAC: OnceLock<bool> = OnceLock::new();
|
||||
*MAC.get_or_init( || web_sys::window().and_then( |w| w.navigator().user_agent().ok() ).is_some_and( |ua| ua.contains( "Mac" ) ) )
|
||||
}
|
||||
|
||||
/// ltk takes every key it is given, browser shortcuts included where the browser lets a page have them, except the Ctrl+V the browser must handle to send its `paste`.
|
||||
fn key( event: &Event, down: bool )
|
||||
{
|
||||
let Some( e ) = event.dyn_ref::<KeyboardEvent>() else { return };
|
||||
// AltGr reaches the page as Ctrl and Alt on some systems; it types a character, it is not a shortcut.
|
||||
let alt_graph = e.get_modifier_state( "AltGraph" );
|
||||
let ( ctrl, alt ) = if alt_graph { ( false, false ) } else { ( e.ctrl_key() || ( is_mac() && e.meta_key() ), e.alt_key() ) };
|
||||
let name = e.key();
|
||||
let paste = down && ctrl && name.eq_ignore_ascii_case( "v" );
|
||||
if down && !paste { event.prevent_default(); }
|
||||
let typed = single_char( &name );
|
||||
let Some( keysym ) = keys::keysym( &name, e.location() ).or( typed.map( Keysym::from_char ) ) else { return };
|
||||
let text = typed.filter( |c| !ctrl && !e.meta_key() && !c.is_control() ).map( String::from );
|
||||
let action = match ( down, e.repeat() )
|
||||
{
|
||||
( true, true ) => KeyAction::Repeat,
|
||||
( true, false ) => KeyAction::Press,
|
||||
( false, _ ) => KeyAction::Release,
|
||||
};
|
||||
let key = KeyInput { keysym, keycode: keys::evdev( &e.code() ), text };
|
||||
let input = WebInput::Key { key, action, ctrl, shift: e.shift_key(), alt };
|
||||
if paste
|
||||
{
|
||||
HELD_PASTE.with( |held| *held.borrow_mut() = Some( input ) );
|
||||
} else {
|
||||
queue( input );
|
||||
}
|
||||
}
|
||||
|
||||
/// The clipboard's text, then the Ctrl+V held for it; a paste from the browser's menu comes with no key, so one is made for it.
|
||||
fn paste( event: &Event )
|
||||
{
|
||||
let Some( e ) = event.dyn_ref::<ClipboardEvent>() else { return };
|
||||
event.prevent_default();
|
||||
let text = e.clipboard_data().and_then( |data| data.get_data( "text/plain" ).ok() ).unwrap_or_default();
|
||||
queue( WebInput::Clipboard( text ) );
|
||||
match HELD_PASTE.with( |held| held.borrow_mut().take() )
|
||||
{
|
||||
Some( held ) => queue( held ),
|
||||
None =>
|
||||
{
|
||||
let key = || KeyInput { keysym: Keysym::v, keycode: KEY_V, text: None };
|
||||
queue( WebInput::Key { key: key(), action: KeyAction::Press, ctrl: true, shift: false, alt: false } );
|
||||
queue( WebInput::Key { key: key(), action: KeyAction::Release, ctrl: false, shift: false, alt: false } );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `KeyboardEvent.key` when it is the character the key types rather than a key's name.
|
||||
fn single_char( name: &str ) -> Option<char>
|
||||
{
|
||||
let mut chars = name.chars();
|
||||
let c = chars.next()?;
|
||||
chars.next().is_none().then_some( c )
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use crate::Keysym;
|
||||
|
||||
/// `KeyboardEvent.location` of the right key of a pair.
|
||||
const LOCATION_RIGHT: u32 = 2;
|
||||
const LOCATION_NUMPAD: u32 = 3;
|
||||
|
||||
/// The keysym of a key that types no character, from `KeyboardEvent.key` and `.location`. `None` for the others, whose keysym is the character they type.
|
||||
pub( super ) fn keysym( key: &str, location: u32 ) -> Option<Keysym>
|
||||
{
|
||||
let right = location == LOCATION_RIGHT;
|
||||
Some( match key
|
||||
{
|
||||
"ArrowLeft" => Keysym::Left,
|
||||
"ArrowRight" => Keysym::Right,
|
||||
"ArrowUp" => Keysym::Up,
|
||||
"ArrowDown" => Keysym::Down,
|
||||
"Enter" if location == LOCATION_NUMPAD => Keysym::KP_Enter,
|
||||
"Enter" => Keysym::Return,
|
||||
"Backspace" => Keysym::BackSpace,
|
||||
"Delete" => Keysym::Delete,
|
||||
"Tab" => Keysym::Tab,
|
||||
"Escape" => Keysym::Escape,
|
||||
"Home" => Keysym::Home,
|
||||
"End" => Keysym::End,
|
||||
"PageUp" => Keysym::Page_Up,
|
||||
"PageDown" => Keysym::Page_Down,
|
||||
"Insert" => Keysym::Insert,
|
||||
"F1" => Keysym::F1,
|
||||
"F2" => Keysym::F2,
|
||||
"F3" => Keysym::F3,
|
||||
"F4" => Keysym::F4,
|
||||
"F5" => Keysym::F5,
|
||||
"F6" => Keysym::F6,
|
||||
"F7" => Keysym::F7,
|
||||
"F8" => Keysym::F8,
|
||||
"F9" => Keysym::F9,
|
||||
"F10" => Keysym::F10,
|
||||
"F11" => Keysym::F11,
|
||||
"F12" => Keysym::F12,
|
||||
"Shift" if right => Keysym::Shift_R,
|
||||
"Shift" => Keysym::Shift_L,
|
||||
"Control" if right => Keysym::Control_R,
|
||||
"Control" => Keysym::Control_L,
|
||||
"Alt" if right => Keysym::Alt_R,
|
||||
"Alt" => Keysym::Alt_L,
|
||||
"AltGraph" => Keysym::ISO_Level3_Shift,
|
||||
"Meta" if right => Keysym::Super_R,
|
||||
"Meta" => Keysym::Super_L,
|
||||
"CapsLock" => Keysym::Caps_Lock,
|
||||
"NumLock" => Keysym::Num_Lock,
|
||||
"ScrollLock" => Keysym::Scroll_Lock,
|
||||
"PrintScreen" => Keysym::Print,
|
||||
"Pause" => Keysym::Pause,
|
||||
"ContextMenu" => Keysym::Menu,
|
||||
"BrowserBack" => Keysym::XF86_Back,
|
||||
"BrowserForward" => Keysym::XF86_Forward,
|
||||
"Cut" => Keysym::XF86_Cut,
|
||||
"Copy" => Keysym::XF86_Copy,
|
||||
"Paste" => Keysym::XF86_Paste,
|
||||
_ => return None,
|
||||
} )
|
||||
}
|
||||
|
||||
/// The Linux input code of the physical key `KeyboardEvent.code` names, the numbering Wayland reports keys in; 0 for a key not listed.
|
||||
pub( super ) fn evdev( code: &str ) -> u32
|
||||
{
|
||||
match code
|
||||
{
|
||||
"Escape" => 1,
|
||||
"Digit1" => 2,
|
||||
"Digit2" => 3,
|
||||
"Digit3" => 4,
|
||||
"Digit4" => 5,
|
||||
"Digit5" => 6,
|
||||
"Digit6" => 7,
|
||||
"Digit7" => 8,
|
||||
"Digit8" => 9,
|
||||
"Digit9" => 10,
|
||||
"Digit0" => 11,
|
||||
"Minus" => 12,
|
||||
"Equal" => 13,
|
||||
"Backspace" => 14,
|
||||
"Tab" => 15,
|
||||
"KeyQ" => 16,
|
||||
"KeyW" => 17,
|
||||
"KeyE" => 18,
|
||||
"KeyR" => 19,
|
||||
"KeyT" => 20,
|
||||
"KeyY" => 21,
|
||||
"KeyU" => 22,
|
||||
"KeyI" => 23,
|
||||
"KeyO" => 24,
|
||||
"KeyP" => 25,
|
||||
"BracketLeft" => 26,
|
||||
"BracketRight" => 27,
|
||||
"Enter" => 28,
|
||||
"ControlLeft" => 29,
|
||||
"KeyA" => 30,
|
||||
"KeyS" => 31,
|
||||
"KeyD" => 32,
|
||||
"KeyF" => 33,
|
||||
"KeyG" => 34,
|
||||
"KeyH" => 35,
|
||||
"KeyJ" => 36,
|
||||
"KeyK" => 37,
|
||||
"KeyL" => 38,
|
||||
"Semicolon" => 39,
|
||||
"Quote" => 40,
|
||||
"Backquote" => 41,
|
||||
"ShiftLeft" => 42,
|
||||
"Backslash" => 43,
|
||||
"KeyZ" => 44,
|
||||
"KeyX" => 45,
|
||||
"KeyC" => 46,
|
||||
"KeyV" => 47,
|
||||
"KeyB" => 48,
|
||||
"KeyN" => 49,
|
||||
"KeyM" => 50,
|
||||
"Comma" => 51,
|
||||
"Period" => 52,
|
||||
"Slash" => 53,
|
||||
"ShiftRight" => 54,
|
||||
"NumpadMultiply" => 55,
|
||||
"AltLeft" => 56,
|
||||
"Space" => 57,
|
||||
"CapsLock" => 58,
|
||||
"F1" => 59,
|
||||
"F2" => 60,
|
||||
"F3" => 61,
|
||||
"F4" => 62,
|
||||
"F5" => 63,
|
||||
"F6" => 64,
|
||||
"F7" => 65,
|
||||
"F8" => 66,
|
||||
"F9" => 67,
|
||||
"F10" => 68,
|
||||
"NumLock" => 69,
|
||||
"ScrollLock" => 70,
|
||||
"Numpad7" => 71,
|
||||
"Numpad8" => 72,
|
||||
"Numpad9" => 73,
|
||||
"NumpadSubtract" => 74,
|
||||
"Numpad4" => 75,
|
||||
"Numpad5" => 76,
|
||||
"Numpad6" => 77,
|
||||
"NumpadAdd" => 78,
|
||||
"Numpad1" => 79,
|
||||
"Numpad2" => 80,
|
||||
"Numpad3" => 81,
|
||||
"Numpad0" => 82,
|
||||
"NumpadDecimal" => 83,
|
||||
"IntlBackslash" => 86,
|
||||
"F11" => 87,
|
||||
"F12" => 88,
|
||||
"NumpadEnter" => 96,
|
||||
"ControlRight" => 97,
|
||||
"NumpadDivide" => 98,
|
||||
"PrintScreen" => 99,
|
||||
"AltRight" => 100,
|
||||
"Home" => 102,
|
||||
"ArrowUp" => 103,
|
||||
"PageUp" => 104,
|
||||
"ArrowLeft" => 105,
|
||||
"ArrowRight" => 106,
|
||||
"End" => 107,
|
||||
"ArrowDown" => 108,
|
||||
"PageDown" => 109,
|
||||
"Insert" => 110,
|
||||
"Delete" => 111,
|
||||
"Pause" => 119,
|
||||
"MetaLeft" => 125,
|
||||
"MetaRight" => 126,
|
||||
"ContextMenu" => 127,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Panics into the browser's console, which otherwise only shows the trap they end in. Standard output and error go nowhere in `wasm32-unknown-unknown`.
|
||||
|
||||
use std::sync::Once;
|
||||
|
||||
pub( super ) fn install_panic_hook()
|
||||
{
|
||||
static HOOK: Once = Once::new();
|
||||
HOOK.call_once( ||
|
||||
{
|
||||
std::panic::set_hook( Box::new( |info| web_sys::console::error_1( &info.to_string().into() ) ) );
|
||||
} );
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! The browser backend: `ltk::run` on a page's canvas, built for `wasm32-unknown-unknown`.
|
||||
//!
|
||||
//! | File | What it does for the runtime |
|
||||
//! | --- | --- |
|
||||
//! | `assets.rs` | The themes, fonts and keyboard layouts a page serves, fetched into memory as the app's resource directory |
|
||||
//! | `log.rs` | Panics into the browser's console |
|
||||
//! | `state.rs` | [`WebPlatform`]: the canvas, its WebGL2 or 2D context, and what the runtime asked of the page |
|
||||
//! | `surface.rs` | [`WebSurfaceState`]; sizing the main surface to the canvas |
|
||||
//! | `present.rs` | Putting a painted canvas on the page: the GPU canvas's blit, or the software one's pixels through `putImageData` |
|
||||
//! | `input.rs`, `keys.rs` | The canvas's pointer, wheel, key and focus events turned into [`crate::input::event`], `KeyboardEvent.key` into keysyms and `.code` into Linux input codes |
|
||||
//! | `cursor.rs` | The cursor's shapes as CSS names them |
|
||||
//! | `clipboard.rs` | Copies to the system clipboard; pastes come from the page's `paste` event |
|
||||
//! | `run.rs` | `ltk::run`: the loop that `setTimeout` and `requestAnimationFrame` drive, and the page's event listeners |
|
||||
//!
|
||||
//! The page has one surface, the main one, on one canvas. Overlays, secondary surfaces and subsurfaces are not created.
|
||||
|
||||
mod assets;
|
||||
mod clipboard;
|
||||
mod cursor;
|
||||
mod input;
|
||||
mod keys;
|
||||
mod log;
|
||||
mod present;
|
||||
pub( crate ) mod run;
|
||||
mod state;
|
||||
mod surface;
|
||||
|
||||
pub use assets::load_resources;
|
||||
pub use run::set_canvas;
|
||||
pub( crate ) use state::WebPlatform;
|
||||
pub( crate ) use surface::WebSurfaceState;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::event_loop::SurfaceFocus;
|
||||
use crate::event_loop::cursor_shape::ResizeEdge;
|
||||
use crate::osk::Purpose;
|
||||
use crate::render::Canvas;
|
||||
use crate::types::CursorShape;
|
||||
|
||||
use super::{ Platform, Presentation };
|
||||
|
||||
impl<A: App> Platform for WebPlatform<A>
|
||||
{
|
||||
type Surface = WebSurfaceState;
|
||||
type Output = ();
|
||||
|
||||
fn is_toplevel( &self, _main: &WebSurfaceState ) -> bool
|
||||
{
|
||||
false
|
||||
}
|
||||
|
||||
fn back_leaves( &self, _main: &WebSurfaceState ) -> bool
|
||||
{
|
||||
false
|
||||
}
|
||||
|
||||
fn begin_window_move( &self, _main: &WebSurfaceState ) {}
|
||||
|
||||
fn begin_window_resize( &self, _main: &WebSurfaceState, _edge: ResizeEdge ) {}
|
||||
|
||||
fn minimize_window( &self, _main: &WebSurfaceState ) {}
|
||||
|
||||
fn set_fullscreen( &self, _main: &WebSurfaceState, fullscreen: bool ) -> bool
|
||||
{
|
||||
lock_keyboard( fullscreen );
|
||||
if fullscreen
|
||||
{
|
||||
let _ = self.canvas.request_fullscreen();
|
||||
} else if let Some( document ) = self.canvas.owner_document() {
|
||||
document.exit_fullscreen();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn request_frame( &self, _surface: &WebSurfaceState, focus: SurfaceFocus ) -> bool
|
||||
{
|
||||
self.frame_due.set( Some( focus ) );
|
||||
true
|
||||
}
|
||||
|
||||
fn precise_damage( &self, _surface: &WebSurfaceState ) -> bool
|
||||
{
|
||||
false
|
||||
}
|
||||
|
||||
fn prepare_canvas( &self, surface: &mut WebSurfaceState, canvas: &mut Option<Canvas>, pw: u32, ph: u32, scale: f32 ) -> bool
|
||||
{
|
||||
present::prepare_canvas( self, surface, canvas, pw, ph, scale )
|
||||
}
|
||||
|
||||
fn present( &self, surface: &mut WebSurfaceState, canvas: &mut Canvas, frame: &Presentation<'_> ) -> bool
|
||||
{
|
||||
present::present( self, surface, canvas, frame )
|
||||
}
|
||||
|
||||
fn publish_clipboard( &mut self, text: &str )
|
||||
{
|
||||
clipboard::write( text );
|
||||
}
|
||||
|
||||
fn publish_primary_selection( &mut self, _text: &str ) {}
|
||||
|
||||
fn set_cursor_shape( &mut self, shape: CursorShape ) -> bool
|
||||
{
|
||||
self.canvas.style().set_property( "cursor", cursor::css( shape ) ).is_ok()
|
||||
}
|
||||
|
||||
fn text_input_active( &self ) -> bool
|
||||
{
|
||||
self.text_input
|
||||
}
|
||||
|
||||
fn activate_text_input( &mut self, _secure: bool, _purpose: Purpose )
|
||||
{
|
||||
self.text_input = true;
|
||||
}
|
||||
|
||||
fn deactivate_text_input( &mut self )
|
||||
{
|
||||
self.text_input = false;
|
||||
}
|
||||
|
||||
fn reenable_text_input( &self, _secure: bool, _purpose: Purpose ) {}
|
||||
}
|
||||
|
||||
/// Let the page have the keys the browser keeps for itself (Ctrl+W, Ctrl+T, Escape) while it is fullscreen, through `navigator.keyboard`, which only Chromium has and web-sys keeps behind its unstable APIs.
|
||||
fn lock_keyboard( lock: bool )
|
||||
{
|
||||
use wasm_bindgen::{ JsCast, JsValue };
|
||||
let Some( window ) = web_sys::window() else { return };
|
||||
let Ok( keyboard ) = js_sys::Reflect::get( &window.navigator(), &JsValue::from_str( "keyboard" ) ) else { return };
|
||||
if keyboard.is_undefined() { return; }
|
||||
let method = if lock { "lock" } else { "unlock" };
|
||||
let Ok( call ) = js_sys::Reflect::get( &keyboard, &JsValue::from_str( method ) ) else { return };
|
||||
if let Some( call ) = call.dyn_ref::<js_sys::Function>() { let _ = call.call0( &keyboard ); }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Putting a painted canvas on the page: the GPU canvas blits its FBO onto the WebGL2 drawing buffer, which the browser shows when the task ends; the software one's pixels go through `putImageData`. The browser may take the WebGL context away, and give it back, at any time.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wasm_bindgen::Clamped;
|
||||
use web_sys::ImageData;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::draw::fit_canvas;
|
||||
use crate::event_loop::AppData;
|
||||
use crate::gles_render::GlesVersion;
|
||||
use crate::platform::Presentation;
|
||||
use crate::render::Canvas;
|
||||
|
||||
use super::{ WebPlatform, WebSurfaceState };
|
||||
|
||||
pub( super ) fn prepare_canvas<A: App>
|
||||
(
|
||||
platform: &WebPlatform<A>,
|
||||
_surface: &mut WebSurfaceState,
|
||||
canvas: &mut Option<Canvas>,
|
||||
pw: u32,
|
||||
ph: u32,
|
||||
scale: f32,
|
||||
) -> bool
|
||||
{
|
||||
if platform.context_lost { return false; }
|
||||
if platform.canvas.width() != pw { platform.canvas.set_width( pw ); }
|
||||
if platform.canvas.height() != ph { platform.canvas.set_height( ph ); }
|
||||
match platform.gl.as_ref()
|
||||
{
|
||||
Some( gl ) => fit_canvas( canvas, pw, ph, scale, || Canvas::new_gles( Arc::clone( gl ), GlesVersion::V3, pw, ph ) ),
|
||||
None => fit_canvas( canvas, pw, ph, scale, || Canvas::new( pw, ph ) ),
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
pub( super ) fn present<A: App>
|
||||
(
|
||||
platform: &WebPlatform<A>,
|
||||
surface: &mut WebSurfaceState,
|
||||
canvas: &mut Canvas,
|
||||
frame: &Presentation<'_>,
|
||||
) -> bool
|
||||
{
|
||||
let shown = match platform.ctx2d.as_ref()
|
||||
{
|
||||
None =>
|
||||
{
|
||||
canvas.present();
|
||||
true
|
||||
}
|
||||
Some( ctx ) => present_software( ctx, surface, canvas ),
|
||||
};
|
||||
if shown && frame.frame.is_some() { platform.frame_due.set( frame.frame ); }
|
||||
shown
|
||||
}
|
||||
|
||||
fn present_software( ctx: &web_sys::CanvasRenderingContext2d, surface: &mut WebSurfaceState, canvas: &Canvas ) -> bool
|
||||
{
|
||||
let ( pw, ph ) = canvas.size();
|
||||
surface.pixels.resize( pw as usize * ph as usize * 4, 0 );
|
||||
canvas.write_to_wayland_buf( &mut surface.pixels, false );
|
||||
unpremultiply( &mut surface.pixels );
|
||||
let Ok( image ) = ImageData::new_with_u8_clamped_array_and_sh( Clamped( &surface.pixels ), pw, ph ) else { return false };
|
||||
ctx.put_image_data( &image, 0.0, 0.0 ).is_ok()
|
||||
}
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
{
|
||||
/// Every texture, framebuffer and program went with the context: drop the canvases built on it.
|
||||
pub( super ) fn webgl_context_lost( &mut self )
|
||||
{
|
||||
self.platform.context_lost = true;
|
||||
self.main.canvas = None;
|
||||
self.main.frame.scroll_canvases.clear();
|
||||
self.main.frame.viewport_canvases.clear();
|
||||
}
|
||||
|
||||
/// Draw again on a fresh `glow::Context`, so the glyph atlas and the caches the old one kept are not found for it.
|
||||
pub( super ) fn webgl_context_restored( &mut self )
|
||||
{
|
||||
let Some( webgl ) = self.platform.webgl.clone() else { return };
|
||||
self.platform.gl = Some( Arc::new( glow::Context::from_webgl2_context( webgl ) ) );
|
||||
self.platform.context_lost = false;
|
||||
self.main.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ImageData` holds straight alpha; the canvas, premultiplied.
|
||||
fn unpremultiply( rgba: &mut [u8] )
|
||||
{
|
||||
for px in rgba.chunks_exact_mut( 4 )
|
||||
{
|
||||
let a = px[ 3 ] as u16;
|
||||
if a == 0 || a == 255 { continue; }
|
||||
for c in &mut px[ .. 3 ]
|
||||
{
|
||||
*c = ( ( *c as u16 * 255 + a / 2 ) / a ).min( 255 ) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! `ltk::run` in a browser.
|
||||
//!
|
||||
//! A page's main thread must not block, so `run` sets the app up and returns, and the browser drives the loop: `AppData` lives in a closure a thread-local [`Loop`] holds, which a `setTimeout` runs for messages, timers and page events and a `requestAnimationFrame` runs to draw. Page events and `ChannelSender::send` only queue and schedule, never touching `AppData`, so nothing they set off can run inside an iteration.
|
||||
|
||||
use std::cell::{ Cell, RefCell };
|
||||
use std::collections::VecDeque;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wasm_bindgen::{ JsCast, JsValue };
|
||||
use web_sys::{ CanvasRenderingContext2d, Document, Event, EventTarget, HtmlCanvasElement, WebGl2RenderingContext };
|
||||
|
||||
use crate::app::App;
|
||||
use crate::event_loop::{ AppData, RunError, Sources, SurfaceState };
|
||||
|
||||
use super::input::WebInput;
|
||||
use super::{ WebPlatform, WebSurfaceState };
|
||||
|
||||
/// What the page reports between iterations.
|
||||
pub( super ) enum PageEvent
|
||||
{
|
||||
Resized,
|
||||
Input( WebInput ),
|
||||
ContextLost,
|
||||
ContextRestored,
|
||||
}
|
||||
|
||||
/// An event listener, registered while it lives in `WebPlatform::listeners`.
|
||||
pub( crate ) struct Listener
|
||||
{
|
||||
target: EventTarget,
|
||||
kind: &'static str,
|
||||
closure: Closure<dyn FnMut( Event )>,
|
||||
}
|
||||
|
||||
impl Listener
|
||||
{
|
||||
pub( super ) fn new( target: &EventTarget, kind: &'static str, handle: impl FnMut( Event ) + 'static ) -> Result<Self, RunError>
|
||||
{
|
||||
let closure = Closure::<dyn FnMut( Event )>::new( handle );
|
||||
target.add_event_listener_with_callback( kind, closure.as_ref().unchecked_ref() ).map_err( |_| RunError::NoDocument )?;
|
||||
Ok( Self { target: target.clone(), kind, closure } )
|
||||
}
|
||||
|
||||
pub( super ) fn remove( &self )
|
||||
{
|
||||
let _ = self.target.remove_event_listener_with_callback( self.kind, self.closure.as_ref().unchecked_ref() );
|
||||
}
|
||||
}
|
||||
|
||||
/// What an iteration asks of the next.
|
||||
struct Next
|
||||
{
|
||||
frame: bool,
|
||||
after: Option<Duration>,
|
||||
exit: bool,
|
||||
}
|
||||
|
||||
struct Loop
|
||||
{
|
||||
iterate: RefCell<Box<dyn FnMut( bool ) -> Next>>,
|
||||
/// The pending timeout and when it fires, in milliseconds since the epoch.
|
||||
timeout: Cell<Option<( i32, f64 )>>,
|
||||
frame: Cell<Option<i32>>,
|
||||
ended: Cell<bool>,
|
||||
on_timeout: Closure<dyn FnMut()>,
|
||||
on_frame: Closure<dyn FnMut( f64 )>,
|
||||
}
|
||||
|
||||
thread_local!
|
||||
{
|
||||
static LOOP: RefCell<Option<Rc<Loop>>> = const { RefCell::new( None ) };
|
||||
static EVENTS: RefCell<VecDeque<PageEvent>> = const { RefCell::new( VecDeque::new() ) };
|
||||
static CANVAS: RefCell<Option<HtmlCanvasElement>> = const { RefCell::new( None ) };
|
||||
}
|
||||
|
||||
/// Draw the app on `canvas` rather than on one ltk lays over the whole page. Call it before `ltk::run`.
|
||||
pub fn set_canvas( canvas: HtmlCanvasElement )
|
||||
{
|
||||
CANVAS.with( |c| *c.borrow_mut() = Some( canvas ) );
|
||||
}
|
||||
|
||||
pub( crate ) fn run<A: App>( app: A )
|
||||
{
|
||||
if let Err( e ) = try_run( app )
|
||||
{
|
||||
panic!( "ltk::run failed during init: {e}" );
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the app up on the page and return; the browser runs it from then on.
|
||||
pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
||||
{
|
||||
super::log::install_panic_hook();
|
||||
let window = web_sys::window().ok_or( RunError::NoDocument )?;
|
||||
let document = window.document().ok_or( RunError::NoDocument )?;
|
||||
let ( canvas, own ) = match CANVAS.with( |c| c.borrow_mut().take() )
|
||||
{
|
||||
Some( canvas ) => ( canvas, false ),
|
||||
None => ( page_canvas( &document )?, true ),
|
||||
};
|
||||
canvas.set_tab_index( 0 );
|
||||
let style = canvas.style();
|
||||
let _ = style.set_property( "touch-action", "none" );
|
||||
let _ = style.set_property( "outline", "none" );
|
||||
|
||||
let webgl = webgl2( &canvas );
|
||||
let ctx2d = if webgl.is_none() { context_2d( &canvas ) } else { None };
|
||||
if webgl.is_none() { web_sys::console::warn_1( &"[ltk] no WebGL2 context: drawing in software".into() ); }
|
||||
crate::render::set_software_render( webgl.is_none() );
|
||||
|
||||
let mut listeners = super::input::listen( &canvas )?;
|
||||
listeners.push( Listener::new( &window, "resize", |_| push( PageEvent::Resized ) )? );
|
||||
if webgl.is_some()
|
||||
{
|
||||
// Without `preventDefault` the browser never gives the context back.
|
||||
listeners.push( Listener::new( &canvas, "webglcontextlost", |event|
|
||||
{
|
||||
event.prevent_default();
|
||||
push( PageEvent::ContextLost );
|
||||
} )? );
|
||||
listeners.push( Listener::new( &canvas, "webglcontextrestored", |_| push( PageEvent::ContextRestored ) )? );
|
||||
}
|
||||
let inset_probe = if own { Some( inset_probe( &document )? ) } else { None };
|
||||
if own { let _ = canvas.focus(); }
|
||||
|
||||
let coarse = window.match_media( "(pointer: coarse)" ).ok().flatten().is_some_and( |m| m.matches() );
|
||||
let platform = WebPlatform::new( canvas, webgl, ctx2d, listeners, inset_probe );
|
||||
let main = SurfaceState::new( WebSurfaceState::default(), 0.0, String::new() );
|
||||
// ltk's own keyboard where the page is used by touch, since the page cannot raise the system's for a canvas.
|
||||
let mut data = AppData::new( app, platform, main, Sources::new( wake ), coarse, None );
|
||||
data.install_sources()?;
|
||||
data.fit_canvas_element();
|
||||
|
||||
let mut data = Some( data );
|
||||
let iterate: Box<dyn FnMut( bool ) -> Next> = Box::new( move |in_frame|
|
||||
{
|
||||
let Some( app ) = data.as_mut() else { return Next { frame: false, after: None, exit: true } };
|
||||
let next = app.iterate( in_frame );
|
||||
if next.exit { data = None; }
|
||||
next
|
||||
} );
|
||||
let lp = Loop
|
||||
{
|
||||
iterate: RefCell::new( iterate ),
|
||||
timeout: Cell::new( None ),
|
||||
frame: Cell::new( None ),
|
||||
ended: Cell::new( false ),
|
||||
on_timeout: Closure::new( || run_iteration( false ) ),
|
||||
on_frame: Closure::new( |_: f64| run_iteration( true ) ),
|
||||
};
|
||||
LOOP.with( |l| *l.borrow_mut() = Some( Rc::new( lp ) ) );
|
||||
wake();
|
||||
Ok( () )
|
||||
}
|
||||
|
||||
/// Ask for an iteration as soon as the page is idle.
|
||||
pub( super ) fn wake()
|
||||
{
|
||||
with_loop( |lp| lp.schedule_in( 0.0 ) );
|
||||
}
|
||||
|
||||
pub( super ) fn push( event: PageEvent )
|
||||
{
|
||||
EVENTS.with( |q| q.borrow_mut().push_back( event ) );
|
||||
wake();
|
||||
}
|
||||
|
||||
fn with_loop( f: impl FnOnce( &Loop ) )
|
||||
{
|
||||
if let Some( lp ) = LOOP.with( |l| l.borrow().clone() ) { f( &lp ); }
|
||||
}
|
||||
|
||||
fn run_iteration( in_frame: bool )
|
||||
{
|
||||
with_loop( |lp|
|
||||
{
|
||||
if in_frame { lp.frame.set( None ); } else { lp.timeout.set( None ); }
|
||||
if lp.ended.get() { return; }
|
||||
let Ok( mut iterate ) = lp.iterate.try_borrow_mut() else { return };
|
||||
let next = ( *iterate )( in_frame );
|
||||
drop( iterate );
|
||||
if next.exit
|
||||
{
|
||||
lp.end();
|
||||
return;
|
||||
}
|
||||
if next.frame { lp.request_frame(); }
|
||||
if let Some( after ) = next.after { lp.schedule_in( after.as_secs_f64() * 1000.0 ); }
|
||||
} );
|
||||
}
|
||||
|
||||
impl Loop
|
||||
{
|
||||
/// Run an iteration in `ms` milliseconds, unless one is already due sooner.
|
||||
fn schedule_in( &self, ms: f64 )
|
||||
{
|
||||
if self.ended.get() { return; }
|
||||
let Some( window ) = web_sys::window() else { return };
|
||||
let at = js_sys::Date::now() + ms;
|
||||
if let Some( ( id, due ) ) = self.timeout.get()
|
||||
{
|
||||
if due <= at { return; }
|
||||
window.clear_timeout_with_handle( id );
|
||||
}
|
||||
let delay = ms.ceil().clamp( 0.0, i32::MAX as f64 ) as i32;
|
||||
if let Ok( id ) = window.set_timeout_with_callback_and_timeout_and_arguments_0( self.on_timeout.as_ref().unchecked_ref(), delay )
|
||||
{
|
||||
self.timeout.set( Some( ( id, at ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
fn request_frame( &self )
|
||||
{
|
||||
if self.frame.get().is_some() { return; }
|
||||
let Some( window ) = web_sys::window() else { return };
|
||||
if let Ok( id ) = window.request_animation_frame( self.on_frame.as_ref().unchecked_ref() )
|
||||
{
|
||||
self.frame.set( Some( id ) );
|
||||
}
|
||||
}
|
||||
|
||||
/// Run no more iterations. The closures stay alive, since one of them is running.
|
||||
fn end( &self )
|
||||
{
|
||||
self.ended.set( true );
|
||||
let Some( window ) = web_sys::window() else { return };
|
||||
if let Some( ( id, _ ) ) = self.timeout.take() { window.clear_timeout_with_handle( id ); }
|
||||
if let Some( id ) = self.frame.take() { let _ = window.cancel_animation_frame( id ); }
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
{
|
||||
/// What the page reported, the due timers and messages, then the runtime's steps; drawing only in an animation frame.
|
||||
fn iterate( &mut self, in_frame: bool ) -> Next
|
||||
{
|
||||
while let Some( event ) = EVENTS.with( |q| q.borrow_mut().pop_front() )
|
||||
{
|
||||
match event
|
||||
{
|
||||
PageEvent::Resized => self.fit_canvas_element(),
|
||||
PageEvent::Input( input ) => self.web_input( input ),
|
||||
PageEvent::ContextLost => self.webgl_context_lost(),
|
||||
PageEvent::ContextRestored => self.webgl_context_restored(),
|
||||
}
|
||||
}
|
||||
self.dispatch_sources();
|
||||
if in_frame
|
||||
{
|
||||
if let Some( focus ) = self.platform.frame_due.take() { self.frame_presented( focus, false ); }
|
||||
}
|
||||
|
||||
self.before_draw();
|
||||
let drawn = in_frame && self.draw_due();
|
||||
self.after_draw( drawn );
|
||||
|
||||
if self.exit_requested
|
||||
{
|
||||
self.leave_page();
|
||||
return Next { frame: false, after: None, exit: true };
|
||||
}
|
||||
let main = &self.main;
|
||||
let frame = ( main.configured && main.needs_redraw && !main.frame_pending ) || self.platform.frame_due.get().is_some();
|
||||
let now = crate::time::Instant::now();
|
||||
let after = [ self.next_wakeup(), self.sources.next_due().map( |due| due.saturating_duration_since( now ) ) ]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min();
|
||||
Next { frame, after, exit: false }
|
||||
}
|
||||
|
||||
fn dispatch_sources( &mut self )
|
||||
{
|
||||
for ( id, mut tick ) in self.sources.take_due( crate::time::Instant::now() )
|
||||
{
|
||||
let next = tick( self );
|
||||
self.sources.rearm( id, tick, next );
|
||||
}
|
||||
let mut drains = self.sources.take_channels();
|
||||
for drain in &mut drains
|
||||
{
|
||||
drain( self );
|
||||
}
|
||||
self.sources.restore_channels( drains );
|
||||
}
|
||||
|
||||
/// Stop listening to the page and tell it, with an `ltk-exit` event on the canvas, that the app has ended.
|
||||
fn leave_page( &mut self )
|
||||
{
|
||||
for listener in &self.platform.listeners
|
||||
{
|
||||
listener.remove();
|
||||
}
|
||||
if let Some( probe ) = self.platform.inset_probe.take() { probe.remove(); }
|
||||
if let Ok( event ) = Event::new( "ltk-exit" )
|
||||
{
|
||||
let _ = self.platform.canvas.dispatch_event( &event );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A canvas over the whole page, for an app that named none.
|
||||
fn page_canvas( document: &Document ) -> Result<HtmlCanvasElement, RunError>
|
||||
{
|
||||
let canvas: HtmlCanvasElement = document.create_element( "canvas" ).ok()
|
||||
.and_then( |e| e.dyn_into().ok() )
|
||||
.ok_or( RunError::NoDocument )?;
|
||||
let _ = canvas.set_attribute( "style", "position: fixed; inset: 0; width: 100%; height: 100%; display: block; touch-action: none" );
|
||||
document.body().ok_or( RunError::NoDocument )?.append_child( &canvas ).map_err( |_| RunError::NoDocument )?;
|
||||
Ok( canvas )
|
||||
}
|
||||
|
||||
/// An invisible element padded by the page's safe-area insets, read back as numbers by `fit_canvas_element`.
|
||||
fn inset_probe( document: &Document ) -> Result<web_sys::Element, RunError>
|
||||
{
|
||||
let probe = document.create_element( "div" ).map_err( |_| RunError::NoDocument )?;
|
||||
let _ = probe.set_attribute( "style", "position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; \
|
||||
padding: env( safe-area-inset-top ) env( safe-area-inset-right ) env( safe-area-inset-bottom ) env( safe-area-inset-left )" );
|
||||
document.body().ok_or( RunError::NoDocument )?.append_child( &probe ).map_err( |_| RunError::NoDocument )?;
|
||||
Ok( probe )
|
||||
}
|
||||
|
||||
/// The WebGL2 context ltk's GPU canvas draws through: premultiplied alpha as it writes it, and no depth, stencil or multisampling, which it does not use.
|
||||
fn webgl2( canvas: &HtmlCanvasElement ) -> Option<WebGl2RenderingContext>
|
||||
{
|
||||
let options = js_sys::Object::new();
|
||||
for ( key, value ) in [ ( "alpha", true ), ( "premultipliedAlpha", true ), ( "antialias", false ), ( "depth", false ), ( "stencil", false ), ( "preserveDrawingBuffer", false ) ]
|
||||
{
|
||||
js_sys::Reflect::set( &options, &JsValue::from_str( key ), &JsValue::from_bool( value ) ).ok()?;
|
||||
}
|
||||
canvas.get_context_with_context_options( "webgl2", &options ).ok()??.dyn_into().ok()
|
||||
}
|
||||
|
||||
fn context_2d( canvas: &HtmlCanvasElement ) -> Option<CanvasRenderingContext2d>
|
||||
{
|
||||
canvas.get_context( "2d" ).ok()??.dyn_into().ok()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use web_sys::{ CanvasRenderingContext2d, Element, HtmlCanvasElement, WebGl2RenderingContext };
|
||||
|
||||
use crate::app::App;
|
||||
use crate::event_loop::SurfaceFocus;
|
||||
|
||||
use super::run::Listener;
|
||||
|
||||
/// What the runtime's requests go through in a browser: the canvas, its context and what the loop must report back to the runtime.
|
||||
pub( crate ) struct WebPlatform<A: App>
|
||||
{
|
||||
pub canvas: HtmlCanvasElement,
|
||||
/// The canvas's WebGL2 context; `None` when the browser has none, and the canvas is drawn through `ctx2d`.
|
||||
pub webgl: Option<WebGl2RenderingContext>,
|
||||
/// `webgl` as the GPU canvas draws through it, wrapped anew when the context is restored.
|
||||
pub gl: Option<Arc<glow::Context>>,
|
||||
pub ctx2d: Option<CanvasRenderingContext2d>,
|
||||
/// The browser took the WebGL context away; nothing is drawn until it gives it back.
|
||||
pub context_lost: bool,
|
||||
/// A hidden element padded by `env( safe-area-inset-* )`, which tells what a phone's notch and bars cover; only on ltk's own canvas over the page.
|
||||
pub inset_probe: Option<Element>,
|
||||
/// A frame was presented or asked for; the loop reports it at the next animation frame.
|
||||
pub frame_due: Cell<Option<SurfaceFocus>>,
|
||||
/// Text input was asked for and not ended; ltk's own keyboard serves it.
|
||||
pub text_input: bool,
|
||||
/// The canvas's input and the window's `resize`, removed when the app exits.
|
||||
pub listeners: Vec<Listener>,
|
||||
_app: PhantomData<fn() -> A>,
|
||||
}
|
||||
|
||||
impl<A: App> WebPlatform<A>
|
||||
{
|
||||
pub( crate ) fn new( canvas: HtmlCanvasElement, webgl: Option<WebGl2RenderingContext>, ctx2d: Option<CanvasRenderingContext2d>, listeners: Vec<Listener>, inset_probe: Option<Element> ) -> Self
|
||||
{
|
||||
let gl = webgl.clone().map( |context| Arc::new( glow::Context::from_webgl2_context( context ) ) );
|
||||
Self
|
||||
{
|
||||
canvas,
|
||||
webgl,
|
||||
gl,
|
||||
ctx2d,
|
||||
context_lost: false,
|
||||
inset_probe,
|
||||
frame_due: Cell::new( None ),
|
||||
text_input: false,
|
||||
listeners,
|
||||
_app: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use web_sys::Element;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::event_loop::AppData;
|
||||
use crate::event_loop::surface::Insets;
|
||||
|
||||
/// The main surface's browser half.
|
||||
#[ derive( Default ) ]
|
||||
pub( crate ) struct WebSurfaceState
|
||||
{
|
||||
/// The software renderer's frame, unpremultiplied for `putImageData`.
|
||||
pub pixels: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The safe-area insets `probe` is padded by, in CSS pixels; none without a probe.
|
||||
fn safe_area( probe: Option<&Element> ) -> Insets
|
||||
{
|
||||
let ( Some( probe ), Some( window ) ) = ( probe, web_sys::window() ) else { return Insets::default() };
|
||||
let Ok( Some( style ) ) = window.get_computed_style( probe ) else { return Insets::default() };
|
||||
let side = |name: &str| style.get_property_value( name ).ok()
|
||||
.and_then( |value| value.trim().trim_end_matches( "px" ).parse::<f32>().ok() )
|
||||
.unwrap_or( 0.0 );
|
||||
Insets { top: side( "padding-top" ), right: side( "padding-right" ), bottom: side( "padding-bottom" ), left: side( "padding-left" ) }
|
||||
}
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
{
|
||||
/// Size the main surface to the canvas, whose CSS pixels are the surface's logical ones at the page's `devicePixelRatio`, so the canvas holds the display's own pixels, and keep the view out of what a phone's notch and bars cover.
|
||||
pub( super ) fn fit_canvas_element( &mut self )
|
||||
{
|
||||
let insets = safe_area( self.platform.inset_probe.as_ref() );
|
||||
if insets != self.main.insets
|
||||
{
|
||||
self.main.insets = insets;
|
||||
self.main.request_redraw();
|
||||
}
|
||||
|
||||
let canvas = &self.platform.canvas;
|
||||
let ( w, h ) = ( canvas.client_width().max( 1 ) as u32, canvas.client_height().max( 1 ) as u32 );
|
||||
let scale = web_sys::window().map_or( 1.0, |w| w.device_pixel_ratio() ) as f32;
|
||||
if self.main.configured && ( w, h ) == ( self.main.width, self.main.height ) && scale == self.main.scale_factor { return; }
|
||||
|
||||
if scale != self.main.scale_factor
|
||||
{
|
||||
self.main.scale_factor = scale;
|
||||
if let Some( ref mut canvas ) = self.main.canvas { canvas.set_dpi_scale( scale ); }
|
||||
}
|
||||
self.main.set_size( w, h );
|
||||
self.on_main_resized( w, h );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user