A browser app joins the page's desktop: on a page with forge/web its main surface, overlays and subsurfaces are surfaces of that desktop, all painted through one WebGL context
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

The browser backend ran an app alone on a canvas over the page. An Eydos desktop in a browser needs crustace as its shell and other ltk apps as its windows on one page, managed by forge/web (forge's web/ directory), which publishes `globalThis.forgeWeb` and stacks, places and focuses the surfaces its clients ask for (forge/web/DESIGN.md).
The desktop's client. On a page where `globalThis.forgeWeb` exists and `set_canvas` named no canvas, `platform/web/desktop.rs` connects as the app's `app_id` and asks for the main surface in the role the app's hooks give, as a Wayland compositor reads them: `shell_mode`, and for a layer `layer_anchor`, `layer_size`, `exclusive_zone` and `keyboard_exclusive`, for a window `app_id` and `window_size_hint`, and for `SessionLock` a full overlay that holds the keyboard. The app draws into the canvas forge/web gives and listens on the input element above it, takes its size and scale from forge/web's `configure` rather than from the window's `resize`, sends its input region in logical pixels when it changes, and leaves the keyboard focus to the desktop, which honours the surfaces that hold it exclusively. A `close` of the main surface reaches `App::on_close_requested`; on exit the surface is destroyed and the connection closed. The new `RunError::Desktop` says forge/web refused the main surface. Fullscreen inside a desktop does nothing yet. A page without a desktop, or one that named a canvas, runs the app as before.
One WebGL context. A page holds few WebGL contexts, and the browser loses the oldest beyond them, while a shell has many surfaces. Every surface of an app now paints through one WebGL2 context on an `OffscreenCanvas`: the surface's FBO is blitted onto it, taken as an `ImageBitmap` and handed to the surface's own canvas, a `bitmaprenderer`, without a copy. Without WebGL2 each surface draws in software on a 2D context of its own. The context's loss is heard on the `OffscreenCanvas`, and now drops the GPU canvases of every surface, not only the main one's.
Overlays. `platform/web/overlays.rs` keeps the app's overlays in step with `App::overlays()`, as the Wayland backend's `overlays_reconcile.rs` does with layer-shell: a new one becomes a layer surface or, for `Layer::Window`, a window, with its size and exclusive zone in logical pixels; a changed one gets an `update_role`; one that went is destroyed; forge/web's `close` of one delivers its `on_dismiss`. Popups anchored to a widget and the tooltip wait for a popup role forge/web does not have, and without a desktop an app still shows no overlays. What every backend does as overlays come and go, resolving an `OverlaySpec::size` and moving a drag and the focuses off an overlay that goes, moved out of the Wayland backend into `event_loop/overlays.rs` (`AppData::overlay_size`, `AppData::remove_overlay`); Wayland's behaviour is unchanged.
Subsurfaces. `platform/web/subsurfaces.rs` makes each of `App::subsurfaces()` a surface of the desktop sized to its parent with an empty input region, painted on one shared GPU canvas only when it appears, its size changes or its `content_version` does, its frame then staying on its own canvas, and otherwise moved with an `update_role`, which the browser composes without painting. `App::on_subsurface_committed` follows each paint. A subsurface whose parent overlay was destroyed and made again is made again, since forge/web destroys a subsurface with its parent. `frame_presented` now knows whether a surface hosts subsurfaces, so while the app animates by subsurface motion only its frames keep coming without painting the parent, as Wayland keeps a parent's frame callbacks. Painting a view with no focus, hover or press on a canvas of its own, which both backends' subsurfaces do, moved out of the Wayland backend into `draw::paint_detached`.
Input per surface. `input::listen` takes the surface's `SurfaceFocus`, which pointer, touch and keyboard focus events carry; a surface's blur no longer takes the keyboard from the surface focused after it. Listeners remove themselves when dropped, and a surface's live in its `WebSurfaceState`, so they go with it. The frames owed at the next animation frame are a list of surfaces instead of one, which with several surfaces left the main one waiting on a frame that never came.
`examples/web_shell.rs` is a shell for the desktop: a background, a bar in the top layer with an exclusive zone, a panel that takes the keyboard, and a card on a subsurface that slides. `make web-example` builds it with web_hello (`WEB_EXAMPLES`); forge/web's `make hello` serves both at /shell.html. web-sys gains `OffscreenCanvas`, `ImageBitmap` and `ImageBitmapRenderingContext`. `CHANGELOG.md` describes the change.
Not done. Popups and the tooltip in a desktop, overlays and subsurfaces without one, fullscreen and window moves through forge/web, and the cursor's shape over an overlay, which is still set on the main surface's element. The offscreen canvas is resized for each surface it paints, which reallocates its drawing buffer when the surfaces differ in size. There are no tests for the browser backend.
This commit is contained in:
2026-10-19 22:42:12 +02:00
parent 95beeda66a
commit cd1a233b2e
20 changed files with 1086 additions and 266 deletions
+1
View File
@@ -6,6 +6,7 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
### Added ### Added
- **A browser app joins the page's desktop** — on a page where forge/web has published `globalThis.forgeWeb`, the web backend connects to it as the app's `app_id` and asks for its main surface in the role the app's hooks give (`shell_mode`, `layer_anchor`, `layer_size`, `exclusive_zone`, `keyboard_exclusive`, `window_size_hint`), as a Wayland compositor would read them; it draws into the canvas forge/web gives, listens on the input element above it, takes its size and scale from forge/web's `configure`, sends its input region when it changes, and leaves the keyboard focus to the desktop. A `close` reaches `App::on_close_requested`, and on exit the surface is destroyed and the connection closed. The app's overlays become surfaces of the desktop as `App::overlays()` changes, layer surfaces or, for `Layer::Window`, windows, with their sizes and zones in logical pixels and an `update_role` when one changes; forge/web's `close` of one delivers its `on_dismiss`. Popups anchored to a widget and the tooltip wait for a popup role. What every backend does as overlays come and go, resolving an `OverlaySpec::size` and moving a drag and the focuses off an overlay that goes, moved out of the Wayland backend into `event_loop/overlays.rs` (`AppData::overlay_size`, `AppData::remove_overlay`). The app's subsurfaces become surfaces of the desktop too, each sized to its parent and taking no input, painted on one shared GPU canvas only when it appears, its size changes or its `content_version` does, and otherwise moved with an `update_role`, which the browser composes without painting; `App::on_subsurface_committed` follows each paint, and while the app animates by subsurface motion only (`App::subsurface_motion_only`) a parent's frames keep coming without painting it. Painting a view on a canvas with no focus, hover or press, which both backends' subsurfaces do, moved out of the Wayland backend into `draw::paint_detached`. A lost WebGL context now drops the overlays' and subsurfaces' GPU canvases too, not only the main surface's. `examples/web_shell.rs` is a shell for the desktop, a background with a bar in the top layer, a panel that takes the keyboard and a card on a subsurface that slides. A page without a desktop, or one that named a canvas with `set_canvas`, runs the app as before. The new `RunError::Desktop` says forge/web refused the main surface. Every surface now paints through the app's one WebGL2 context, on an `OffscreenCanvas`, and its frame moves to the surface's canvas, a `bitmaprenderer`, as an `ImageBitmap`: a page holds few WebGL contexts, and a desktop's apps have many surfaces.
- **Browser backend, the `web` feature** — `ltk::run` on a page's canvas, built for `wasm32-unknown-unknown` with wasm-bindgen, js-sys and web-sys, optional dependencies on that target only. `build.rs` refuses it with another backend or for another target, so a web build uses `default-features = false, features = [ "web" ]`. A page's main thread must not block, so `run` sets the app up and returns, and `setTimeout` and `requestAnimationFrame` drive the loop; `Sources` (`event_loop/sources.rs`) gains a queue of timers and channels for it, and page events and `ChannelSender::send` only queue and schedule 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, or without it in software through `putImageData`; its logical size is the canvas's CSS size, its scale the page's `devicePixelRatio`, fractional as it may be, so the canvas holds the display's own pixels. Panics go to the browser's console. When the app exits, the canvas receives an `ltk-exit` event. The new `RunError::NoDocument` is returned where there is no page, as in a worker. The built-in keyboard serves text where the page is used by touch. Input comes from the canvas's Pointer Events, a mouse as the pointer and fingers and pens as touches, at `offsetX` / `offsetY` in CSS pixels; a press captures the pointer and focuses the canvas. A wheel in lines scrolls in detents, one in pixels continuously, and the page neither scrolls nor zooms under the canvas. Keys take their keysym from `KeyboardEvent.key`, their code from `.code` as the Linux input code Wayland reports, and repeat on the browser's clock; AltGr types its character rather than counting as Ctrl and Alt. ltk takes every key the browser gives a page, and while fullscreen also those `navigator.keyboard.lock()` gives where there is one. The cursor's shape is the canvas's CSS `cursor`, and on macOS Command counts as Ctrl. 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 as a Ctrl+V of its own. A lost WebGL 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. On ltk's own canvas the page's `env( safe-area-inset-* )`, read from a hidden element, become the main surface's insets. Resources are the tree an Android package carries, which `scripts/web-assets.sh` builds through `android-assets.sh` and `make web-assets` builds into `target/web/ltk/` with the in-tree default theme and the fallback fonts `WEB_FONTS` names; `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 now read through `resources::read_font`, so a path that does not exist is found by file name in the resource directory's `fonts/`, which is where a page's are. `wasm-bindgen-futures` joins the `web` feature. `examples/web_hello.rs` is the smoke test: `make web-check` typechecks the backend, `make web-example` builds the example and runs wasm-bindgen into `target/web/`, `make web-serve` serves it, and `make web-deps` installs Debian's std for wasm32 and wasm-bindgen-cli at the version of `Cargo.lock`. The text-scale watcher does not run in a browser, and the built-in keyboard's language is the page's. Not yet: overlays, secondary surfaces and subsurfaces, dead keys and input methods, the accessibility tree, the primary selection, and a canvas that changes size while the window does not. - **Browser backend, the `web` feature** — `ltk::run` on a page's canvas, built for `wasm32-unknown-unknown` with wasm-bindgen, js-sys and web-sys, optional dependencies on that target only. `build.rs` refuses it with another backend or for another target, so a web build uses `default-features = false, features = [ "web" ]`. A page's main thread must not block, so `run` sets the app up and returns, and `setTimeout` and `requestAnimationFrame` drive the loop; `Sources` (`event_loop/sources.rs`) gains a queue of timers and channels for it, and page events and `ChannelSender::send` only queue and schedule 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, or without it in software through `putImageData`; its logical size is the canvas's CSS size, its scale the page's `devicePixelRatio`, fractional as it may be, so the canvas holds the display's own pixels. Panics go to the browser's console. When the app exits, the canvas receives an `ltk-exit` event. The new `RunError::NoDocument` is returned where there is no page, as in a worker. The built-in keyboard serves text where the page is used by touch. Input comes from the canvas's Pointer Events, a mouse as the pointer and fingers and pens as touches, at `offsetX` / `offsetY` in CSS pixels; a press captures the pointer and focuses the canvas. A wheel in lines scrolls in detents, one in pixels continuously, and the page neither scrolls nor zooms under the canvas. Keys take their keysym from `KeyboardEvent.key`, their code from `.code` as the Linux input code Wayland reports, and repeat on the browser's clock; AltGr types its character rather than counting as Ctrl and Alt. ltk takes every key the browser gives a page, and while fullscreen also those `navigator.keyboard.lock()` gives where there is one. The cursor's shape is the canvas's CSS `cursor`, and on macOS Command counts as Ctrl. 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 as a Ctrl+V of its own. A lost WebGL 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. On ltk's own canvas the page's `env( safe-area-inset-* )`, read from a hidden element, become the main surface's insets. Resources are the tree an Android package carries, which `scripts/web-assets.sh` builds through `android-assets.sh` and `make web-assets` builds into `target/web/ltk/` with the in-tree default theme and the fallback fonts `WEB_FONTS` names; `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 now read through `resources::read_font`, so a path that does not exist is found by file name in the resource directory's `fonts/`, which is where a page's are. `wasm-bindgen-futures` joins the `web` feature. `examples/web_hello.rs` is the smoke test: `make web-check` typechecks the backend, `make web-example` builds the example and runs wasm-bindgen into `target/web/`, `make web-serve` serves it, and `make web-deps` installs Debian's std for wasm32 and wasm-bindgen-cli at the version of `Cargo.lock`. The text-scale watcher does not run in a browser, and the built-in keyboard's language is the page's. Not yet: overlays, secondary surfaces and subsurfaces, dead keys and input methods, the accessibility tree, the primary selection, and a canvas that changes size while the window does not.
- **The built-in keyboard serves an app's own text input** — while `App::external_text_input` asks for text and no ltk text field has the focus, the built-in keyboard shows with `App::external_text_purpose` (a terminal gets its row of Escape, Tab, Ctrl, Alt and arrows), comes back when the serial changes, and hides when the request ends. Its keys reach the app as physical ones, pressed and released through `App::on_raw_key_event` with the panel's Ctrl and Alt held for them. Before, the built-in keyboard only served ltk's text fields, so such an app had no keyboard where the compositor offers no input method. - **The built-in keyboard serves an app's own text input** — while `App::external_text_input` asks for text and no ltk text field has the focus, the built-in keyboard shows with `App::external_text_purpose` (a terminal gets its row of Escape, Tab, Ctrl, Alt and arrows), comes back when the serial changes, and hides when the request ends. Its keys reach the app as physical ones, pressed and released through `App::on_raw_key_event` with the panel's Ctrl and Alt held for them. Before, the built-in keyboard only served ltk's text fields, so such an app had no keyboard where the compositor offers no input method.
- **Android: the system clipboard, back that leaves, ltk's keyboard, `HOME` and `LANG`** — copies go to Android's `ClipboardManager` and its text is read back whenever the app gains the focus, through JNI on the activity (the `jni` crate joins the `android` feature). Back with nothing left to go back to minimises the app with `moveTaskToBack`, or closes it, as `App::back_at_root` says; the new `Platform::back_leaves` decides whether back may leave the main surface, which keeps Wayland's rule of the xdg-toplevel. Text is typed on ltk's own keyboard rather than the system's, from which a NativeActivity gets only key events. `set_android_app` sets `HOME` to the app's internal storage and, unless set, `SSL_CERT_DIR` to Android's CA certificates and `LANG` to the device's language, so desktop code finds its preferences, a TLS client its roots (rustls-native-certs knows no Android path) and the locale. - **Android: the system clipboard, back that leaves, ltk's keyboard, `HOME` and `LANG`** — copies go to Android's `ClipboardManager` and its text is read back whenever the app gains the focus, through JNI on the activity (the `jni` crate joins the `android` feature). Back with nothing left to go back to minimises the app with `moveTaskToBack`, or closes it, as `App::back_at_root` says; the new `Platform::back_leaves` decides whether back may leave the main surface, which keeps Wayland's rule of the xdg-toplevel. Text is typed on ltk's own keyboard rather than the system's, from which a NativeActivity gets only key events. `set_android_app` sets `HOME` to the app's internal storage and, unless set, `SSL_CERT_DIR` to Android's CA certificates and `LANG` to the device's language, so desktop code finds its preferences, a TLS client its roots (rustls-native-certs knows no Android path) and the locale.
+8
View File
@@ -119,12 +119,15 @@ web-sys = { version = "0.3", optional = true, features = [
"EventTarget", "EventTarget",
"HtmlCanvasElement", "HtmlCanvasElement",
"HtmlElement", "HtmlElement",
"ImageBitmap",
"ImageBitmapRenderingContext",
"ImageData", "ImageData",
"KeyboardEvent", "KeyboardEvent",
"MediaQueryList", "MediaQueryList",
"MouseEvent", "MouseEvent",
"Navigator", "Navigator",
"Node", "Node",
"OffscreenCanvas",
"PointerEvent", "PointerEvent",
"Response", "Response",
"WebGl2RenderingContext", "WebGl2RenderingContext",
@@ -146,6 +149,11 @@ name = "web_hello"
crate-type = [ "cdylib" ] crate-type = [ "cdylib" ]
required-features = [ "web" ] required-features = [ "web" ]
[[example]]
name = "web_shell"
crate-type = [ "cdylib" ]
required-features = [ "web" ]
[[bench]] [[bench]]
name = "lookup" name = "lookup"
harness = false harness = false
+6 -2
View File
@@ -28,6 +28,8 @@ ANDROID_APK ?= target/android/ltk-hello.apk
WEB_TARGET = wasm32-unknown-unknown WEB_TARGET = wasm32-unknown-unknown
WEB_CARGO = --target $(WEB_TARGET) --no-default-features --features web WEB_CARGO = --target $(WEB_TARGET) --no-default-features --features web
WEB_OUT ?= target/web WEB_OUT ?= target/web
# web_hello runs alone on a page; web_shell needs forge/web, whose `make hello` serves both.
WEB_EXAMPLES = web_hello web_shell
# Fallback fonts a page bundles beyond the theme's, for text Sora does not # Fallback fonts a page bundles beyond the theme's, for text Sora does not
# cover: Cyrillic, Greek and extended Latin, symbols, arrows and box drawing. # cover: Cyrillic, Greek and extended Latin, symbols, arrows and box drawing.
WEB_FONTS ?= $(wildcard /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf /usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf) WEB_FONTS ?= $(wildcard /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf /usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf)
@@ -121,8 +123,10 @@ web-assets:
# examples/web_hello.rs as a page under $(WEB_OUT), with its resources. # examples/web_hello.rs as a page under $(WEB_OUT), with its resources.
web-example: web-assets web-example: web-assets
@test -n "$(WASM_BINDGEN)" || { echo "No wasm-bindgen in $(WEB_TOOLS)/bin or on PATH; run make web-deps"; exit 1; } @test -n "$(WASM_BINDGEN)" || { echo "No wasm-bindgen in $(WEB_TOOLS)/bin or on PATH; run make web-deps"; exit 1; }
cargo build --release --example web_hello $(WEB_CARGO) cargo build --release $(addprefix --example ,$(WEB_EXAMPLES)) $(WEB_CARGO)
$(WASM_BINDGEN) --target web --no-typescript --out-dir $(WEB_OUT)/pkg target/$(WEB_TARGET)/release/examples/web_hello.wasm for example in $(WEB_EXAMPLES); do \
$(WASM_BINDGEN) --target web --no-typescript --out-dir $(WEB_OUT)/pkg target/$(WEB_TARGET)/release/examples/$$example.wasm || exit 1; \
done
cp web/index.html $(WEB_OUT)/ cp web/index.html $(WEB_OUT)/
web-serve: web-example web-serve: web-example
+185
View File
@@ -0,0 +1,185 @@
//! A shell in a browser's desktop, as crustace is on forge: a background, a bar along the top that keeps windows below it, a panel that opens over everything and takes the keyboard, and a card on a subsurface that slides without being painted again. It needs forge/web on the page; `make hello` in forge/web serves it at /shell.html, with web_hello as a window.
use ltk::{ Anchor, App, ButtonVariant, Element, Layer, Length, OverlayId, OverlaySpec, ShellMode, SubsurfaceId, SubsurfaceParent, SubsurfaceSpec, button, column, container, row, spacer, text, text_edit };
use wasm_bindgen::prelude::wasm_bindgen;
const BAR: OverlayId = OverlayId( 1 );
const PANEL: OverlayId = OverlayId( 2 );
const CARD: SubsurfaceId = SubsurfaceId( 1 );
/// How far the card slides, and the milliseconds it takes to come and go.
const SLIDE_PX: f64 = 600.0;
const SLIDE_MS: f64 = 3000.0;
/// The bar's height, in density-independent pixels.
const BAR_HEIGHT: f32 = 48.0;
#[ derive( Clone ) ]
enum Msg
{
TogglePanel,
ClosePanel,
Edit( String ),
ToggleSlide,
}
#[ derive( Default ) ]
struct Shell
{
panel: bool,
note: String,
/// When the card started sliding, in milliseconds since the epoch.
sliding: Option<f64>,
}
impl Shell
{
fn bar( &self ) -> OverlaySpec<Msg>
{
let palette = ltk::theme_palette();
let view = container(
row::<Msg>()
.spacing( 12.0 )
.push( text( "ltk shell" ).size( 18.0 ).color( palette.text_primary ) )
.push( spacer() )
.push( button::<Msg>( if self.sliding.is_some() { "Parar" } else { "Deslizar" } ).variant( ButtonVariant::Secondary ).on_press( Msg::ToggleSlide ) )
.push( button::<Msg>( if self.panel { "Cerrar panel" } else { "Abrir panel" } ).variant( ButtonVariant::Secondary ).on_press( Msg::TogglePanel ) ),
)
.background( palette.surface )
.padding_h( 16.0 );
OverlaySpec
{
id: BAR,
layer: Layer::Top,
anchor: Anchor::TOP,
size: ( Length::px( 0.0 ), Length::dp( BAR_HEIGHT ) ),
exclusive_zone: ( BAR_HEIGHT * ltk::density() ).round() as i32,
keyboard_exclusive: false,
input_region: None,
view: view.into(),
on_dismiss: None,
anchor_widget_id: None,
}
}
fn panel( &self ) -> OverlaySpec<Msg>
{
let palette = ltk::theme_palette();
let view = container(
column::<Msg>()
.spacing( 16.0 )
.push( text( "Panel con el teclado en exclusiva" ).size( 20.0 ).color( palette.text_primary ) )
.push( text_edit::<Msg>( "Escribe aquí", &self.note ).on_change( Msg::Edit ) )
.push( button::<Msg>( "Cerrar" ).variant( ButtonVariant::Secondary ).on_press( Msg::ClosePanel ) ),
)
.background( palette.surface_alt )
.radius( 16.0 )
.padding( 24.0 );
OverlaySpec
{
id: PANEL,
layer: Layer::Overlay,
anchor: Anchor { top: false, bottom: false, left: false, right: false },
size: ( Length::dp( 420.0 ), Length::dp( 260.0 ) ),
exclusive_zone: 0,
keyboard_exclusive: true,
input_region: None,
view: view.into(),
on_dismiss: Some( Msg::ClosePanel ),
anchor_widget_id: None,
}
}
/// The card's offset along its slide, back and forth.
fn card_x( &self ) -> i32
{
let Some( start ) = self.sliding else { return 0 };
let phase = ( js_sys::Date::now() - start ) / SLIDE_MS * std::f64::consts::TAU;
( ( 1.0 - phase.cos() ) / 2.0 * SLIDE_PX * ltk::density() as f64 ).round() as i32
}
fn card( &self ) -> SubsurfaceSpec<Msg>
{
let palette = ltk::theme_palette();
let card = container( text( "Una subsuperficie: se mueve sin volver a pintarse" ).size( 18.0 ).color( palette.surface ) )
.background( palette.accent )
.radius( 12.0 )
.padding( 20.0 );
let density = ltk::density();
SubsurfaceSpec
{
id: CARD,
parent: SubsurfaceParent::Main,
view: column::<Msg>().push( row::<Msg>().push( card ) ).into(),
x: ( 40.0 * density ).round() as i32 + self.card_x(),
y: ( 120.0 * density ).round() as i32,
content_version: 1,
gpu: true,
}
}
}
impl App for Shell
{
type Message = Msg;
fn app_id( &self ) -> &str { "net.liberux.ltk.Shell" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn shell_mode( &self ) -> ShellMode { ShellMode::Layer( Layer::Background ) }
fn view( &self ) -> Element<Msg>
{
let palette = ltk::theme_palette();
column::<Msg>()
.push( spacer() )
.push( text( "El fondo del shell" ).size( 28.0 ).color( palette.text_secondary ) )
.push( spacer() )
.into()
}
fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
{
let mut out = vec![ self.bar() ];
if self.panel { out.push( self.panel() ); }
out
}
fn subsurfaces( &self ) -> Vec<SubsurfaceSpec<Msg>>
{
vec![ self.card() ]
}
fn is_animating( &self ) -> bool
{
self.sliding.is_some()
}
/// Sliding moves the card's subsurface and nothing else, so no frame paints the background again.
fn subsurface_motion_only( &self ) -> bool
{
self.sliding.is_some()
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::TogglePanel => self.panel = !self.panel,
Msg::ClosePanel => self.panel = false,
Msg::Edit( note ) => self.note = note,
Msg::ToggleSlide => self.sliding = if self.sliding.is_some() { None } else { Some( js_sys::Date::now() ) },
}
}
}
#[ wasm_bindgen( start ) ]
pub async fn start()
{
if let Err( e ) = ltk::web::load_resources( "ltk" ).await
{
web_sys::console::warn_1( &format!( "ltk resources: {e}" ).into() );
}
ltk::run( Shell::default() );
}
+1 -1
View File
@@ -40,7 +40,7 @@ pub( crate ) mod layout;
pub( crate ) use damage::{ compute_damage, compute_interaction_dirty_rects }; pub( crate ) use damage::{ compute_damage, compute_interaction_dirty_rects };
pub( crate ) use layout::layout_and_draw; pub( crate ) use layout::layout_and_draw;
#[ cfg( backend ) ] #[ cfg( backend ) ]
pub( crate ) use paint::{ fit_canvas, paint_full, paint_partial }; pub( crate ) use paint::{ fit_canvas, paint_detached, paint_full, paint_partial };
/// Per-frame draw state threaded through [`layout_and_draw`]. Captures /// Per-frame draw state threaded through [`layout_and_draw`]. Captures
/// the interaction snapshot (focus / hover / pressed), scratch space /// the interaction snapshot (focus / hover / pressed), scratch space
+36
View File
@@ -3,6 +3,7 @@
//! Painting a surface's view on its canvas, the same for every backend and renderer. The backend prepares the canvas before and presents it after; see [`crate::platform::Platform`]. //! Painting a surface's view on its canvas, the same for every backend and renderer. The backend prepares the canvas before and presents it after; see [`crate::platform::Platform`].
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use crate::event_loop::SurfaceState; use crate::event_loop::SurfaceState;
@@ -34,6 +35,41 @@ pub( crate ) fn fit_canvas( slot: &mut Option<Canvas>, pw: u32, ph: u32, scale:
canvas canvas
} }
/// Paint `view` over the whole of `canvas`, cleared to transparent, with no focus, hover or press, for a surface that takes no input such as a subsurface. `scroll` and `viewport` keep its sub-canvases between paints.
pub( crate ) fn paint_detached<Msg: Clone>( canvas: &mut Canvas, view: &Element<Msg>, scroll: &mut HashMap<usize, Canvas>, viewport: &mut HashMap<usize, Canvas> )
{
canvas.clear_clip();
canvas.clear();
let ( pw, ph ) = canvas.size();
let screen_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: ph as f32 };
let mut ctx = DrawCtx
{
focused_idx: None,
hovered_idx: None,
pressed_idx: None,
cursor_state: HashMap::new(),
selection_anchor: HashMap::new(),
text_scroll: HashMap::new(),
widget_rects: Vec::new(),
debug_layout: false,
scroll_offsets: HashMap::new(),
scroll_rects: Vec::new(),
scroll_canvases: std::mem::take( scroll ),
viewport_canvases: std::mem::take( viewport ),
viewport_seq: 0,
scroll_navigable_items: HashMap::new(),
scroll_clipped: Vec::new(),
previous_widget_rects: Vec::new(),
accessible_extras: Vec::new(),
live_depth: 0,
origin: ( 0.0, 0.0 ),
blur_regions: Vec::new(),
};
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
*scroll = ctx.scroll_canvases;
*viewport = ctx.viewport_canvases;
}
/// Repaint the whole surface. Returns the rects that changed when `precise_damage` asks for them and the content did not change wholesale, else none, meaning the whole surface. /// Repaint the whole surface. Returns the rects that changed when `precise_damage` asks for them and the content did not change wholesale, else none, meaning the whole surface.
pub( crate ) fn paint_full<Msg: Clone, S>( pub( crate ) fn paint_full<Msg: Clone, S>(
ss: &mut SurfaceState<Msg, S>, ss: &mut SurfaceState<Msg, S>,
+5
View File
@@ -44,6 +44,9 @@ pub enum RunError
/// In a browser, `ltk::run` was called where there is no page: /// In a browser, `ltk::run` was called where there is no page:
/// no `window` or `document`, as in a worker. /// no `window` or `document`, as in a worker.
NoDocument, NoDocument,
/// In a browser, the page's desktop (forge/web) refused the main
/// surface.
Desktop( String ),
} }
impl std::fmt::Display for RunError impl std::fmt::Display for RunError
@@ -64,6 +67,8 @@ impl std::fmt::Display for RunError
write!( f, "no AndroidApp: call ltk::set_android_app from android_main before ltk::run" ), write!( f, "no AndroidApp: call ltk::set_android_app from android_main before ltk::run" ),
Self::NoDocument => Self::NoDocument =>
write!( f, "no window or document: ltk::run runs on a page's main thread" ), write!( f, "no window or document: ltk::run runs on a page's main thread" ),
Self::Desktop( d ) =>
write!( f, "the page's desktop refused the main surface: {d}" ),
} }
} }
} }
+1
View File
@@ -9,6 +9,7 @@ pub( crate ) mod cursor_shape;
pub( crate ) mod drag; pub( crate ) mod drag;
pub( crate ) mod focus; pub( crate ) mod focus;
pub( crate ) mod osk; pub( crate ) mod osk;
pub( crate ) mod overlays;
pub( crate ) mod perf; pub( crate ) mod perf;
pub( crate ) mod repeat; pub( crate ) mod repeat;
pub( crate ) mod setup; pub( crate ) mod setup;
+50
View File
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! What every backend does as the app's overlays come and go: the size an overlay asks for, and what has to move to the main surface when one goes.
use crate::app::{ App, OverlayId };
use crate::platform::Platform;
use crate::types::Length;
use super::{ AppData, SurfaceFocus, SurfaceState };
impl<A: App, P: Platform> AppData<A, P>
{
/// `size`, an `OverlaySpec::size`, in physical pixels. It resolves against the main surface's physical viewport, the app's layout space, so an overlay sized with `Length::widget( … )` scales with the display like any widget, and `Length::px( n )` keeps a fixed size.
pub( crate ) fn overlay_size( &self, size: &( Length, Length ) ) -> ( u32, u32 )
{
let viewport = ( self.main.physical_width() as f32, self.main.physical_height() as f32 );
let density = crate::types::density();
let scale = self.main.scale_factor;
(
size.0.resolve_physical( viewport, Length::EM_BASE_DEFAULT, density, scale ).max( 0.0 ).round() as u32,
size.1.resolve_physical( viewport, Length::EM_BASE_DEFAULT, density, scale ).max( 0.0 ).round() as u32,
)
}
/// Forget overlay `id` and return its state for the backend to release. A drag a long press began on it moves to the main surface, since apps typically hide the overlay because of the long press and the motion and release that follow must still reach the app's drag handlers; every focus that named it names the main surface.
pub( crate ) fn remove_overlay( &mut self, id: OverlayId ) -> Option<SurfaceState<A::Message, P::Surface>>
{
let ss = self.overlays.remove( &id )?;
if ss.gesture.long_press_fired
{
self.main.gesture.long_press_fired = true;
self.main.gesture.long_press_origin = ss.gesture.long_press_origin;
// Keep the primary touch slot with the migrated drag so motion / release still route through the gesture machine.
if self.main.primary_touch_id.is_none()
{
self.main.primary_touch_id = ss.primary_touch_id;
}
}
let gone = SurfaceFocus::Overlay( id );
if self.pointer_focus == gone { self.pointer_focus = SurfaceFocus::Main; }
if self.keyboard_focus == gone { self.keyboard_focus = SurfaceFocus::Main; }
// Rewritten rather than dropped: a touch id with no focus would default to Main without the drag state, turning a release into a stray tap.
for f in self.touch_focus.values_mut()
{
if *f == gone { *f = SurfaceFocus::Main; }
}
Some( ss )
}
}
+5 -55
View File
@@ -25,9 +25,9 @@ use wayland_protocols::xdg::shell::client::xdg_positioner::
}; };
use crate::app::App; use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus, SurfaceState }; use crate::event_loop::{ AppData, SurfaceState };
use crate::event_loop::tooltip::tooltip_id; use crate::event_loop::tooltip::tooltip_id;
use crate::types::{ Length, Rect }; use crate::types::Rect;
use super::{ LayerConfig, SurfaceKind, WaylandSurfaceState }; use super::{ LayerConfig, SurfaceKind, WaylandSurfaceState };
@@ -47,19 +47,13 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
let next_ids: Vec<_> = specs.iter().map( |s| s.id ).collect(); let next_ids: Vec<_> = specs.iter().map( |s| s.id ).collect();
let wanted: HashSet<crate::app::OverlayId> = next_ids.iter().copied().collect(); let wanted: HashSet<crate::app::OverlayId> = next_ids.iter().copied().collect();
// Drop overlays that disappeared from the spec list. If the overlay had
// an in-flight drag (long-press fired), migrate that state to `main` so
// the motion / release that follows still routes through the app's drag
// handlers — apps typically hide the overlay *because* of the long-press,
// and we must not lose the drag state with the surface.
let removed: Vec<_> = data.overlays.keys() let removed: Vec<_> = data.overlays.keys()
.filter( |id| !wanted.contains( id ) ) .filter( |id| !wanted.contains( id ) )
.copied() .copied()
.collect(); .collect();
for id in removed for id in removed
{ {
if let Some( mut ss ) = data.overlays.remove( &id ) let Some( mut ss ) = data.remove_overlay( id ) else { continue };
{
// A GLES canvas carries compiled programs, the glyph atlas and // A GLES canvas carries compiled programs, the glyph atlas and
// the image cache: keep it for the next overlay instead of // the image cache: keep it for the next overlay instead of
// paying all of that again on every open. // paying all of that again on every open.
@@ -70,38 +64,8 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
data.platform.canvas_pool.push( canvas ); data.platform.canvas_pool.push( canvas );
} }
} }
if ss.gesture.long_press_fired
{
data.main.gesture.long_press_fired = true;
data.main.gesture.long_press_origin = ss.gesture.long_press_origin;
// Keep the primary touch slot with the migrated drag so motion / release still route through the gesture machine.
if data.main.primary_touch_id.is_none()
{
data.main.primary_touch_id = ss.primary_touch_id;
}
}
}
}
// Clear any stale per-device focus pointing at a destroyed overlay.
if let SurfaceFocus::Overlay( id ) = data.pointer_focus
{
if !data.overlays.contains_key( &id ) { data.pointer_focus = SurfaceFocus::Main; }
}
if let SurfaceFocus::Overlay( id ) = data.keyboard_focus
{
if !data.overlays.contains_key( &id ) { data.keyboard_focus = SurfaceFocus::Main; }
}
// Rewrite touch_focus entries pointing at a destroyed overlay to Main
// rather than dropping them. Dropping would make subsequent motion/up
// events for the same touch id default to Main *without* the long-press
// drag state, turning a release into a stray tap.
for f in data.touch_focus.values_mut()
{
if let SurfaceFocus::Overlay( id ) = *f
{
if !data.overlays.contains_key( &id ) { *f = SurfaceFocus::Main; }
}
} }
let sizes: Vec<( u32, u32 )> = specs.iter().map( |s| data.overlay_size( &s.size ) ).collect();
// Create overlays that just appeared. Uses field-level borrow splitting so // Create overlays that just appeared. Uses field-level borrow splitting so
// the shared borrows of `layer_shell` / `xdg_shell` / `compositor_state` // the shared borrows of `layer_shell` / `xdg_shell` / `compositor_state`
@@ -123,19 +87,6 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
_ => None, _ => None,
}; };
let parent_scale = data.main.scale_factor; 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
// any other widget. `Length::px( n )` keeps a fixed size.
let main_vp = ( data.main.physical_width() as f32, data.main.physical_height() as f32 );
let resolve_size = move | s: &( Length, Length ) | -> ( u32, u32 )
{
let density = crate::types::density();
(
s.0.resolve_physical( main_vp, Length::EM_BASE_DEFAULT, density, parent_scale ).max( 0.0 ).round() as u32,
s.1.resolve_physical( main_vp, Length::EM_BASE_DEFAULT, density, parent_scale ).max( 0.0 ).round() as u32,
)
};
// `OverlaySpec::size` is physical pixels (the app's layout space); the // `OverlaySpec::size` is physical pixels (the app's layout space); the
// layer-shell `set_size` is logical. They only coincide at scale 1, so // layer-shell `set_size` is logical. They only coincide at scale 1, so
// convert here — without it a scale-2 overlay requests a surface twice // convert here — without it a scale-2 overlay requests a surface twice
@@ -162,9 +113,8 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
let overlays_m = &mut data.overlays; let overlays_m = &mut data.overlays;
let canvas_pool = &mut data.platform.canvas_pool; let canvas_pool = &mut data.platform.canvas_pool;
let has_egl = data.platform.egl_context.is_some(); let has_egl = data.platform.egl_context.is_some();
for spec in &specs for ( spec, &resolved_size ) in specs.iter().zip( &sizes )
{ {
let resolved_size = resolve_size( &spec.size );
if let Some( ss ) = overlays_m.get_mut( &spec.id ) if let Some( ss ) = overlays_m.get_mut( &spec.id )
{ {
// Already-live overlay: propagate size changes to the layer // Already-live overlay: propagate size changes to the layer
+3 -56
View File
@@ -23,12 +23,10 @@ use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface;
use crate::app::{ App, SubsurfaceParent }; use crate::app::{ App, SubsurfaceParent };
use crate::egl_context::{ EglContext, EglSurface }; use crate::egl_context::{ EglContext, EglSurface };
use crate::draw::DrawCtx; use crate::draw::paint_detached;
use crate::draw::layout_and_draw;
use crate::event_loop::{ AppData, SurfaceFocus }; use crate::event_loop::{ AppData, SurfaceFocus };
use crate::event_loop::perf::RasterTrace; use crate::event_loop::perf::RasterTrace;
use crate::render::Canvas; use crate::render::Canvas;
use crate::types::Rect;
use crate::widget::Element; use crate::widget::Element;
use super::present::{ apply_input_region, pick_shm_format }; use super::present::{ apply_input_region, pick_shm_format };
@@ -306,43 +304,6 @@ pub( crate ) fn reconcile_subsurfaces<A: App>( data: &mut AppData<A> )
} }
} }
/// Input-transparent subsurfaces never carry focus / hover / scroll state,
/// so they draw with an empty context.
fn empty_draw_ctx<Msg: Clone>() -> DrawCtx<Msg>
{
DrawCtx
{
focused_idx: None,
hovered_idx: None,
pressed_idx: None,
cursor_state: HashMap::new(),
selection_anchor: HashMap::new(),
text_scroll: HashMap::new(),
widget_rects: Vec::new(),
debug_layout: false,
scroll_offsets: HashMap::new(),
scroll_rects: Vec::new(),
scroll_canvases: HashMap::new(),
viewport_canvases: HashMap::new(),
viewport_seq: 0,
scroll_navigable_items: HashMap::new(),
scroll_clipped: Vec::new(),
previous_widget_rects: Vec::new(),
accessible_extras: Vec::new(),
live_depth: 0,
origin: ( 0.0, 0.0 ),
blur_regions: Vec::new(),
}
}
fn slot_draw_ctx<Msg: Clone>( scroll: &mut HashMap<usize, Canvas>, viewport: &mut HashMap<usize, Canvas> ) -> DrawCtx<Msg>
{
let mut ctx = empty_draw_ctx::<Msg>();
ctx.scroll_canvases = std::mem::take( scroll );
ctx.viewport_canvases = std::mem::take( viewport );
ctx
}
/// Raster a subsurface's content. Uses the GLES path when an [`EglContext`] /// Raster a subsurface's content. Uses the GLES path when an [`EglContext`]
/// is available — only there does the `surface-panel` backdrop blur render; /// is available — only there does the `surface-panel` backdrop blur render;
/// the software fallback paints the same widgets without the blur. /// the software fallback paints the same widgets without the blur.
@@ -419,16 +380,9 @@ fn render_slot_gpu<Msg: Clone>(
canvas.resize( pw, ph ); canvas.resize( pw, ph );
canvas.set_dpi_scale( scale as f32 ); canvas.set_dpi_scale( scale as f32 );
} }
canvas.clear_clip(); paint_detached::<Msg>( canvas, view, &mut slot.scroll_canvases, &mut slot.viewport_canvases );
canvas.clear();
let screen_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: ph as f32 };
let mut ctx = slot_draw_ctx::<Msg>( &mut slot.scroll_canvases, &mut slot.viewport_canvases );
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
canvas.present(); canvas.present();
if let Some( t ) = trace { t.end( egl_ctx.gl(), "subsurface", pw, ph ); } if let Some( t ) = trace { t.end( egl_ctx.gl(), "subsurface", pw, ph ); }
slot.scroll_canvases = ctx.scroll_canvases;
slot.viewport_canvases = ctx.viewport_canvases;
let wl = &slot.surface; let wl = &slot.surface;
wl.set_buffer_scale( scale as i32 ); wl.set_buffer_scale( scale as i32 );
@@ -484,14 +438,7 @@ fn render_slot_software<Msg: Clone>(
canvas.resize( pw, ph ); canvas.resize( pw, ph );
canvas.set_dpi_scale( scale as f32 ); canvas.set_dpi_scale( scale as f32 );
} }
canvas.clear_clip(); paint_detached::<Msg>( canvas, view, &mut slot.scroll_canvases, &mut slot.viewport_canvases );
canvas.clear();
let screen_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: ph as f32 };
let mut ctx = slot_draw_ctx::<Msg>( &mut slot.scroll_canvases, &mut slot.viewport_canvases );
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
slot.scroll_canvases = ctx.scroll_canvases;
slot.viewport_canvases = ctx.viewport_canvases;
canvas.write_to_wayland_buf( canvas_buf, swap_rb ); canvas.write_to_wayland_buf( canvas_buf, swap_rb );
+123
View File
@@ -0,0 +1,123 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The page's desktop, when it has one: forge/web publishes `globalThis.forgeWeb`, and the app's surfaces are asked of it by role and drawn into the elements it gives (forge/web/DESIGN.md, section 4). Its events are queued like any other page event.
use serde_json::{ json, Value };
use wasm_bindgen::closure::Closure;
use wasm_bindgen::{ JsCast, JsValue };
use web_sys::{ HtmlCanvasElement, HtmlElement };
use crate::app::{ App, Layer, ShellMode };
use super::run::{ push, PageEvent };
/// The id the main surface goes by.
pub( super ) const MAIN: u32 = 0;
pub( crate ) struct Connection
{
request: js_sys::Function,
disconnect: js_sys::Function,
_on_event: Closure<dyn FnMut( String )>,
}
impl Connection
{
/// Connect as `app_id`; `None` when the page has no desktop.
pub( super ) fn open( app_id: &str ) -> Option<Self>
{
let api = js_sys::Reflect::get( &js_sys::global(), &"forgeWeb".into() ).ok().filter( |v| v.is_object() )?;
let connect: js_sys::Function = js_sys::Reflect::get( &api, &"connect".into() ).ok()?.dyn_into().ok()?;
let on_event = Closure::<dyn FnMut( String )>::new( on_event );
let connection = connect.call2( &api, &JsValue::from_str( app_id ), on_event.as_ref() ).ok()?;
let method = |name: &str| js_sys::Reflect::get( &connection, &name.into() ).ok().and_then( |f| f.dyn_into::<js_sys::Function>().ok() );
Some( Self { request: method( "request" )?, disconnect: method( "disconnect" )?, _on_event: on_event } )
}
pub( super ) fn request( &self, message: &Value ) -> Result<JsValue, String>
{
self.request.call1( &JsValue::NULL, &JsValue::from_str( &message.to_string() ) )
.map_err( |e| e.as_string().unwrap_or_else( || format!( "{e:?}" ) ) )
}
/// A surface in the role `role`: its canvas, and the element above it that takes its input.
pub( super ) fn create_surface( &self, surface: u32, role: Value ) -> Result<( HtmlCanvasElement, HtmlElement ), String>
{
let elements = self.request( &json!( { "type": "create_surface", "surface": surface, "role": role } ) )?;
let get = |name: &str| js_sys::Reflect::get( &elements, &name.into() ).map_err( |_| format!( "create_surface: no {name}" ) );
let canvas = get( "canvas" )?.dyn_into().map_err( |_| "create_surface: canvas is not a canvas".to_string() )?;
let input = get( "input" )?.dyn_into().map_err( |_| "create_surface: input is not an element".to_string() )?;
Ok( ( canvas, input ) )
}
pub( super ) fn close( &self )
{
let _ = self.disconnect.call0( &JsValue::NULL );
}
}
fn on_event( json: String )
{
let Ok( event ) = serde_json::from_str::<Value>( &json ) else { return };
let surface = event[ "surface" ].as_u64().unwrap_or( 0 ) as u32;
match event[ "type" ].as_str()
{
Some( "configure" ) => push( PageEvent::Configure
{
surface,
width: event[ "width" ].as_f64().unwrap_or( 0.0 ),
height: event[ "height" ].as_f64().unwrap_or( 0.0 ),
scale: event[ "scale" ].as_f64().unwrap_or( 1.0 ),
} ),
Some( "close" ) => push( PageEvent::Close { surface } ),
_ => web_sys::console::warn_1( &format!( "[ltk] forge/web: {json}" ).into() ),
}
}
pub( super ) fn layer_name( layer: Layer ) -> &'static str
{
match layer
{
Layer::Background => "background",
Layer::Bottom => "bottom",
Layer::Top => "top",
Layer::Overlay => "overlay",
Layer::Window => "window",
}
}
/// The main surface's role, from the hooks a Wayland compositor would read it from.
pub( super ) fn main_role<A: App>( app: &A ) -> Value
{
let window = ||
{
let ( width, height ) = app.window_size_hint().unwrap_or( ( 0, 0 ) );
json!( { "kind": "window", "title": app.app_id(), "app_id": app.app_id(), "width": width, "height": height } )
};
match app.shell_mode()
{
ShellMode::Window | ShellMode::Layer( Layer::Window ) => window(),
ShellMode::Layer( layer ) =>
{
let anchor = app.layer_anchor();
let ( width, height ) = app.layer_size();
json!( {
"kind": "layer",
"layer": layer_name( layer ),
"anchor": { "top": anchor.top, "bottom": anchor.bottom, "left": anchor.left, "right": anchor.right },
"width": width,
"height": height,
"exclusive_zone": app.exclusive_zone(),
"keyboard": if app.keyboard_exclusive() { "exclusive" } else { "on_demand" },
} )
}
ShellMode::SessionLock => json!( {
"kind": "layer",
"layer": "overlay",
"anchor": { "top": true, "bottom": true, "left": true, "right": true },
"exclusive_zone": -1,
"keyboard": "exclusive",
} ),
}
}
+46 -43
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: LGPL-2.1-only // SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // 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 surface'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 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.
//! //!
@@ -12,7 +12,7 @@ use std::rc::Rc;
use std::sync::OnceLock; use std::sync::OnceLock;
use wasm_bindgen::{ JsCast, JsValue }; use wasm_bindgen::{ JsCast, JsValue };
use web_sys::{ ClipboardEvent, Event, HtmlCanvasElement, KeyboardEvent, PointerEvent, WheelEvent }; use web_sys::{ ClipboardEvent, Event, HtmlElement, KeyboardEvent, PointerEvent, WheelEvent };
use crate::app::App; use crate::app::App;
use crate::event_loop::{ AppData, RunError, SurfaceFocus }; use crate::event_loop::{ AppData, RunError, SurfaceFocus };
@@ -61,7 +61,8 @@ pub( super ) enum WebInput
shift: bool, shift: bool,
alt: bool, alt: bool,
}, },
Focus( bool ), /// The surface's input element gained or lost the keyboard focus.
Focus( SurfaceFocus, bool ),
/// What a `paste` brought, before the key that pastes it. /// What a `paste` brought, before the key that pastes it.
Clipboard( String ), Clipboard( String ),
} }
@@ -88,10 +89,12 @@ impl<A: App> AppData<A>
KeyAction::Release => self.key_release( &key ), KeyAction::Release => self.key_release( &key ),
} }
} }
WebInput::Focus( true ) => self.keyboard_enter( SurfaceFocus::Main ), WebInput::Focus( focus, true ) => self.keyboard_enter( focus ),
WebInput::Focus( false ) => WebInput::Focus( focus, false ) =>
{ {
// The keys held when the canvas lost the focus are released where the page does not see it. // Moving between surfaces blurs the old one before the new one is focused.
if self.keyboard_focus != focus { return; }
// The keys held when the surface 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 ); } if self.ctrl_pressed || self.shift_pressed || self.alt_pressed { self.modifiers_changed( false, false, false ); }
self.keyboard_leave(); self.keyboard_leave();
} }
@@ -100,29 +103,29 @@ impl<A: App> AppData<A>
} }
} }
/// Listen on `canvas` for every input the runtime takes. /// Listen on `target`, which takes the input of the surface `focus` names, for every input the runtime takes. A press focuses `target` when `take_focus`; in a desktop the desktop gives the keyboard.
pub( super ) fn listen( canvas: &HtmlCanvasElement ) -> Result<Vec<Listener>, RunError> pub( super ) fn listen( target: &HtmlElement, take_focus: bool, focus: SurfaceFocus ) -> Result<Vec<Listener>, RunError>
{ {
let hovering = Rc::new( Cell::new( false ) ); let hovering = Rc::new( Cell::new( false ) );
let mut listeners = Vec::new(); let mut listeners = Vec::new();
let ( target, over ) = ( canvas.clone(), Rc::clone( &hovering ) ); let ( element, over ) = ( target.clone(), Rc::clone( &hovering ) );
listeners.push( Listener::new( canvas, "pointerdown", move |event| pointer_down( &target, &over, &event ) )? ); listeners.push( Listener::new( target, "pointerdown", move |event| pointer_down( &element, take_focus, focus, &over, &event ) )? );
let over = Rc::clone( &hovering ); let over = Rc::clone( &hovering );
listeners.push( Listener::new( canvas, "pointermove", move |event| pointer_move( &over, &event ) )? ); listeners.push( Listener::new( target, "pointermove", move |event| pointer_move( focus, &over, &event ) )? );
let over = Rc::clone( &hovering ); let over = Rc::clone( &hovering );
listeners.push( Listener::new( canvas, "pointerup", move |event| pointer_up( &over, &event ) )? ); listeners.push( Listener::new( target, "pointerup", move |event| pointer_up( focus, &over, &event ) )? );
listeners.push( Listener::new( canvas, "pointercancel", |event| pointer_cancel( &event ) )? ); listeners.push( Listener::new( target, "pointercancel", |event| pointer_cancel( &event ) )? );
let over = Rc::clone( &hovering ); let over = Rc::clone( &hovering );
listeners.push( Listener::new( canvas, "pointerleave", move |event| pointer_leave( &over, &event ) )? ); listeners.push( Listener::new( target, "pointerleave", move |event| pointer_leave( focus, &over, &event ) )? );
let ( over, target ) = ( Rc::clone( &hovering ), canvas.clone() ); let ( over, element ) = ( Rc::clone( &hovering ), target.clone() );
listeners.push( Listener::new( canvas, "wheel", move |event| wheel( &target, &over, &event ) )? ); listeners.push( Listener::new( target, "wheel", move |event| wheel( &element, focus, &over, &event ) )? );
listeners.push( Listener::new( canvas, "contextmenu", |event| event.prevent_default() )? ); listeners.push( Listener::new( target, "contextmenu", |event| event.prevent_default() )? );
listeners.push( Listener::new( canvas, "keydown", |event| key( &event, true ) )? ); listeners.push( Listener::new( target, "keydown", |event| key( &event, true ) )? );
listeners.push( Listener::new( canvas, "keyup", |event| key( &event, false ) )? ); listeners.push( Listener::new( target, "keyup", |event| key( &event, false ) )? );
listeners.push( Listener::new( canvas, "focus", |_| queue( WebInput::Focus( true ) ) )? ); listeners.push( Listener::new( target, "focus", move |_| queue( WebInput::Focus( focus, true ) ) )? );
listeners.push( Listener::new( canvas, "blur", |_| queue( WebInput::Focus( false ) ) )? ); listeners.push( Listener::new( target, "blur", move |_| queue( WebInput::Focus( focus, false ) ) )? );
listeners.push( Listener::new( canvas, "paste", |event| paste( &event ) )? ); listeners.push( Listener::new( target, "paste", |event| paste( &event ) )? );
Ok( listeners ) Ok( listeners )
} }
@@ -131,7 +134,7 @@ fn queue( input: WebInput )
push( PageEvent::Input( input ) ); 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. /// Where `event` happened on the surface, in its CSS pixels. Read as numbers, since web-sys rounds `offsetX` to an integer.
fn position( event: &Event ) -> ( f64, f64 ) 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 ); let get = |name: &str| js_sys::Reflect::get( event, &JsValue::from_str( name ) ).ok().and_then( |v| v.as_f64() ).unwrap_or( 0.0 );
@@ -149,14 +152,14 @@ fn is_mouse( event: &PointerEvent ) -> bool
event.pointer_type() == "mouse" event.pointer_type() == "mouse"
} }
/// A mouse event, after the `Enter` the runtime expects first when the pointer was over the canvas before any `pointerenter`. /// A mouse event, after the `Enter` the runtime expects first when the pointer was over the surface before any `pointerenter`.
fn mouse( hovering: &Cell<bool>, position: ( f64, f64 ), kind: PointerKind ) fn mouse( focus: SurfaceFocus, hovering: &Cell<bool>, position: ( f64, f64 ), kind: PointerKind )
{ {
if !hovering.replace( true ) && kind != PointerKind::Enter if !hovering.replace( true ) && kind != PointerKind::Enter
{ {
queue( WebInput::Pointer( PointerInput { focus: SurfaceFocus::Main, position, kind: PointerKind::Enter } ) ); queue( WebInput::Pointer( PointerInput { focus, position, kind: PointerKind::Enter } ) );
} }
queue( WebInput::Pointer( PointerInput { focus: SurfaceFocus::Main, position, kind } ) ); queue( WebInput::Pointer( PointerInput { focus, position, kind } ) );
} }
fn button( button: i16 ) -> PointerButton fn button( button: i16 ) -> PointerButton
@@ -171,41 +174,41 @@ fn button( button: i16 ) -> PointerButton
} }
} }
/// The canvas takes the keyboard focus and every later event of the pointer, wherever it goes. /// The surface takes every later event of the pointer, wherever it goes, and the keyboard when `take_focus`.
fn pointer_down( canvas: &HtmlCanvasElement, hovering: &Cell<bool>, event: &Event ) fn pointer_down( target: &HtmlElement, take_focus: bool, focus: SurfaceFocus, hovering: &Cell<bool>, event: &Event )
{ {
let Some( e ) = event.dyn_ref::<PointerEvent>() else { return }; let Some( e ) = event.dyn_ref::<PointerEvent>() else { return };
event.prevent_default(); event.prevent_default();
let _ = canvas.focus(); if take_focus { let _ = target.focus(); }
let _ = canvas.set_pointer_capture( e.pointer_id() ); let _ = target.set_pointer_capture( e.pointer_id() );
let position = position( event ); let position = position( event );
if is_mouse( e ) if is_mouse( e )
{ {
mouse( hovering, position, PointerKind::Press( button( e.button() ) ) ); mouse( focus, hovering, position, PointerKind::Press( button( e.button() ) ) );
} else { } else {
queue( WebInput::Touch( TouchInput { id: e.pointer_id(), time: time( event ), phase: TouchPhase::Down { focus: SurfaceFocus::Main, position } } ) ); queue( WebInput::Touch( TouchInput { id: e.pointer_id(), time: time( event ), phase: TouchPhase::Down { focus, position } } ) );
} }
} }
/// A pen hovering moves with no button held, which is not a touch yet. /// A pen hovering moves with no button held, which is not a touch yet.
fn pointer_move( hovering: &Cell<bool>, event: &Event ) fn pointer_move( focus: SurfaceFocus, hovering: &Cell<bool>, event: &Event )
{ {
let Some( e ) = event.dyn_ref::<PointerEvent>() else { return }; let Some( e ) = event.dyn_ref::<PointerEvent>() else { return };
let position = position( event ); let position = position( event );
if is_mouse( e ) if is_mouse( e )
{ {
mouse( hovering, position, PointerKind::Motion ); mouse( focus, hovering, position, PointerKind::Motion );
} else if e.buttons() != 0 { } else if e.buttons() != 0 {
queue( WebInput::Touch( TouchInput { id: e.pointer_id(), time: time( event ), phase: TouchPhase::Motion { position } } ) ); queue( WebInput::Touch( TouchInput { id: e.pointer_id(), time: time( event ), phase: TouchPhase::Motion { position } } ) );
} }
} }
fn pointer_up( hovering: &Cell<bool>, event: &Event ) fn pointer_up( focus: SurfaceFocus, hovering: &Cell<bool>, event: &Event )
{ {
let Some( e ) = event.dyn_ref::<PointerEvent>() else { return }; let Some( e ) = event.dyn_ref::<PointerEvent>() else { return };
if is_mouse( e ) if is_mouse( e )
{ {
mouse( hovering, position( event ), PointerKind::Release( button( e.button() ) ) ); mouse( focus, hovering, position( event ), PointerKind::Release( button( e.button() ) ) );
} else { } else {
queue( WebInput::Touch( TouchInput { id: e.pointer_id(), time: time( event ), phase: TouchPhase::Up } ) ); queue( WebInput::Touch( TouchInput { id: e.pointer_id(), time: time( event ), phase: TouchPhase::Up } ) );
} }
@@ -217,15 +220,15 @@ fn pointer_cancel( event: &Event )
if !is_mouse( e ) { queue( WebInput::TouchCancel ); } if !is_mouse( e ) { queue( WebInput::TouchCancel ); }
} }
fn pointer_leave( hovering: &Cell<bool>, event: &Event ) fn pointer_leave( focus: SurfaceFocus, hovering: &Cell<bool>, event: &Event )
{ {
let Some( e ) = event.dyn_ref::<PointerEvent>() else { return }; let Some( e ) = event.dyn_ref::<PointerEvent>() else { return };
if !is_mouse( e ) || !hovering.replace( false ) { return; } if !is_mouse( e ) || !hovering.replace( false ) { return; }
queue( WebInput::Pointer( PointerInput { focus: SurfaceFocus::Main, position: position( event ), kind: PointerKind::Leave } ) ); queue( WebInput::Pointer( PointerInput { focus, 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. /// A wheel scrolls lines, in detents; a touchpad scrolls pixels. The page does not scroll or zoom under the surface.
fn wheel( canvas: &HtmlCanvasElement, hovering: &Cell<bool>, event: &Event ) fn wheel( target: &HtmlElement, focus: SurfaceFocus, hovering: &Cell<bool>, event: &Event )
{ {
let Some( e ) = event.dyn_ref::<WheelEvent>() else { return }; let Some( e ) = event.dyn_ref::<WheelEvent>() else { return };
event.prevent_default(); event.prevent_default();
@@ -238,12 +241,12 @@ fn wheel( canvas: &HtmlCanvasElement, hovering: &Cell<bool>, event: &Event )
} }
DOM_DELTA_PAGE => DOM_DELTA_PAGE =>
{ {
let ( w, h ) = ( canvas.client_width() as f64, canvas.client_height() as f64 ); let ( w, h ) = ( target.client_width() as f64, target.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() * 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 ), _ => ( 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 } ); mouse( focus, hovering, position( event ), PointerKind::Axis { horizontal, vertical, source } );
} }
fn is_mac() -> bool fn is_mac() -> bool
+19 -11
View File
@@ -1,31 +1,37 @@
// SPDX-License-Identifier: LGPL-2.1-only // SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // 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`. //! The browser backend: `ltk::run` on a page, built for `wasm32-unknown-unknown`. On a page with forge/web the app is a client of that desktop, its surfaces in the elements forge/web gives; on any other the app draws on a canvas of its own over the page, or on the one `set_canvas` names.
//! //!
//! | File | What it does for the runtime | //! | 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 | //! | `assets.rs` | The themes, fonts and keyboard layouts a page serves, fetched into memory as the app's resource directory |
//! | `desktop.rs` | The connection to forge/web and the main surface's role |
//! | `overlays.rs` | The app's overlays as surfaces of the desktop, kept in step with `App::overlays()` |
//! | `subsurfaces.rs` | The app's subsurfaces as surfaces of the desktop, painted when their content changes and otherwise only moved |
//! | `log.rs` | Panics into the browser's console | //! | `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 | //! | `state.rs` | [`WebPlatform`]: the desktop, the one WebGL2 context on its `OffscreenCanvas`, and what the runtime asked of the page |
//! | `surface.rs` | [`WebSurfaceState`]; sizing the main surface to the canvas | //! | `surface.rs` | [`WebSurfaceState`]: a surface's canvas and its context; sizing the main surface |
//! | `present.rs` | Putting a painted canvas on the page: the GPU canvas's blit, or the software one's pixels through `putImageData` | //! | `present.rs` | Putting a painted canvas on the page: through the shared WebGL context into a `bitmaprenderer`, or the software frame 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 | //! | `input.rs`, `keys.rs` | A surface'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 | //! | `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 | //! | `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 | //! | `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. //! In a desktop an app's overlays and subsurfaces are surfaces of their own; without one the app has only its main surface. Popups anchored to a widget, the tooltip and secondary surfaces are not created.
mod assets; mod assets;
mod clipboard; mod clipboard;
mod cursor; mod cursor;
mod desktop;
mod input; mod input;
mod keys; mod keys;
mod log; mod log;
mod overlays;
mod present; mod present;
pub( crate ) mod run; pub( crate ) mod run;
mod state; mod state;
mod subsurfaces;
mod surface; mod surface;
pub use assets::load_resources; pub use assets::load_resources;
@@ -63,13 +69,15 @@ impl<A: App> Platform for WebPlatform<A>
fn minimize_window( &self, _main: &WebSurfaceState ) {} fn minimize_window( &self, _main: &WebSurfaceState ) {}
fn set_fullscreen( &self, _main: &WebSurfaceState, fullscreen: bool ) -> bool /// In a desktop a window's fullscreen is forge/web's to give, which it does not yet.
fn set_fullscreen( &self, main: &WebSurfaceState, fullscreen: bool ) -> bool
{ {
if self.desktop.is_some() { return true; }
lock_keyboard( fullscreen ); lock_keyboard( fullscreen );
if fullscreen if fullscreen
{ {
let _ = self.canvas.request_fullscreen(); let _ = main.canvas.request_fullscreen();
} else if let Some( document ) = self.canvas.owner_document() { } else if let Some( document ) = main.canvas.owner_document() {
document.exit_fullscreen(); document.exit_fullscreen();
} }
true true
@@ -77,7 +85,7 @@ impl<A: App> Platform for WebPlatform<A>
fn request_frame( &self, _surface: &WebSurfaceState, focus: SurfaceFocus ) -> bool fn request_frame( &self, _surface: &WebSurfaceState, focus: SurfaceFocus ) -> bool
{ {
self.frame_due.set( Some( focus ) ); self.owe_frame( focus );
true true
} }
@@ -105,7 +113,7 @@ impl<A: App> Platform for WebPlatform<A>
fn set_cursor_shape( &mut self, shape: CursorShape ) -> bool fn set_cursor_shape( &mut self, shape: CursorShape ) -> bool
{ {
self.canvas.style().set_property( "cursor", cursor::css( shape ) ).is_ok() self.cursor_target.style().set_property( "cursor", cursor::css( shape ) ).is_ok()
} }
fn text_input_active( &self ) -> bool fn text_input_active( &self ) -> bool
+127
View File
@@ -0,0 +1,127 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The app's overlays as surfaces of the page's desktop: created, changed and destroyed as `App::overlays()` changes, as the Wayland backend's `overlays_reconcile.rs` does with layer-shell. Without a desktop the app shows no overlays. Popups anchored to a widget and the tooltip need a popup role forge/web does not have yet.
use std::collections::HashSet;
use serde_json::{ json, Value };
use crate::app::{ App, Layer, OverlayId, OverlaySpec };
use crate::event_loop::{ AppData, SurfaceFocus, SurfaceState };
use super::desktop::layer_name;
use super::WebSurfaceState;
/// `spec`'s role, its `size` given in physical pixels at `scale`: forge/web takes logical ones, and so does a zone, whose -1 and 0 are not distances.
fn overlay_role<A: App>( app: &A, spec: &OverlaySpec<A::Message>, ( width, height ): ( u32, u32 ), scale: f32 ) -> Value
{
let logical = |px: u32| ( px as f32 / scale ).round();
if spec.layer == Layer::Window
{
let title = app.overlay_title( spec.id ).unwrap_or_else( || app.app_id().to_string() );
return json!( { "kind": "window", "title": title, "app_id": app.app_id(), "width": logical( width ), "height": logical( height ) } );
}
let zone = if spec.exclusive_zone > 0 { ( spec.exclusive_zone as f32 / scale ).round() as i32 } else { spec.exclusive_zone };
let a = spec.anchor;
json!( {
"kind": "layer",
"layer": layer_name( spec.layer ),
"anchor": { "top": a.top, "bottom": a.bottom, "left": a.left, "right": a.right },
"width": logical( width ),
"height": logical( height ),
"exclusive_zone": zone,
"keyboard": if spec.keyboard_exclusive { "exclusive" } else { "on_demand" },
} )
}
pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
{
if data.platform.desktop.is_none() { return; }
let specs: Vec<_> = data.app.overlays().into_iter().filter( |s| s.anchor_widget_id.is_none() ).collect();
let wanted: HashSet<OverlayId> = specs.iter().map( |s| s.id ).collect();
let removed: Vec<OverlayId> = data.overlays.keys().filter( |id| !wanted.contains( id ) ).copied().collect();
for id in removed
{
let Some( ss ) = data.remove_overlay( id ) else { continue };
if let Some( desktop ) = data.platform.desktop.as_ref()
{
let _ = desktop.request( &json!( { "type": "destroy_surface", "surface": ss.platform.id } ) );
}
}
let scale = data.main.scale_factor;
let sizes: Vec<( u32, u32 )> = specs.iter().map( |s| data.overlay_size( &s.size ) ).collect();
let gpu = data.platform.offscreen.is_some();
let Some( desktop ) = data.platform.desktop.as_ref() else { return };
for ( spec, &size ) in specs.iter().zip( &sizes )
{
let role = overlay_role( &data.app, spec, size, scale );
if let Some( ss ) = data.overlays.get_mut( &spec.id )
{
if ss.platform.role.as_ref() == Some( &role ) { continue; }
let request = json!( { "type": "update_role", "surface": ss.platform.id, "role": role } );
if let Err( e ) = desktop.request( &request ) { web_sys::console::warn_1( &format!( "[ltk] overlay {:?}: {e}", spec.id ).into() ); }
ss.platform.role = Some( role );
continue;
}
let id = data.platform.surface_id();
let ( canvas, input ) = match desktop.create_surface( id, role.clone() )
{
Ok( elements ) => elements,
Err( e ) =>
{
web_sys::console::warn_1( &format!( "[ltk] overlay {:?}: {e}", spec.id ).into() );
continue;
}
};
let listeners = match super::input::listen( &input, false, SurfaceFocus::Overlay( spec.id ) )
{
Ok( listeners ) => listeners,
Err( e ) =>
{
web_sys::console::warn_1( &format!( "[ltk] overlay {:?}: {e}", spec.id ).into() );
Vec::new()
}
};
let mut platform = WebSurfaceState::new( canvas, id, gpu, listeners );
platform.role = Some( role );
let mut ss = SurfaceState::new( platform, 0.0, String::new() );
// The main surface's scale until forge/web configures this one, so the first frame lays out at the right size.
ss.scale_factor = scale;
ss.layer_anchor = Some( spec.anchor );
data.overlays.insert( spec.id, ss );
}
}
impl<A: App> AppData<A>
{
fn overlay_by_surface( &self, surface: u32 ) -> Option<OverlayId>
{
self.overlays.iter().find( |( _, ss )| ss.platform.id == surface ).map( |( id, _ )| *id )
}
/// The size and scale forge/web gave an overlay's surface.
pub( super ) fn configure_overlay( &mut self, surface: u32, width: f64, height: f64, scale: f64 )
{
let Some( id ) = self.overlay_by_surface( surface ) else { return };
let Some( ss ) = self.overlays.get_mut( &id ) else { return };
let scale = scale as f32;
if scale != ss.scale_factor
{
ss.scale_factor = scale;
if let Some( ref mut canvas ) = ss.canvas { canvas.set_dpi_scale( scale ); }
}
ss.set_size( width.round().max( 1.0 ) as u32, height.round().max( 1.0 ) as u32 );
self.overlays_dirty = true;
}
/// forge/web asked to close an overlay's surface: the app hears it as the overlay's dismissal.
pub( super ) fn dismiss_overlay( &mut self, surface: u32 )
{
let Some( id ) = self.overlay_by_surface( surface ) else { return };
if let Some( msg ) = self.overlay_dismiss_msg( id ) { self.pending_msgs.push( msg ); }
}
}
+54 -16
View File
@@ -1,10 +1,11 @@
// SPDX-License-Identifier: LGPL-2.1-only // SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // 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. //! Putting a painted canvas on the page. On the GPU every surface is painted through the app's one WebGL context, on its `OffscreenCanvas`: the surface's FBO is blitted onto it, taken as an `ImageBitmap` and handed to the surface's own canvas, a `bitmaprenderer`, without a copy. In software the frame goes through `putImageData`. The browser may take the WebGL context away, and give it back, at any time.
use std::sync::Arc; use std::sync::Arc;
use serde_json::json;
use wasm_bindgen::Clamped; use wasm_bindgen::Clamped;
use web_sys::ImageData; use web_sys::ImageData;
@@ -14,13 +15,14 @@ use crate::event_loop::AppData;
use crate::gles_render::GlesVersion; use crate::gles_render::GlesVersion;
use crate::platform::Presentation; use crate::platform::Presentation;
use crate::render::Canvas; use crate::render::Canvas;
use crate::types::Rect;
use super::{ WebPlatform, WebSurfaceState }; use super::{ WebPlatform, WebSurfaceState };
pub( super ) fn prepare_canvas<A: App> pub( super ) fn prepare_canvas<A: App>
( (
platform: &WebPlatform<A>, platform: &WebPlatform<A>,
_surface: &mut WebSurfaceState, surface: &mut WebSurfaceState,
canvas: &mut Option<Canvas>, canvas: &mut Option<Canvas>,
pw: u32, pw: u32,
ph: u32, ph: u32,
@@ -28,13 +30,19 @@ pub( super ) fn prepare_canvas<A: App>
) -> bool ) -> bool
{ {
if platform.context_lost { return false; } if platform.context_lost { return false; }
if platform.canvas.width() != pw { platform.canvas.set_width( pw ); } if surface.canvas.width() != pw { surface.canvas.set_width( pw ); }
if platform.canvas.height() != ph { platform.canvas.set_height( ph ); } if surface.canvas.height() != ph { surface.canvas.set_height( ph ); }
match platform.gl.as_ref() match platform.gl.as_ref().zip( platform.offscreen.as_ref() )
{ {
Some( gl ) => fit_canvas( canvas, pw, ph, scale, || Canvas::new_gles( Arc::clone( gl ), GlesVersion::V3, pw, ph ) ), Some( ( gl, offscreen ) ) =>
None => fit_canvas( canvas, pw, ph, scale, || Canvas::new( pw, ph ) ), {
}; // The blit and the bitmap take the offscreen canvas's size, which the last surface drawn may have changed.
if offscreen.width() != pw { offscreen.set_width( pw ); }
if offscreen.height() != ph { offscreen.set_height( ph ); }
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 true
} }
@@ -46,21 +54,45 @@ pub( super ) fn present<A: App>
frame: &Presentation<'_>, frame: &Presentation<'_>,
) -> bool ) -> bool
{ {
let shown = match platform.ctx2d.as_ref() let shown = match ( platform.offscreen.as_ref(), surface.bitmap.as_ref(), surface.ctx2d.as_ref() )
{ {
None => ( Some( offscreen ), Some( bitmap ), _ ) =>
{ {
canvas.present(); canvas.present();
match offscreen.transfer_to_image_bitmap()
{
Ok( image ) =>
{
bitmap.transfer_from_image_bitmap( &image );
true true
} }
Some( ctx ) => present_software( ctx, surface, canvas ), Err( _ ) => false,
}
}
( _, _, Some( _ ) ) => present_software( surface, canvas ),
_ => false,
}; };
if shown && frame.frame.is_some() { platform.frame_due.set( frame.frame ); } if let Some( desktop ) = platform.desktop.as_ref() { sync_input_region( desktop, surface, frame ); }
if let ( true, Some( focus ) ) = ( shown, frame.frame ) { platform.owe_frame( focus ); }
shown shown
} }
fn present_software( ctx: &web_sys::CanvasRenderingContext2d, surface: &mut WebSurfaceState, canvas: &Canvas ) -> bool /// Tell forge/web the surface's input region when it changed: `frame`'s, which is in physical pixels, in logical ones.
fn sync_input_region( desktop: &super::desktop::Connection, surface: &mut WebSurfaceState, frame: &Presentation<'_> )
{ {
let s = frame.scale.max( f32::EPSILON ) as f64;
let rects = frame.input_region.map( |rects| rects.iter()
.map( |r: &Rect| [ r.x as f64 / s, r.y as f64 / s, r.width as f64 / s, r.height as f64 / s ] )
.collect::<Vec<_>>() );
if surface.input_region.as_ref() == Some( &rects ) { return; }
let request = json!( { "type": "set_input_region", "surface": surface.id, "rects": rects } );
if let Err( e ) = desktop.request( &request ) { web_sys::console::warn_1( &format!( "[ltk] set_input_region: {e}" ).into() ); }
surface.input_region = Some( rects );
}
pub( super ) fn present_software( surface: &mut WebSurfaceState, canvas: &Canvas ) -> bool
{
let Some( ctx ) = surface.ctx2d.as_ref() else { return false };
let ( pw, ph ) = canvas.size(); let ( pw, ph ) = canvas.size();
surface.pixels.resize( pw as usize * ph as usize * 4, 0 ); surface.pixels.resize( pw as usize * ph as usize * 4, 0 );
canvas.write_to_wayland_buf( &mut surface.pixels, false ); canvas.write_to_wayland_buf( &mut surface.pixels, false );
@@ -75,9 +107,14 @@ impl<A: App> AppData<A>
pub( super ) fn webgl_context_lost( &mut self ) pub( super ) fn webgl_context_lost( &mut self )
{ {
self.platform.context_lost = true; self.platform.context_lost = true;
self.main.canvas = None; for ss in std::iter::once( &mut self.main ).chain( self.overlays.values_mut() )
self.main.frame.scroll_canvases.clear(); {
self.main.frame.viewport_canvases.clear(); ss.canvas = None;
ss.frame.scroll_canvases.clear();
ss.frame.viewport_canvases.clear();
}
self.platform.subsurface_canvas = None;
for slot in self.platform.subsurfaces.values_mut() { slot.forget_frame(); }
} }
/// Draw again on a fresh `glow::Context`, so the glyph atlas and the caches the old one kept are not found for it. /// Draw again on a fresh `glow::Context`, so the glyph atlas and the caches the old one kept are not found for it.
@@ -87,6 +124,7 @@ impl<A: App> AppData<A>
self.platform.gl = Some( Arc::new( glow::Context::from_webgl2_context( webgl ) ) ); self.platform.gl = Some( Arc::new( glow::Context::from_webgl2_context( webgl ) ) );
self.platform.context_lost = false; self.platform.context_lost = false;
self.main.request_redraw(); self.main.request_redraw();
for ss in self.overlays.values_mut() { ss.request_redraw(); }
} }
} }
+92 -41
View File
@@ -10,13 +10,15 @@ use std::collections::VecDeque;
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration; use std::time::Duration;
use serde_json::json;
use wasm_bindgen::closure::Closure; use wasm_bindgen::closure::Closure;
use wasm_bindgen::{ JsCast, JsValue }; use wasm_bindgen::{ JsCast, JsValue };
use web_sys::{ CanvasRenderingContext2d, Document, Event, EventTarget, HtmlCanvasElement, WebGl2RenderingContext }; use web_sys::{ Document, Event, EventTarget, HtmlCanvasElement, HtmlElement, OffscreenCanvas, WebGl2RenderingContext };
use crate::app::App; use crate::app::App;
use crate::event_loop::{ AppData, RunError, Sources, SurfaceState }; use crate::event_loop::{ AppData, RunError, Sources, SurfaceFocus, SurfaceState };
use super::desktop::{ main_role, Connection, MAIN };
use super::input::WebInput; use super::input::WebInput;
use super::{ WebPlatform, WebSurfaceState }; use super::{ WebPlatform, WebSurfaceState };
@@ -27,9 +29,22 @@ pub( super ) enum PageEvent
Input( WebInput ), Input( WebInput ),
ContextLost, ContextLost,
ContextRestored, ContextRestored,
/// forge/web's size and scale for a surface.
Configure
{
surface: u32,
width: f64,
height: f64,
scale: f64,
},
/// forge/web asks to close a surface.
Close
{
surface: u32,
},
} }
/// An event listener, registered while it lives in `WebPlatform::listeners`. /// An event listener, registered for as long as it lives.
pub( crate ) struct Listener pub( crate ) struct Listener
{ {
target: EventTarget, target: EventTarget,
@@ -45,8 +60,11 @@ impl Listener
target.add_event_listener_with_callback( kind, closure.as_ref().unchecked_ref() ).map_err( |_| RunError::NoDocument )?; target.add_event_listener_with_callback( kind, closure.as_ref().unchecked_ref() ).map_err( |_| RunError::NoDocument )?;
Ok( Self { target: target.clone(), kind, closure } ) Ok( Self { target: target.clone(), kind, closure } )
} }
}
pub( super ) fn remove( &self ) impl Drop for Listener
{
fn drop( &mut self )
{ {
let _ = self.target.remove_event_listener_with_callback( self.kind, self.closure.as_ref().unchecked_ref() ); let _ = self.target.remove_event_listener_with_callback( self.kind, self.closure.as_ref().unchecked_ref() );
} }
@@ -98,43 +116,60 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
super::log::install_panic_hook(); super::log::install_panic_hook();
let window = web_sys::window().ok_or( RunError::NoDocument )?; let window = web_sys::window().ok_or( RunError::NoDocument )?;
let document = window.document().ok_or( RunError::NoDocument )?; let document = window.document().ok_or( RunError::NoDocument )?;
let ( canvas, own ) = match CANVAS.with( |c| c.borrow_mut().take() )
let offscreen = webgl2();
if offscreen.is_none() { web_sys::console::warn_1( &"[ltk] no WebGL2 context: drawing in software".into() ); }
crate::render::set_software_render( offscreen.is_none() );
// A canvas the page named wins; else the page's desktop gives the main surface, and without one ltk lays a canvas over the page.
let given = CANVAS.with( |c| c.borrow_mut().take() );
let desktop = if given.is_none() { Connection::open( app.app_id() ) } else { None };
let ( canvas, target, own ) = match ( desktop.as_ref(), given )
{ {
Some( canvas ) => ( canvas, false ), ( Some( desktop ), _ ) =>
None => ( page_canvas( &document )?, true ), {
let ( canvas, input ) = desktop.create_surface( MAIN, main_role( &app ) ).map_err( RunError::Desktop )?;
( canvas, input, false )
}
( None, Some( canvas ) ) => ( canvas.clone(), HtmlElement::from( canvas ), false ),
( None, None ) =>
{
let canvas = page_canvas( &document )?;
( canvas.clone(), HtmlElement::from( canvas ), true )
}
}; };
canvas.set_tab_index( 0 ); if desktop.is_none()
let style = canvas.style(); {
target.set_tab_index( 0 );
let style = target.style();
let _ = style.set_property( "touch-action", "none" ); let _ = style.set_property( "touch-action", "none" );
let _ = style.set_property( "outline", "none" ); let _ = style.set_property( "outline", "none" );
}
let webgl = webgl2( &canvas ); let input = super::input::listen( &target, desktop.is_none(), SurfaceFocus::Main )?;
let ctx2d = if webgl.is_none() { context_2d( &canvas ) } else { None }; let mut listeners = Vec::new();
if webgl.is_none() { web_sys::console::warn_1( &"[ltk] no WebGL2 context: drawing in software".into() ); } if desktop.is_none() { listeners.push( Listener::new( &window, "resize", |_| push( PageEvent::Resized ) )? ); }
crate::render::set_software_render( webgl.is_none() ); if let Some( ( offscreen, _ ) ) = offscreen.as_ref()
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. // Without `preventDefault` the browser never gives the context back.
listeners.push( Listener::new( &canvas, "webglcontextlost", |event| listeners.push( Listener::new( offscreen, "webglcontextlost", |event|
{ {
event.prevent_default(); event.prevent_default();
push( PageEvent::ContextLost ); push( PageEvent::ContextLost );
} )? ); } )? );
listeners.push( Listener::new( &canvas, "webglcontextrestored", |_| push( PageEvent::ContextRestored ) )? ); listeners.push( Listener::new( offscreen, "webglcontextrestored", |_| push( PageEvent::ContextRestored ) )? );
} }
let inset_probe = if own { Some( inset_probe( &document )? ) } else { None }; let inset_probe = if own { Some( inset_probe( &document )? ) } else { None };
if own { let _ = canvas.focus(); } if own { let _ = target.focus(); }
let coarse = window.match_media( "(pointer: coarse)" ).ok().flatten().is_some_and( |m| m.matches() ); 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::new( canvas, MAIN, offscreen.is_some(), input ), 0.0, String::new() );
let main = SurfaceState::new( WebSurfaceState::default(), 0.0, String::new() ); let platform = WebPlatform::new( desktop, offscreen, target, listeners, inset_probe );
// ltk's own keyboard where the page is used by touch, since the page cannot raise the system's for a canvas. // 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 ); let mut data = AppData::new( app, platform, main, Sources::new( wake ), coarse, None );
data.install_sources()?; data.install_sources()?;
data.fit_canvas_element(); // In a desktop the size comes with forge/web's first configure.
if data.platform.desktop.is_none() { data.fit_canvas_element(); }
let mut data = Some( data ); let mut data = Some( data );
let iterate: Box<dyn FnMut( bool ) -> Next> = Box::new( move |in_frame| let iterate: Box<dyn FnMut( bool ) -> Next> = Box::new( move |in_frame|
@@ -247,16 +282,30 @@ impl<A: App> AppData<A>
PageEvent::Input( input ) => self.web_input( input ), PageEvent::Input( input ) => self.web_input( input ),
PageEvent::ContextLost => self.webgl_context_lost(), PageEvent::ContextLost => self.webgl_context_lost(),
PageEvent::ContextRestored => self.webgl_context_restored(), PageEvent::ContextRestored => self.webgl_context_restored(),
PageEvent::Configure { surface: MAIN, width, height, scale } => self.configure_main( width, height, scale ),
PageEvent::Configure { surface, width, height, scale } => self.configure_overlay( surface, width, height, scale ),
PageEvent::Close { surface: MAIN } =>
{
if self.app.on_close_requested() { self.exit_requested = true; }
}
PageEvent::Close { surface } => self.dismiss_overlay( surface ),
} }
} }
self.dispatch_sources(); self.dispatch_sources();
if in_frame if in_frame
{ {
if let Some( focus ) = self.platform.frame_due.take() { self.frame_presented( focus, false ); } for focus in self.platform.frame_due.take()
{
let hosts = super::subsurfaces::hosts_subsurfaces( self, focus );
self.frame_presented( focus, hosts );
}
} }
self.before_draw(); self.before_draw();
super::overlays::reconcile_overlays( self );
let drawn = in_frame && self.draw_due(); let drawn = in_frame && self.draw_due();
super::subsurfaces::reconcile_subsurfaces( self );
super::subsurfaces::keep_cadence( self );
self.after_draw( drawn ); self.after_draw( drawn );
if self.exit_requested if self.exit_requested
@@ -264,8 +313,8 @@ impl<A: App> AppData<A>
self.leave_page(); self.leave_page();
return Next { frame: false, after: None, exit: true }; return Next { frame: false, after: None, exit: true };
} }
let main = &self.main; let due = |ss: &SurfaceState<A::Message, WebSurfaceState>| ss.configured && ss.needs_redraw && !ss.frame_pending;
let frame = ( main.configured && main.needs_redraw && !main.frame_pending ) || self.platform.frame_due.get().is_some(); let frame = due( &self.main ) || self.overlays.values().any( due ) || !self.platform.frame_due.borrow().is_empty();
let now = crate::time::Instant::now(); let now = crate::time::Instant::now();
let after = [ self.next_wakeup(), self.sources.next_due().map( |due| due.saturating_duration_since( now ) ) ] let after = [ self.next_wakeup(), self.sources.next_due().map( |due| due.saturating_duration_since( now ) ) ]
.into_iter() .into_iter()
@@ -289,17 +338,22 @@ impl<A: App> AppData<A>
self.sources.restore_channels( drains ); 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. /// Stop listening to the page, give the desktop its surfaces back, and tell the page, with an `ltk-exit` event on the canvas, that the app has ended.
fn leave_page( &mut self ) fn leave_page( &mut self )
{ {
for listener in &self.platform.listeners self.platform.listeners.clear();
{ self.main.platform.listeners.clear();
listener.remove(); self.overlays.clear();
} self.platform.subsurfaces.clear();
if let Some( probe ) = self.platform.inset_probe.take() { probe.remove(); } if let Some( probe ) = self.platform.inset_probe.take() { probe.remove(); }
if let Some( desktop ) = self.platform.desktop.as_ref()
{
let _ = desktop.request( &json!( { "type": "destroy_surface", "surface": MAIN } ) );
desktop.close();
}
if let Ok( event ) = Event::new( "ltk-exit" ) if let Ok( event ) = Event::new( "ltk-exit" )
{ {
let _ = self.platform.canvas.dispatch_event( &event ); let _ = self.main.platform.canvas.dispatch_event( &event );
} }
} }
} }
@@ -325,18 +379,15 @@ fn inset_probe( document: &Document ) -> Result<web_sys::Element, RunError>
Ok( probe ) 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. /// The app's one WebGL2 context, on a canvas of its own off the page, which every surface is painted through: premultiplied alpha as ltk writes it, and no depth, stencil or multisampling, which it does not use.
fn webgl2( canvas: &HtmlCanvasElement ) -> Option<WebGl2RenderingContext> fn webgl2() -> Option<( OffscreenCanvas, WebGl2RenderingContext )>
{ {
let offscreen = OffscreenCanvas::new( 1, 1 ).ok()?;
let options = js_sys::Object::new(); let options = js_sys::Object::new();
for ( key, value ) in [ ( "alpha", true ), ( "premultipliedAlpha", true ), ( "antialias", false ), ( "depth", false ), ( "stencil", false ), ( "preserveDrawingBuffer", false ) ] for ( key, value ) in [ ( "alpha", true ), ( "premultipliedAlpha", true ), ( "antialias", false ), ( "depth", false ), ( "stencil", false ) ]
{ {
js_sys::Reflect::set( &options, &JsValue::from_str( key ), &JsValue::from_bool( value ) ).ok()?; 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() let context = offscreen.get_context_with_context_options( "webgl2", &options ).ok()??.dyn_into().ok()?;
} Some( ( offscreen, context ) )
fn context_2d( canvas: &HtmlCanvasElement ) -> Option<CanvasRenderingContext2d>
{
canvas.get_context( "2d" ).ok()??.dyn_into().ok()
} }
+46 -14
View File
@@ -1,56 +1,88 @@
// SPDX-License-Identifier: LGPL-2.1-only // SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::cell::Cell; use std::cell::{ Cell, RefCell };
use std::collections::HashMap;
use std::marker::PhantomData; use std::marker::PhantomData;
use std::sync::Arc; use std::sync::Arc;
use web_sys::{ CanvasRenderingContext2d, Element, HtmlCanvasElement, WebGl2RenderingContext }; use web_sys::{ Element, HtmlElement, OffscreenCanvas, WebGl2RenderingContext };
use crate::app::App; use crate::app::{ App, SubsurfaceId };
use crate::event_loop::SurfaceFocus; use crate::event_loop::SurfaceFocus;
use crate::render::Canvas;
use super::desktop::Connection;
use super::run::Listener; use super::run::Listener;
use super::subsurfaces::WebSubsurface;
/// What the runtime's requests go through in a browser: the canvas, its context and what the loop must report back to the runtime. /// What the runtime's requests go through in a browser: the page's desktop when it has one, the one WebGL context every surface draws through, and what the loop must report back to the runtime.
pub( crate ) struct WebPlatform<A: App> pub( crate ) struct WebPlatform<A: App>
{ {
pub canvas: HtmlCanvasElement, /// forge/web, when the page has it; the app then has no canvas of its own over the page.
/// The canvas's WebGL2 context; `None` when the browser has none, and the canvas is drawn through `ctx2d`. pub desktop: Option<Connection>,
/// Where every surface is painted on the GPU before its frame moves to the surface's own canvas, so the app holds one WebGL context however many surfaces it has. `None` without WebGL2: each surface is then drawn in software.
pub offscreen: Option<OffscreenCanvas>,
pub webgl: Option<WebGl2RenderingContext>, pub webgl: Option<WebGl2RenderingContext>,
/// `webgl` as the GPU canvas draws through it, wrapped anew when the context is restored. /// `webgl` as the GPU canvas draws through it, wrapped anew when the context is restored.
pub gl: Option<Arc<glow::Context>>, 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. /// The browser took the WebGL context away; nothing is drawn until it gives it back.
pub context_lost: bool, pub context_lost: bool,
/// Where the cursor's shape is set: the element under the pointer that takes the main surface's input.
pub cursor_target: HtmlElement,
/// 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. /// 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>, pub inset_probe: Option<Element>,
/// A frame was presented or asked for; the loop reports it at the next animation frame. /// The surfaces a frame was presented or asked for; the loop reports them at the next animation frame.
pub frame_due: Cell<Option<SurfaceFocus>>, pub frame_due: RefCell<Vec<SurfaceFocus>>,
/// The id the next overlay's or subsurface's surface goes by in forge/web's protocol.
pub next_surface: Cell<u32>,
/// The subsurfaces of [`App::subsurfaces`], each a surface of the desktop.
pub subsurfaces: HashMap<SubsurfaceId, WebSubsurface>,
/// The GPU canvas every subsurface is painted on before its frame moves to its own canvas, which keeps it.
pub subsurface_canvas: Option<Canvas>,
/// Text input was asked for and not ended; ltk's own keyboard serves it. /// Text input was asked for and not ended; ltk's own keyboard serves it.
pub text_input: bool, pub text_input: bool,
/// The canvas's input and the window's `resize`, removed when the app exits. /// The input of every surface, the window's `resize` and the context's loss, removed when the app exits.
pub listeners: Vec<Listener>, pub listeners: Vec<Listener>,
_app: PhantomData<fn() -> A>, _app: PhantomData<fn() -> A>,
} }
impl<A: App> WebPlatform<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 pub( crate ) fn new( desktop: Option<Connection>, offscreen: Option<( OffscreenCanvas, WebGl2RenderingContext )>, cursor_target: HtmlElement, listeners: Vec<Listener>, inset_probe: Option<Element> ) -> Self
{ {
let ( offscreen, webgl ) = offscreen.unzip();
let gl = webgl.clone().map( |context| Arc::new( glow::Context::from_webgl2_context( context ) ) ); let gl = webgl.clone().map( |context| Arc::new( glow::Context::from_webgl2_context( context ) ) );
Self Self
{ {
canvas, desktop,
offscreen,
webgl, webgl,
gl, gl,
ctx2d,
context_lost: false, context_lost: false,
cursor_target,
inset_probe, inset_probe,
frame_due: Cell::new( None ), frame_due: RefCell::new( Vec::new() ),
next_surface: Cell::new( super::desktop::MAIN + 1 ),
subsurfaces: HashMap::new(),
subsurface_canvas: None,
text_input: false, text_input: false,
listeners, listeners,
_app: PhantomData, _app: PhantomData,
} }
} }
/// Report a frame of `focus` at the next animation frame.
pub( super ) fn owe_frame( &self, focus: SurfaceFocus )
{
let mut due = self.frame_due.borrow_mut();
if !due.contains( &focus ) { due.push( focus ); }
}
pub( super ) fn surface_id( &self ) -> u32
{
let id = self.next_surface.get();
self.next_surface.set( id + 1 );
id
}
} }
+214
View File
@@ -0,0 +1,214 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The app's subsurfaces (`App::subsurfaces`) as surfaces of the page's desktop, as the Wayland backend's `subsurface.rs` has them: each sized to its parent, taking no input, painted only when it appears, its size changes or its `content_version` does, and otherwise only moved, which the browser composes without anyone painting again. Every one is painted on one shared GPU canvas, since its frame then stays on its own canvas.
use std::collections::{ HashMap, HashSet };
use std::sync::Arc;
use serde_json::json;
use crate::app::{ App, SubsurfaceId, SubsurfaceParent, SubsurfaceSpec };
use crate::draw::{ fit_canvas, paint_detached };
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::gles_render::GlesVersion;
use crate::render::Canvas;
use super::desktop::{ Connection, MAIN };
use super::WebSurfaceState;
pub( crate ) struct WebSubsurface
{
surface: WebSurfaceState,
/// Its software canvas, without WebGL2.
canvas: Option<Canvas>,
parent: SubsurfaceParent,
/// The parent's surface id when this one was made: forge/web destroys a subsurface with its parent, so a parent made again needs it made again.
parent_id: u32,
last_pos: ( i32, i32 ),
last_size: ( u32, u32 ),
last_version: u64,
painted: bool,
scroll_canvases: HashMap<usize, Canvas>,
viewport_canvases: HashMap<usize, Canvas>,
}
impl WebSubsurface
{
/// Its frame went with the WebGL context: paint it again on the next.
pub( super ) fn forget_frame( &mut self )
{
self.painted = false;
self.scroll_canvases.clear();
self.viewport_canvases.clear();
}
}
/// What a subsurface needs of its parent: its surface id, its logical and physical size, and its scale.
struct Parent
{
id: u32,
logical: ( u32, u32 ),
physical: ( u32, u32 ),
scale: f32,
}
fn parent_of<A: App>( data: &AppData<A>, parent: SubsurfaceParent ) -> Option<Parent>
{
let ( ss, id ) = match parent
{
SubsurfaceParent::Main => ( &data.main, MAIN ),
SubsurfaceParent::Overlay( id ) =>
{
let ss = data.overlays.get( &id )?;
( ss, ss.platform.id )
}
};
if !ss.configured || ss.width == 0 || ss.height == 0 { return None; }
Some( Parent { id, logical: ( ss.width, ss.height ), physical: ( ss.physical_width(), ss.physical_height() ), scale: ss.scale_factor } )
}
fn role( spec_x: i32, spec_y: i32, parent: &Parent ) -> serde_json::Value
{
let s = parent.scale.max( f32::EPSILON ) as f64;
json!( {
"kind": "subsurface",
"parent": parent.id,
"x": spec_x as f64 / s,
"y": spec_y as f64 / s,
"width": parent.logical.0,
"height": parent.logical.1,
} )
}
fn warn( id: SubsurfaceId, e: &str )
{
web_sys::console::warn_1( &format!( "[ltk] subsurface {id:?}: {e}" ).into() );
}
/// Whether `focus` has subsurfaces on it, which decides what its presented frame asks next (`AppData::frame_presented`).
pub( super ) fn hosts_subsurfaces<A: App>( data: &AppData<A>, focus: SurfaceFocus ) -> bool
{
data.platform.subsurfaces.values().any( |s| match ( s.parent, focus )
{
( SubsurfaceParent::Main, SurfaceFocus::Main ) => true,
( SubsurfaceParent::Overlay( a ), SurfaceFocus::Overlay( b ) ) => a == b,
_ => false,
} )
}
pub( super ) fn reconcile_subsurfaces<A: App>( data: &mut AppData<A> )
{
if data.platform.desktop.is_none() || !data.main.configured || data.platform.context_lost { return; }
let specs: Vec<SubsurfaceSpec<A::Message>> = data.app.subsurfaces();
let parents: Vec<Option<Parent>> = specs.iter().map( |s| parent_of( data, s.parent ) ).collect();
let present: HashSet<SubsurfaceId> = specs.iter().map( |s| s.id ).collect();
let platform = &mut data.platform;
let Some( desktop ) = platform.desktop.as_ref() else { return };
platform.subsurfaces.retain( |id, slot|
{
if present.contains( id ) { return true; }
let _ = desktop.request( &json!( { "type": "destroy_surface", "surface": slot.surface.id } ) );
false
} );
let gpu = platform.gl.clone().zip( platform.offscreen.clone() );
let mut painted = Vec::new();
for ( spec, parent ) in specs.iter().zip( &parents )
{
let Some( parent ) = parent else { continue };
if platform.subsurfaces.get( &spec.id ).is_some_and( |s| s.parent_id != parent.id ) { platform.subsurfaces.remove( &spec.id ); }
if !platform.subsurfaces.contains_key( &spec.id )
{
let id = platform.next_surface.get();
platform.next_surface.set( id + 1 );
let Some( slot ) = create( desktop, id, spec, parent, gpu.is_some() ) else { continue };
platform.subsurfaces.insert( spec.id, slot );
}
let slot = platform.subsurfaces.get_mut( &spec.id ).unwrap();
let resized = slot.last_size != parent.physical;
if !slot.painted || resized || slot.last_version != spec.content_version
{
if paint( slot, &spec.view, parent, gpu.as_ref(), &mut platform.subsurface_canvas )
{
slot.painted = true;
slot.last_version = spec.content_version;
painted.push( spec.id );
}
}
if resized || slot.last_pos != ( spec.x, spec.y )
{
let request = json!( { "type": "update_role", "surface": slot.surface.id, "role": role( spec.x, spec.y, parent ) } );
if let Err( e ) = desktop.request( &request ) { warn( spec.id, &e ); }
slot.last_pos = ( spec.x, spec.y );
slot.last_size = parent.physical;
}
}
for id in painted { data.app.on_subsurface_committed( id ); }
}
fn create<Msg: Clone>( desktop: &Connection, id: u32, spec: &SubsurfaceSpec<Msg>, parent: &Parent, gpu: bool ) -> Option<WebSubsurface>
{
let ( canvas, _input ) = desktop.create_surface( id, role( spec.x, spec.y, parent ) ).map_err( |e| warn( spec.id, &e ) ).ok()?;
// Input falls through to the parent, which keeps one gesture model for both.
if let Err( e ) = desktop.request( &json!( { "type": "set_input_region", "surface": id, "rects": [] } ) ) { warn( spec.id, &e ); }
Some( WebSubsurface
{
surface: WebSurfaceState::new( canvas, id, gpu, Vec::new() ),
canvas: None,
parent: spec.parent,
parent_id: parent.id,
last_pos: ( spec.x, spec.y ),
last_size: parent.physical,
last_version: u64::MAX,
painted: false,
scroll_canvases: HashMap::new(),
viewport_canvases: HashMap::new(),
} )
}
/// Paint `view` at the parent's physical size and put the frame on the subsurface's canvas.
fn paint<Msg: Clone>( slot: &mut WebSubsurface, view: &crate::widget::Element<Msg>, parent: &Parent, gpu: Option<&( Arc<glow::Context>, web_sys::OffscreenCanvas )>, shared: &mut Option<Canvas> ) -> bool
{
let ( pw, ph ) = parent.physical;
if slot.surface.canvas.width() != pw { slot.surface.canvas.set_width( pw ); }
if slot.surface.canvas.height() != ph { slot.surface.canvas.set_height( ph ); }
match gpu
{
Some( ( gl, offscreen ) ) =>
{
if offscreen.width() != pw { offscreen.set_width( pw ); }
if offscreen.height() != ph { offscreen.set_height( ph ); }
let canvas = fit_canvas( shared, pw, ph, parent.scale, || Canvas::new_gles( Arc::clone( gl ), GlesVersion::V3, pw, ph ) );
paint_detached( canvas, view, &mut slot.scroll_canvases, &mut slot.viewport_canvases );
canvas.present();
let ( Some( bitmap ), Ok( image ) ) = ( slot.surface.bitmap.as_ref(), offscreen.transfer_to_image_bitmap() ) else { return false };
bitmap.transfer_from_image_bitmap( &image );
true
}
None =>
{
let canvas = fit_canvas( &mut slot.canvas, pw, ph, parent.scale, || Canvas::new( pw, ph ) );
paint_detached( canvas, view, &mut slot.scroll_canvases, &mut slot.viewport_canvases );
let Some( canvas ) = slot.canvas.as_ref() else { return false };
super::present::present_software( &mut slot.surface, canvas )
}
}
}
/// A surface whose presented frame came while the app animates only by moving subsurfaces keeps its frames coming without painting, as the Wayland backend keeps a parent's frame callbacks.
pub( super ) fn keep_cadence<A: App>( data: &mut AppData<A> )
{
let animating = data.app.is_animating();
let mut owed = Vec::new();
for ( focus, ss ) in std::iter::once( ( SurfaceFocus::Main, &mut data.main ) )
.chain( data.overlays.iter_mut().map( |( id, ss )| ( SurfaceFocus::Overlay( *id ), ss ) ) )
{
if !std::mem::take( &mut ss.frame_tick_due ) || ss.frame_pending || !animating { continue; }
ss.frame_pending = true;
owed.push( focus );
}
for focus in owed { data.platform.owe_frame( focus ); }
}
+43 -6
View File
@@ -1,18 +1,45 @@
// SPDX-License-Identifier: LGPL-2.1-only // SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use web_sys::Element; use serde_json::Value;
use web_sys::{ CanvasRenderingContext2d, Element, HtmlCanvasElement, ImageBitmapRenderingContext };
use crate::app::App; use crate::app::App;
use crate::event_loop::AppData; use crate::event_loop::AppData;
use crate::event_loop::surface::Insets; use crate::event_loop::surface::Insets;
/// The main surface's browser half. use super::run::Listener;
#[ derive( Default ) ]
/// A surface's browser half: the canvas it is shown on.
pub( crate ) struct WebSurfaceState pub( crate ) struct WebSurfaceState
{ {
pub canvas: HtmlCanvasElement,
/// Takes the frames painted on the shared WebGL context.
pub bitmap: Option<ImageBitmapRenderingContext>,
/// Takes the software renderer's frames, without WebGL2.
pub ctx2d: Option<CanvasRenderingContext2d>,
/// The software renderer's frame, unpremultiplied for `putImageData`. /// The software renderer's frame, unpremultiplied for `putImageData`.
pub pixels: Vec<u8>, pub pixels: Vec<u8>,
/// The surface's id in forge/web's protocol.
pub id: u32,
/// The input region last sent to forge/web, in logical `[ x, y, width, height ]`.
pub input_region: Option<Option<Vec<[ f64; 4 ]>>>,
/// The role last asked of forge/web, for an overlay.
pub role: Option<Value>,
/// The surface's input, removed with it.
pub listeners: Vec<Listener>,
}
impl WebSurfaceState
{
/// The surface shown on `canvas`, through the shared WebGL context when there is one, else in software.
pub( super ) fn new( canvas: HtmlCanvasElement, id: u32, gpu: bool, listeners: Vec<Listener> ) -> Self
{
let context = |kind: &str| canvas.get_context( kind ).ok().flatten();
let bitmap = if gpu { context( "bitmaprenderer" ).and_then( |c| wasm_bindgen::JsCast::dyn_into( c ).ok() ) } else { None };
let ctx2d = if gpu { None } else { context( "2d" ).and_then( |c| wasm_bindgen::JsCast::dyn_into( c ).ok() ) };
Self { canvas, bitmap, ctx2d, pixels: Vec::new(), id, input_region: None, role: None, listeners }
}
} }
/// The safe-area insets `probe` is padded by, in CSS pixels; none without a probe. /// The safe-area insets `probe` is padded by, in CSS pixels; none without a probe.
@@ -28,7 +55,7 @@ fn safe_area( probe: Option<&Element> ) -> Insets
impl<A: App> AppData<A> 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. /// Size the main surface to its 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 ) pub( super ) fn fit_canvas_element( &mut self )
{ {
let insets = safe_area( self.platform.inset_probe.as_ref() ); let insets = safe_area( self.platform.inset_probe.as_ref() );
@@ -38,11 +65,21 @@ impl<A: App> AppData<A>
self.main.request_redraw(); self.main.request_redraw();
} }
let canvas = &self.platform.canvas; let canvas = &self.main.platform.canvas;
let ( w, h ) = ( canvas.client_width().max( 1 ) as u32, canvas.client_height().max( 1 ) as u32 ); 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; 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; } self.size_main( w, h, scale );
}
/// The size and scale forge/web gave the main surface.
pub( super ) fn configure_main( &mut self, width: f64, height: f64, scale: f64 )
{
self.size_main( width.round().max( 1.0 ) as u32, height.round().max( 1.0 ) as u32, scale as f32 );
}
fn size_main( &mut self, w: u32, h: u32, scale: 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 if scale != self.main.scale_factor
{ {
self.main.scale_factor = scale; self.main.scale_factor = scale;