A browser app takes part in the desktop as a Wayland client does: fullscreen, the desktop's windows, activation and the shell protocol through forge/web, its own files through ltk::vfs, and images its caller names
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

On a page with forge/web an ltk app had a main surface, overlays and subsurfaces, but none of what a shell such as crustace needs from the compositor: fullscreen did nothing, the app heard nothing of the other windows, an activation token it asked for never came, forge/web was never told when a launched window had shown, the shell could not speak its protocol, and the app had no way to read its own files in a browser. Each now goes through the app's connection to forge/web, in the shape of the Wayland protocol it stands for.
Fullscreen. On a page with forge/web, `App::fullscreen` and `App::start_fullscreen` send a `set_fullscreen` request for the main window, as `xdg_toplevel.set_fullscreen` does, instead of doing nothing; alone on a page the canvas still takes the browser's fullscreen. `examples/web_hello.rs` gains a button that toggles it.
The desktop's windows. On connecting, the backend asks for the list of toplevels, and forge/web's `toplevel_opened` and `toplevel_closed` events become `ToplevelEvent`s for `App::on_toplevel_event`, as `ext-foreign-toplevel-list-v1` does on Wayland, so a shell's dock knows which applications have windows open.
Activation. The tokens an app asks for with `App::take_activation_requests` are issued before it draws: forge/web's, through its `activation_token` request, or empty without a desktop, as on Wayland without xdg-activation, and handed to `App::on_activation_token` with their messages processed. A browser app that launched others through tokens waited for one that never came. Each surface's first presented frame is reported with a `presented` request (`WebSurfaceState::presented`), so forge/web knows when a window it launched has shown and can report it activated, which dismisses crustace's splash.
The shell protocol. `platform/web/shell.rs` carries the JSON a shell speaks to forge over its socket over the app's connection to forge/web: `ltk::web::shell_send( json )` sends it as a `shell` request, and each `shell` event's message reaches the listener `ltk::web::set_shell_listener` gives, as JSON. What is sent before the app has connected is kept and sent once it has, so a shell can tell forge/web its theme while it starts. An overlay's role now carries its `App::overlay_namespace`, as its layer-shell namespace does on Wayland, so forge/web can tell a shell's overlays apart.
The cursor over every surface. The CSS `cursor` was set on the main surface's element only, so over an overlay the pointer kept the page's arrow. `WebSurfaceState` keeps the element that takes its input, the shape moves to that of the surface the pointer enters, and `WebPlatform::cursor` keeps the last one asked for.
An app's own files. `ltk::vfs`, through which ltk reads its themes, fonts and keyboard layouts, is public: `read`, `read_to_string`, `read_dir`, `is_file` and `is_dir` read the file system natively and, in a browser, the files the page loaded into memory. `ltk::web::load_tree( base, root ).await` loads any tree served with a `MANIFEST` under `root`, and `load_resources` is that into `/ltk` with the directory made the resource directory. `ltk::time::Instant` is public too, `std`'s natively and `web_time`'s on wasm32, where `std::time::Instant::now` panics, so an app that builds for both measures time with ltk's clock.
wasm-bindgen-cli. `scripts/wasm-bindgen-cli.sh TOOLS_DIR` builds wasm-bindgen-cli into `TOOLS_DIR/bin` at the version of the `wasm-bindgen` crate ltk pins in its `Cargo.toml`, from its sources with a lock resolved for Debian's rustc, which is what `make web-deps` did inline and now runs. Since `make install` copies `scripts/` into the registry, a project that builds for the browser against the installed ltk, such as forge/web or crustace, gets the same CLI without a checkout of ltk. The README and docs/architecture.md say so.
Images named by their caller. Every draw through `draw_image_data` fingerprints its pixels to find them in the image caches, which for a terminal means hashing every glyph of every frame. `Canvas::draw_image_keyed( key, rgba, w, h, dest, opacity )` is `draw_image_data` with `key` naming the pixels in place of their fingerprint: every draw with the same key must pass the same pixels, which are read only when the image is not cached. Both backends' caches are keyed by `ImageId`, `Content` for a fingerprint and `Keyed` for a caller's key, so a key never names an image drawn by content even when it equals its fingerprint. `GlesCanvas` and `SoftwareCanvas` gain `draw_image_keyed`, and their `draw_image_data` and it share one private `draw_image`. Tests check that a keyed image draws as its pixels do and that a key equal to a fingerprint is another image.
A held button is not also tapped. A button with `on_long_press` and no `on_drag_start` stayed a tap after its hold fired, so releasing it sent its `on_press` as well. `GestureState::long_press_msg_fired` is set when the hold fires `long_press_msg` and suppresses the press message on release; a test covers it.
`CHANGELOG.md` describes these changes.
This commit is contained in:
2026-10-21 10:21:11 +02:00
parent cd1a233b2e
commit af4009374d
27 changed files with 369 additions and 69 deletions
+11
View File
@@ -6,6 +6,14 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
### Added
- **`Canvas::draw_image_keyed( key, rgba, w, h, dest, opacity )`** — `draw_image_data` with `key` naming the pixels in place of a fingerprint of them, so a caller that draws the same images on every frame, such as a terminal's glyphs, does not hash every one of them each time. Every draw with the same `key` must pass the same pixels, which are read only when the image is not cached; a key never names an image drawn by content.
- **Fullscreen in a browser's desktop** — on a page with forge/web, `App::fullscreen` and `App::start_fullscreen` ask forge/web for the main window to fill the desktop with a `set_fullscreen` request, as Wayland's `xdg_toplevel.set_fullscreen`, where they did nothing; alone on a page the app's canvas still takes the browser's fullscreen.
- **A browser app hears of the desktop's windows** — on a page with forge/web, the web backend asks for the list of toplevels when it connects and turns its `toplevel_opened` and `toplevel_closed` events into `App::on_toplevel_event`, as the Wayland backend does with `ext-foreign-toplevel-list-v1`, so a shell's dock knows which applications have windows open and names each by its `identifier`.
- **A browser app tells the desktop it is shown** — on a page with forge/web, each surface's first presented frame is reported with a `presented` request, as a Wayland client's first commit follows `xdg_activation_v1.activate`, so forge/web knows when a window it launched has shown and reports `app_activated` with its token, which dismisses crustace's splash.
- **An app's own files in a browser: `ltk::vfs` and `ltk::web::load_tree`** — `vfs`, through which ltk reads its themes, fonts and layouts, is public: `read`, `read_to_string`, `read_dir`, `is_file` and `is_dir` are the file system's natively and, in a browser, read the files the page loaded into memory. `load_tree( base, root ).await` loads any tree served with a `MANIFEST`, as ltk's resources are, under `root`, and `load_resources` is now `load_tree` into `/ltk` and that directory made the resource directory. crustace loads its catalog of applications and their icons this way. In a browser the activation tokens an app asks for with `App::take_activation_requests` are now issued: forge/web's, through its `activation_token` request, or empty without a desktop, as on Wayland without xdg-activation, handed to `App::on_activation_token` and their messages processed before the app draws. Before, a browser app that launched others through tokens waited for one that never came.
- **The shell protocol in a browser: `ltk::web::shell_send` and `ltk::web::set_shell_listener`** — a shell such as crustace speaks to forge over a socket in JSON; on a page with forge/web the same JSON travels over the app's connection to it, `shell_send( json )` as a `shell` request and each `shell` event's message to the listener `set_shell_listener` gives, as JSON. What is sent before the app has connected is sent once it has, so a shell can tell forge/web its theme while it starts. An overlay's role now carries its `App::overlay_namespace`, as its layer-shell namespace does on Wayland, so forge/web can tell crustace's launcher and Quick Settings apart.
- **`scripts/wasm-bindgen-cli.sh TOOLS_DIR`** — builds wasm-bindgen-cli into `TOOLS_DIR/bin` at the version of the `wasm-bindgen` crate ltk pins in its `Cargo.toml`, from its sources with a lock resolved for Debian's rustc, which is what `make web-deps` did inline. `make web-deps` runs it, and since `make install` copies `scripts/` into the registry, a project that builds for the browser against the installed ltk, such as forge/web or crustace, gets the same CLI without a checkout of ltk.
- **`ltk::time::Instant`** — ltk's monotonic clock, `std::time::Instant` natively and `web_time::Instant` on wasm32, where `std::time::Instant::now` panics, so an app that also builds for the browser measures time with the same clock as ltk and as `osk::Panel::press_down`, which takes it.
- **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.
- **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.
@@ -79,6 +87,9 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
### Fixed
- **A held button is not also tapped.** A button with `on_long_press` and no `on_drag_start` stayed a tap after its hold fired, so releasing it sent its `on_press` as well; it no longer does.
- **Drawing many small images no longer slows down as the image caches fill.** Every hit in the GLES texture cache, the GLES surface cache and the software canvas's premultiplied cache looked its key up in a linear LRU order, so an app that draws thousands of small images, a terminal's glyphs in each of their colours, walked thousands of entries for each one on every frame. The caches now find, insert and evict an entry in O(log n), with the same budgets and eviction order.
- **The cursor's shape over a browser app's overlays** — the web backend set the CSS `cursor` on the main surface's element only, so over an overlay, such as crustace's dock or a panel, the pointer kept the page's arrow. Each surface now keeps the element that takes its input, the cursor's shape moves to that of the surface the pointer enters, and the last shape asked for is kept for it.
- **A focus request reaches a widget scrolled out of view.** Scroll viewports drop the widgets outside their visible area from the hit-test list, so `App::take_focus_request` for a text input that a tap had just added below the fold (a password field under the selected network of a Wi-Fi list) never found it — and the runtime kept re-requesting a rebuild and a redraw every frame while it waited. Widgets with a `WidgetId` that a scroll clips are now recorded with their content position (`scroll_clipped`); a request whose target is among them moves that viewport with the same rule keyboard navigation uses (`scroll::offset_into_view`) and is satisfied on the next frame. The wait is bounded to a few drawn frames, after which the request is dropped instead of spinning.
- **Fast two-thumb typing no longer drops taps.** A finger landing while another was still down went to the auxiliary slot and never reached the gesture machine, so on a touch keypad every overlapping tap was lost. A finger landing on a primary that is still a plain press (`GestureState::is_plain_press`: no drag, scroll, swipe or slider in flight) now commits that press as a tap at its last position and takes the primary slot; the earlier finger's release reports through `App::on_touch_up`. Presses that have become a drag keep the old behaviour, so a second finger during a pattern stroke or a slider drag still stays out of the gesture machine.
- **`PatternGrid::on_node` fires once per node, not once per motion event.** Every touch motion within a node's snap radius re-emitted the node's message and requested a repaint, so drawing a pattern rebuilt and re-rastered the surface at the touchscreen's motion rate. The gesture machine now remembers the node the finger is over (`last_pattern_node`) and emits only on change; a `Slider` move outcome without a message no longer requests a redraw.