From af4009374d12a50e8c6d2241e562ecfe72df13eb Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Wed, 21 Oct 2026 10:21:11 +0200 Subject: [PATCH] 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 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. --- CHANGELOG.md | 11 +++++ Makefile | 17 ++------ README.md | 2 +- docs/architecture.md | 2 +- examples/web_hello.rs | 11 +++-- scripts/wasm-bindgen-cli.sh | 28 +++++++++++++ src/event_loop/drag.rs | 1 + src/gles_render/image.rs | 27 ++++++++---- src/input/gesture/mod.rs | 12 ++++-- src/input/gesture/tests.rs | 11 +++++ src/lib.rs | 6 +-- src/platform/web/assets.rs | 11 ++++- src/platform/web/desktop.rs | 47 ++++++++++++++++++++- src/platform/web/input.rs | 19 ++++++++- src/platform/web/mod.rs | 16 ++++++-- src/platform/web/overlays.rs | 3 +- src/platform/web/present.rs | 11 ++++- src/platform/web/run.rs | 12 +++++- src/platform/web/shell.rs | 73 +++++++++++++++++++++++++++++++++ src/platform/web/state.rs | 5 ++- src/platform/web/subsurfaces.rs | 4 +- src/platform/web/surface.rs | 10 +++-- src/render/helpers.rs | 8 ++++ src/render/image.rs | 16 ++++++-- src/render/mod.rs | 49 ++++++++++++++++++++++ src/time.rs | 6 +-- src/vfs.rs | 20 ++++----- 27 files changed, 369 insertions(+), 69 deletions(-) create mode 100755 scripts/wasm-bindgen-cli.sh create mode 100644 src/platform/web/shell.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b7f82fa..6bff094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Makefile b/Makefile index 56c5294..96b6d23 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ ANDROID_APK ?= target/android/ltk-hello.apk # The browser build uses Debian's std for wasm32 (libstd-rust-dev-wasm32) and # wasm-bindgen-cli, which Debian does not package and whose version must be -# the one of the wasm-bindgen crate in Cargo.lock. +# the one of the wasm-bindgen crate ltk pins. WEB_TARGET = wasm32-unknown-unknown WEB_CARGO = --target $(WEB_TARGET) --no-default-features --features web WEB_OUT ?= target/web @@ -33,7 +33,6 @@ WEB_EXAMPLES = web_hello web_shell # 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. 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) -WASM_BINDGEN_SRC = target/wasm-bindgen-cli # Where web-deps installs wasm-bindgen-cli, so nothing needs to be on PATH; # one already on PATH is used when there is none here. WEB_TOOLS = target/tools @@ -97,20 +96,10 @@ android-apk: android-assets android-install: android-apk adb install -r $(ANDROID_APK) -# What the web-* targets need: Debian's std for wasm32, and wasm-bindgen-cli -# from crates.io at the version Cargo.lock holds. The lock it was published -# with needs a newer rustc, and `cargo install` ignores rust-version when it -# resolves, so its sources get a lock resolved for the rust-version it -# declares and are installed from there. +# What the web-* targets need: Debian's std for wasm32, and wasm-bindgen-cli at the version ltk pins, which scripts/wasm-bindgen-cli.sh builds. web-deps: sudo apt-get install -y libstd-rust-dev-wasm32 curl - cargo metadata --format-version 1 >/dev/null - version="$$(sed -n '/^name = "wasm-bindgen"$$/{n;s/^version = "\(.*\)"$$/\1/p}' Cargo.lock)" && \ - rm -rf $(WASM_BINDGEN_SRC) && mkdir -p $(WASM_BINDGEN_SRC) && \ - curl -sSfL "https://static.crates.io/crates/wasm-bindgen-cli/wasm-bindgen-cli-$$version.crate" | tar -xz -C $(WASM_BINDGEN_SRC) --strip-components=1 && \ - cd $(WASM_BINDGEN_SRC) && \ - CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS=fallback cargo generate-lockfile && \ - cargo install --path . --locked --root $(CURDIR)/$(WEB_TOOLS) + ./scripts/wasm-bindgen-cli.sh $(WEB_TOOLS) # Typecheck the browser backend. web-check: diff --git a/README.md b/README.md index 63012ae..a81d693 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ to keep. `apksigner`, `android-framework-res`, `librsvg2-bin`, `default-jdk-headless`, `art-standalone` (for `dx`), `google-android-platform-34-installer` (non-free) and `android-sdk-platform-tools`. - `make web-deps` installs what the `web-*` targets need: `libstd-rust-dev-wasm32` and `curl` from Debian, and `wasm-bindgen-cli` at the version of the `wasm-bindgen` crate in `Cargo.lock`, built from its sources into `target/tools`, where the Makefile finds it. + `make web-deps` installs what the `web-*` targets need: `libstd-rust-dev-wasm32` and `curl` from Debian, and `wasm-bindgen-cli` at the version of the `wasm-bindgen` crate ltk pins, which `scripts/wasm-bindgen-cli.sh` builds from its sources into `target/tools`, where the Makefile finds it. The script is installed with the package, so a project that builds against the installed ltk runs it to get the same CLI into a directory of its own. - A usable system font (`fonts-sora`, `fonts-liberation`, `fonts-dejavu`, …). If none is installed `ltk` falls back to an embedded Sora Regular build with a stderr warning. diff --git a/docs/architecture.md b/docs/architecture.md index 4bbca71..899fc9c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -224,7 +224,7 @@ Behind them sit hover and focus, the gesture machine (tap, long-press, drag, scr **The Android backend** is `platform/android/`, on `android-activity` in its NativeActivity mode. The app's `android_main` hands its `AndroidApp` to `ltk::set_android_app` and calls `ltk::run`, which fails with `RunError::NoAndroidApp` otherwise; `ltk::AndroidApp` is re-exported so the app needs no direct dependency. The activity's thread sleeps in `AndroidApp::poll_events` on its `ALooper`, to which calloop's epoll descriptor is added with a callback that wakes it, so the app's message channel and the text-scale watcher still reach the loop; calloop's timers raise no descriptor, so the looper's timeout is bounded by the runtime's deadlines, `App::poll_interval` and a held button's repeat. Lifecycle events are handled inside `poll_events`, since the window must be released before the system destroys it: the window's arrival creates the EGL surface on it, a resize or a configuration change refits the main surface, its end releases it. The main surface's buffer scale is the display's density in multiples of 160 dpi, rounded; its logical size is the window's pixels at that scale, and its buffers are set to exactly that many physical pixels, which the system stretches by less than a pixel to fill the window, so input positions are divided by the window's pixels per logical pixel. EGL swaps with an interval of 1, so `eglSwapBuffers` waits for the display, and the backend reports each presented frame to the runtime at once (`AppData::frame_presented`, which the Wayland backend calls from its frame callbacks), which paces animations at the display's refresh. Without EGL the canvas is copied into a locked `R8G8B8A8` window buffer. Fingers and styluses are touches and a mouse a pointer; keys take their keysym from a table for keys that type nothing (`keys.rs`) or from the character the device's `KeyCharacterMap` gives with Shift and Caps Lock, and repeat on Android's clock. Text is typed on ltk's own keyboard, never the system's: a NativeActivity gets only key events from an input method, which would lose composed text and suggestions. It serves text fields and an app's own text input (`App::external_text_input`), whose keys reach the app as physical ones, and its height is taken from the view as on any platform. The clipboard is the system's, through JNI on the activity (`java.rs`): a copy goes to `ClipboardManager`, and its text is read back whenever the app gains the focus, the only time Android lets an app read it. Back with nothing left to go back to minimises the app, `moveTaskToBack`, or closes it, as `App::back_at_root` says; `Platform::back_leaves` tells the runtime whether the main surface may leave, which on Wayland is the xdg-toplevel and on Android always. An app that asks to exit has its activity closed and taken out of the recent apps (`finishAndRemoveTask`), and `ltk::run` returns once the system has destroyed it. Android freezes an app soon after it leaves the screen; `ltk::keep_running( Some( text ) )` keeps it running with an ongoing notification, through `java/net/liberux/ltk/KeepAlive.java`, a foreground service that `scripts/android-apk.sh` compiles in with `KEEP_ALIVE=1` (javac against the platform's `android.jar`, then `dx`), and `None` stops it. `set_android_app` also sets `HOME` to the app's internal storage, `SSL_CERT_DIR` to Android's CA certificates and `LANG` to the device's language, which preferences, TLS clients, locales and the keyboard's layout read. Keys the runtime has no keysym for, the volume keys among them, go back to the system. Themes and fonts travel in the APK under `assets/ltk/`, listed in `assets/ltk/MANIFEST` since the asset manager cannot list subdirectories; `set_android_app` unpacks the tree into the app's internal storage when the manifest changed since the last start (its first line is a hash of the contents) and hands it to `ltk::set_resource_dir` (`resources.rs`), so the theme is read from files as anywhere else. The resource directory's `themes/` is searched after the user's themes and before `/usr/share/ltk/themes`, and a font the theme names by an absolute path that does not exist is read from its `fonts/` by file name, so the default theme's Debian paths work unchanged. `scripts/android-assets.sh`, or `make android-assets`, builds the tree from installed themes, the fonts they name and the keyboard layouts next to the script. The per-script fallback fonts come from Android's `/system/fonts`; the primary face stays the embedded Sora. `make android-check` typechecks the backend with Debian's rustc, rust-src and NDK installer. An app is a `cdylib` with an `android_main`; `scripts/android-apk.sh` packages it as a NativeActivity APK with no Java code, using Debian's `aapt`, `zipalign`, `apksigner` and `android-framework-res`, with the launcher icon from `ICON` (an SVG goes through `rsvg-convert` to each density), and `examples/android_hello.rs` with `make android-apk` / `make android-install` is the end-to-end check. Standard output and error, where ltk's diagnostics and panics go, are redirected to logcat (`log.rs`). The system bars cover part of the window: from Android 11 the window's insets, read through JNI, become the main surface's `SurfaceState::insets` (the status bar and any cutout on top, elsewhere only bars that take taps, so the view runs under a gesture bar), and before it the activity's content rect does; `draw/paint.rs` keeps the view out of them while the background still fills the whole window. The APK's theme has no action bar, which the content rect would otherwise leave out though it is never shown. Not done yet: overlays, secondary surfaces and subsurfaces are not created, dead keys from a physical keyboard are not composed, and the activity's saved state is not used. -**The browser backend** is `platform/web/`, for `wasm32-unknown-unknown` through wasm-bindgen. A page's main thread must not block, so `ltk::run` sets the app up and returns, and the browser drives the loop (`run.rs`): `AppData` lives in a closure held by a thread-local `Loop`, which a `setTimeout` runs for messages, timers and page events and a `requestAnimationFrame` runs to draw; an iteration drains the page's events, runs the due timers and the channels, reports a frame presented at the previous animation frame through `AppData::frame_presented`, and calls `before_draw`, `draw_due` (only in an animation frame) and `after_draw`, then schedules the next from `next_wakeup` and the next timer. The timers and channels are `event_loop/sources.rs`'s queue rather than calloop's: the backend takes the due timers out, runs them on `AppData` and hands them back, and a timer removed meanwhile is not armed again; `ChannelSender::send` wakes the loop through a waker the backend registers. Event listeners never touch `AppData`: they translate what they receive and queue it, and the iteration hands it to the runtime, so nothing a listener sets off, such as the focus event `canvas.focus()` fires at once, can run inside an iteration. The app draws on a canvas laid over the whole page, or the one `ltk::web::set_canvas` gives, through WebGL2 with the GLES canvas unchanged, its shaders being GLSL ES 1.00 and its frame kept in an FBO that `present` blits, so WebGL clearing its drawing buffer after each frame does not matter; without WebGL2 the software canvas is unpremultiplied into `putImageData`. A lost context drops the GPU canvases, and its restoration wraps it in a new `glow::Context`, so the glyph atlas and the caches the old one kept, found through a `Weak`, are not reused. The main surface's logical size is the canvas's CSS size and its scale the page's `devicePixelRatio`, fractional as it may be, so the canvas holds the display's own pixels; the window's `resize` refits it, and on ltk's own canvas a hidden element padded by `env( safe-area-inset-* )` gives the insets a phone's notch and bars cover. Input (`input.rs`) comes from the canvas's Pointer Events, a mouse as the pointer and fingers and pens as touches at `offsetX` / `offsetY`, the wheel, whose lines are detents and whose pixels scroll continuously, and keys, whose keysym comes from `KeyboardEvent.key` and code from `.code` as the Linux input code Wayland reports (`keys.rs`); keys repeat on the browser's clock, AltGr types its character rather than counting as Ctrl and Alt, and on macOS Command is Ctrl. ltk takes every key the browser lets a page have, and while fullscreen also those `navigator.keyboard.lock()` gives in Chromium. Copies go to `navigator.clipboard.writeText` (`clipboard.rs`); the 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`. The built-in keyboard serves text where the page is used by touch (`(pointer: coarse)`), and its language is the page's (`platform::language`). The cursor's shape is the canvas's CSS `cursor` (`cursor.rs`). Resources are the tree an Android package carries, built by `scripts/web-assets.sh` and served next to the page; `ltk::web::load_resources( base )` fetches its `MANIFEST` and every file it lists into `vfs.rs` under `/ltk` and makes that the resource directory, and the per-script fallback fonts are found by file name in its `fonts/`. Panics go to the browser's console (`log.rs`); standard output and error go nowhere. When the app exits, the listeners are removed, the canvas receives an `ltk-exit` event and the app is dropped. `make web-check` typechecks the backend; `make web-deps` installs Debian's std for wasm32 and wasm-bindgen-cli, whose version is the wasm-bindgen of `Cargo.lock`, built from its sources for Debian's rustc into `target/tools`; `examples/web_hello.rs` with `make web-example` / `make web-serve` is the end-to-end check. Not done yet: overlays, secondary surfaces and subsurfaces are not created, dead keys and input methods are not composed, there is no accessibility tree and no primary selection, and a canvas that changes size while the window does not is not refitted. +**The browser backend** is `platform/web/`, for `wasm32-unknown-unknown` through wasm-bindgen. A page's main thread must not block, so `ltk::run` sets the app up and returns, and the browser drives the loop (`run.rs`): `AppData` lives in a closure held by a thread-local `Loop`, which a `setTimeout` runs for messages, timers and page events and a `requestAnimationFrame` runs to draw; an iteration drains the page's events, runs the due timers and the channels, reports a frame presented at the previous animation frame through `AppData::frame_presented`, and calls `before_draw`, `draw_due` (only in an animation frame) and `after_draw`, then schedules the next from `next_wakeup` and the next timer. The timers and channels are `event_loop/sources.rs`'s queue rather than calloop's: the backend takes the due timers out, runs them on `AppData` and hands them back, and a timer removed meanwhile is not armed again; `ChannelSender::send` wakes the loop through a waker the backend registers. Event listeners never touch `AppData`: they translate what they receive and queue it, and the iteration hands it to the runtime, so nothing a listener sets off, such as the focus event `canvas.focus()` fires at once, can run inside an iteration. The app draws on a canvas laid over the whole page, or the one `ltk::web::set_canvas` gives, through WebGL2 with the GLES canvas unchanged, its shaders being GLSL ES 1.00 and its frame kept in an FBO that `present` blits, so WebGL clearing its drawing buffer after each frame does not matter; without WebGL2 the software canvas is unpremultiplied into `putImageData`. A lost context drops the GPU canvases, and its restoration wraps it in a new `glow::Context`, so the glyph atlas and the caches the old one kept, found through a `Weak`, are not reused. The main surface's logical size is the canvas's CSS size and its scale the page's `devicePixelRatio`, fractional as it may be, so the canvas holds the display's own pixels; the window's `resize` refits it, and on ltk's own canvas a hidden element padded by `env( safe-area-inset-* )` gives the insets a phone's notch and bars cover. Input (`input.rs`) comes from the canvas's Pointer Events, a mouse as the pointer and fingers and pens as touches at `offsetX` / `offsetY`, the wheel, whose lines are detents and whose pixels scroll continuously, and keys, whose keysym comes from `KeyboardEvent.key` and code from `.code` as the Linux input code Wayland reports (`keys.rs`); keys repeat on the browser's clock, AltGr types its character rather than counting as Ctrl and Alt, and on macOS Command is Ctrl. ltk takes every key the browser lets a page have, and while fullscreen also those `navigator.keyboard.lock()` gives in Chromium. Copies go to `navigator.clipboard.writeText` (`clipboard.rs`); the 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`. The built-in keyboard serves text where the page is used by touch (`(pointer: coarse)`), and its language is the page's (`platform::language`). The cursor's shape is the CSS `cursor` of the element taking the input of the surface the pointer last entered (`cursor.rs`), as on Wayland it follows the pointer. Resources are the tree an Android package carries, built by `scripts/web-assets.sh` and served next to the page; `ltk::web::load_resources( base )` fetches its `MANIFEST` and every file it lists into `vfs.rs` under `/ltk` and makes that the resource directory, and the per-script fallback fonts are found by file name in its `fonts/`. Panics go to the browser's console (`log.rs`); standard output and error go nowhere. When the app exits, the listeners are removed, the canvas receives an `ltk-exit` event and the app is dropped. `make web-check` typechecks the backend; `make web-deps` installs Debian's std for wasm32 and wasm-bindgen-cli, whose version is the wasm-bindgen ltk pins, built from its sources for Debian's rustc into `target/tools` by `scripts/wasm-bindgen-cli.sh`, which the package installs for other projects to run; `examples/web_hello.rs` with `make web-example` / `make web-serve` is the end-to-end check. Not done yet: overlays, secondary surfaces and subsurfaces are not created, dead keys and input methods are not composed, there is no accessibility tree and no primary selection, and a canvas that changes size while the window does not is not refitted. ## On-screen keyboard diff --git a/examples/web_hello.rs b/examples/web_hello.rs index e07f701..44d26f8 100644 --- a/examples/web_hello.rs +++ b/examples/web_hello.rs @@ -11,14 +11,16 @@ enum Msg Press, Edit( String ), Submit, + Fullscreen, } #[ derive( Default ) ] struct Hello { - presses: u32, - typed: String, - submitted: String, + presses: u32, + typed: String, + submitted: String, + fullscreen: bool, } impl App for Hello @@ -28,6 +30,7 @@ impl App for Hello fn app_id( &self ) -> &str { "net.liberux.ltk.Hello" } fn save_state( &self ) -> Option> { None } fn restore_state( &mut self, _state: Vec ) {} + fn fullscreen( &self ) -> Option { Some( self.fullscreen ) } fn view( &self ) -> Element { @@ -40,6 +43,7 @@ impl App for Hello .push( button::( "Press" ).variant( ButtonVariant::Secondary ).on_press( Msg::Press ) ) .push( text_edit::( "Type and press Enter", &self.typed ).fixed_width( 320.0 ).on_change( Msg::Edit ).on_submit( Msg::Submit ) ) .push( text( format!( "Submitted: {}", self.submitted ) ).size( 18.0 ).color( palette.text_secondary ) ) + .push( button::( if self.fullscreen { "Leave fullscreen" } else { "Fullscreen" } ).variant( ButtonVariant::Secondary ).on_press( Msg::Fullscreen ) ) .push( spacer() ) .into() } @@ -51,6 +55,7 @@ impl App for Hello Msg::Press => self.presses += 1, Msg::Edit( value ) => self.typed = value, Msg::Submit => self.submitted = std::mem::take( &mut self.typed ), + Msg::Fullscreen => self.fullscreen = !self.fullscreen, } } } diff --git a/scripts/wasm-bindgen-cli.sh b/scripts/wasm-bindgen-cli.sh new file mode 100755 index 0000000..82091b4 --- /dev/null +++ b/scripts/wasm-bindgen-cli.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Build wasm-bindgen-cli into TOOLS_DIR/bin at the version of the wasm-bindgen crate ltk pins in its Cargo.toml, which is the version a module built with ltk needs. Debian does not package the CLI, and the lock it is published with needs a rustc newer than Debian's, so it is built from its sources with a lock resolved for the rust-version it declares. It reads the Cargo.toml next to this script's directory, so the copy installed with the package works from any project. +# +# Usage: scripts/wasm-bindgen-cli.sh TOOLS_DIR +set -eu + +if [ $# -ne 1 ] +then + echo "usage: $0 TOOLS_DIR" >&2 + exit 2 +fi +mkdir -p "$1" +tools=$( cd "$1" && pwd ) + +manifest=$( dirname "$0" )/../Cargo.toml +version=$( sed -n 's/^wasm-bindgen *= *{ *version *= *"=\([^"]*\)".*/\1/p' "$manifest" ) +if [ -z "$version" ] +then + echo "$0: no pinned wasm-bindgen in $manifest" >&2 + exit 1 +fi + +src=$( mktemp -d ) +trap 'rm -rf "$src"' EXIT +curl -sSfL "https://static.crates.io/crates/wasm-bindgen-cli/wasm-bindgen-cli-$version.crate" | tar -xz -C "$src" --strip-components=1 +cd "$src" +CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS=fallback cargo generate-lockfile +cargo install --path . --locked --root "$tools" diff --git a/src/event_loop/drag.rs b/src/event_loop/drag.rs index 77ef259..5c0d0c0 100644 --- a/src/event_loop/drag.rs +++ b/src/event_loop/drag.rs @@ -49,6 +49,7 @@ impl AppData // text menu likewise does not switch to drag, so a // subsequent motion can still extend a selection. if ds_msg.is_some() { ss.gesture.long_press_fired = true; } + if lp_msg.is_some() { ss.gesture.long_press_msg_fired = true; } ss.request_redraw(); Some( ( lp_msg, ds_msg, origin, text_idx ) ) }; diff --git a/src/gles_render/image.rs b/src/gles_render/image.rs index 889cbf9..8de5c7f 100644 --- a/src/gles_render/image.rs +++ b/src/gles_render/image.rs @@ -14,7 +14,8 @@ //! *different* buffer on the next frame, a pointer-keyed cache would //! serve the stale texture for the new content. Content-keying makes //! that impossible: identical bytes → identical key, regardless of -//! where they live in memory. +//! where they live in memory. `draw_image_keyed` takes the caller's key +//! in place of the fingerprint, in a key space of its own. //! //! The cache is bounded to [`IMAGE_CACHE_MAX_BYTES`] of estimated GPU //! memory (RGBA8: `w × h × 4` per texture) with least-recently-drawn @@ -30,7 +31,7 @@ use glow::HasContext; -use crate::render::helpers::fingerprint_rgba; +use crate::render::helpers::{ fingerprint_rgba, ImageId }; use crate::render::lru::Lru; use crate::types::Rect; @@ -43,7 +44,7 @@ use super::GlesCanvas; /// the compositor. pub const IMAGE_CACHE_MAX_BYTES: usize = 32 * 1024 * 1024; -type ImageKey = ( u32, u32, u64 ); +type ImageKey = ( u32, u32, ImageId ); /// Content-keyed texture cache shared by every canvas on one GL context. pub( super ) struct ImageCache @@ -133,6 +134,16 @@ impl GlesCanvas /// the internal `upload_rgba_texture` helper enforces, raised one /// level so the cache key is never seeded with a bogus mapping. pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 ) + { + self.draw_image( ImageId::Content( fingerprint_rgba( rgba_data ) ), rgba_data, img_w, img_h, dest, opacity ); + } + + pub fn draw_image_keyed( &mut self, key: u64, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 ) + { + self.draw_image( ImageId::Keyed( key ), rgba_data, img_w, img_h, dest, opacity ); + } + + fn draw_image( &mut self, id: ImageId, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 ) { if !crate::render::helpers::validate_rgba_dims( "GlesCanvas", rgba_data, img_w, img_h ) { @@ -140,12 +151,10 @@ impl GlesCanvas } self.activate_target(); - // Content-fingerprint key — see the module doc for the - // rationale. The (w, h) prefix means a pathological pair of - // buffers with identical bytes but different declared sizes - // stays distinct (cannot happen for valid input, defence in - // depth). - let cache_key = ( img_w, img_h, fingerprint_rgba( rgba_data ) ); + // The (w, h) prefix keeps a pathological pair of buffers with + // identical bytes but different declared sizes distinct (cannot + // happen for valid input, defence in depth). + let cache_key = ( img_w, img_h, id ); let tex = self.image_cache.borrow_mut().image( &self.gl, cache_key, || { diff --git a/src/input/gesture/mod.rs b/src/input/gesture/mod.rs index 643be08..d21835a 100644 --- a/src/input/gesture/mod.rs +++ b/src/input/gesture/mod.rs @@ -130,6 +130,8 @@ pub struct GestureState /// `app.on_drop`. Set by the touch deadline when `drag_start_msg` /// is present, or by the mouse-motion promotion path. pub long_press_fired: bool, + /// `true` once the hold has fired `long_press_msg`, so the release is not also a tap. + pub long_press_msg_fired: bool, /// Accumulated downward overscroll (physical px) while a vertical /// scroll is pinned at its top — built up by [`Self::on_move`] when a /// pull-down can no longer scroll content, unwound by upward drag, and @@ -181,6 +183,7 @@ impl GestureState drag_start_msg: None, long_press_text_idx: None, long_press_fired: false, + long_press_msg_fired: false, overscroll_y: 0.0, mouse_press: false, last_pattern_node: None, @@ -243,8 +246,9 @@ impl GestureState // pointer Press handler flips `mouse_press` back to true // right after this call when the press came from a mouse; // touch leaves it `false`. - self.long_press_fired = false; - self.mouse_press = false; + self.long_press_fired = false; + self.long_press_msg_fired = false; + self.mouse_press = false; // Built-in long-press for text inputs: arms the same timer // even when the widget has no user-set `on_long_press`, so // the runtime can show the Copy / Cut / Paste menu after the @@ -522,6 +526,7 @@ impl GestureState let pressed = self.pressed_idx.take(); let released_slider = self.dragging_slider; let long_press_fired = self.long_press_fired; + let long_press_msg_fired = self.long_press_msg_fired; let horizontal_drag_started = self.horizontal_drag_started; let vertical_drag_started = self.vertical_drag_started; @@ -535,6 +540,7 @@ impl GestureState self.drag_start_msg = None; self.long_press_text_idx = None; self.long_press_fired = false; + self.long_press_msg_fired = false; self.mouse_press = false; self.horizontal_drag_started = false; self.vertical_drag_started = false; @@ -677,7 +683,7 @@ impl GestureState // keep firing through the calloop repeat timer // while held) — suppressing the release-time fire // avoids a double tap on a quick click. - if !w.handlers.is_repeating() + if !w.handlers.is_repeating() && !long_press_msg_fired { if let Some( msg ) = w.handlers.press_msg() { diff --git a/src/input/gesture/tests.rs b/src/input/gesture/tests.rs index fd05583..49f321e 100644 --- a/src/input/gesture/tests.rs +++ b/src/input/gesture/tests.rs @@ -551,6 +551,17 @@ fn release_after_long_press_fired_emits_drop() assert!( matches!( events[ 0 ], ReleaseEvent::Drop { .. } ) ); } +#[ test ] +fn release_after_long_press_msg_fired_is_not_a_tap() +{ + let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), Some( Msg::LongPressed ) ) ]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] ); + g.long_press_msg_fired = true; + let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false ); + assert!( events.iter().all( |e| !matches!( e, ReleaseEvent::PushMsg( _ ) ) ) ); +} + #[ test ] fn release_with_global_drag_emits_drop() { diff --git a/src/lib.rs b/src/lib.rs index 09e46c4..0a5d575 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -319,8 +319,8 @@ pub( crate ) mod protocol; pub( crate ) mod session_state; pub( crate ) mod secure_mem; pub( crate ) mod resources; -pub( crate ) mod time; -pub( crate ) mod vfs; +pub mod time; +pub mod vfs; pub mod gles_render; #[ cfg( not( target_arch = "wasm32" ) ) ] pub mod egl_context; @@ -432,7 +432,7 @@ pub use platform::android::{ keep_running, set_android_app, AndroidApp }; #[ cfg( feature = "web" ) ] pub mod web { - pub use crate::platform::web::{ load_resources, set_canvas }; + pub use crate::platform::web::{ load_resources, load_tree, set_canvas, set_shell_listener, shell_send }; } pub use resources::{ resource_dir, set_resource_dir }; pub use xkeysym::Keysym; diff --git a/src/platform/web/assets.rs b/src/platform/web/assets.rs index 7cd9d49..7f80ccd 100644 --- a/src/platform/web/assets.rs +++ b/src/platform/web/assets.rs @@ -18,6 +18,14 @@ const ROOT: &str = "/ltk"; /// /// The files are fetched at once, each with the manifest's hash in its URL, so a changed tree is never served from the browser's cache; the manifest itself always comes from the server. pub async fn load_resources( base: &str ) -> Result<(), String> +{ + load_tree( base, ROOT ).await?; + crate::resources::set_resource_dir( ROOT ); + Ok( () ) +} + +/// Fetch the tree served at `base`, listed by its `MANIFEST` as ltk's resources are, into memory under `root`, an absolute path such as `"/apps"`, where [`crate::vfs`] reads it. An app loads its own files this way, as crustace does its catalog of applications. +pub async fn load_tree( base: &str, root: &str ) -> Result<(), String> { let window = web_sys::window().ok_or( "no window" )?; let base = base.trim_end_matches( '/' ); @@ -38,9 +46,8 @@ pub async fn load_resources( base: &str ) -> Result<(), String> for ( path, request ) in pending { let bytes = body( request ).await.map_err( |e| format!( "{path}: {e}" ) )?; - crate::vfs::insert( Path::new( ROOT ).join( path ), bytes ); + crate::vfs::insert( Path::new( root ).join( path ), bytes ); } - crate::resources::set_resource_dir( ROOT ); Ok( () ) } diff --git a/src/platform/web/desktop.rs b/src/platform/web/desktop.rs index 545abf8..de47d9e 100644 --- a/src/platform/web/desktop.rs +++ b/src/platform/web/desktop.rs @@ -8,9 +8,11 @@ use wasm_bindgen::closure::Closure; use wasm_bindgen::{ JsCast, JsValue }; use web_sys::{ HtmlCanvasElement, HtmlElement }; -use crate::app::{ App, Layer, ShellMode }; +use crate::app::{ App, Layer, ShellMode, ToplevelEvent }; +use crate::event_loop::AppData; use super::run::{ push, PageEvent }; +use super::shell; /// The id the main surface goes by. pub( super ) const MAIN: u32 = 0; @@ -32,7 +34,13 @@ impl Connection let on_event = Closure::::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::().ok() ); - Some( Self { request: method( "request" )?, disconnect: method( "disconnect" )?, _on_event: on_event } ) + let this = Self { request: method( "request" )?, disconnect: method( "disconnect" )?, _on_event: on_event }; + shell::connected( Some( this.request.clone() ) ); + if let Err( e ) = this.request( &json!( { "type": "list_toplevels" } ) ) + { + web_sys::console::warn_1( &format!( "[ltk] list_toplevels: {e}" ).into() ); + } + Some( this ) } pub( super ) fn request( &self, message: &Value ) -> Result @@ -51,8 +59,18 @@ impl Connection Ok( ( canvas, input ) ) } + /// Ask for `surface`, a window, to fill the desktop or to go back. + pub( super ) fn set_fullscreen( &self, surface: u32, on: bool ) + { + if let Err( e ) = self.request( &json!( { "type": "set_fullscreen", "surface": surface, "on": on } ) ) + { + web_sys::console::warn_1( &format!( "[ltk] set_fullscreen: {e}" ).into() ); + } + } + pub( super ) fn close( &self ) { + shell::connected( None ); let _ = self.disconnect.call0( &JsValue::NULL ); } } @@ -71,10 +89,35 @@ fn on_event( json: String ) scale: event[ "scale" ].as_f64().unwrap_or( 1.0 ), } ), Some( "close" ) => push( PageEvent::Close { surface } ), + Some( "shell" ) => shell::deliver( &event[ "message" ] ), + Some( "toplevel_opened" ) => push( PageEvent::Toplevel( ToplevelEvent::Opened + { + id: event[ "id" ].as_u64().unwrap_or( 0 ) as u32, + app_id: event[ "app_id" ].as_str().unwrap_or_default().to_string(), + identifier: event[ "identifier" ].as_str().unwrap_or_default().to_string(), + title: event[ "title" ].as_str().unwrap_or_default().to_string(), + } ) ), + Some( "toplevel_closed" ) => push( PageEvent::Toplevel( ToplevelEvent::Closed { id: event[ "id" ].as_u64().unwrap_or( 0 ) as u32 } ) ), _ => web_sys::console::warn_1( &format!( "[ltk] forge/web: {json}" ).into() ), } } +/// Issue the activation tokens the app asked for and hand them to it before it draws, as xdg-activation does on Wayland: forge/web's, or empty without a desktop, so the app still goes ahead. +pub( super ) fn issue_activation_tokens( data: &mut AppData ) +{ + let tags = data.app.take_activation_requests(); + if tags.is_empty() { return; } + for tag in tags + { + let token = data.platform.desktop.as_ref() + .and_then( |d| d.request( &json!( { "type": "activation_token" } ) ).ok() ) + .and_then( |token| token.as_string() ) + .unwrap_or_default(); + if let Some( msg ) = data.app.on_activation_token( tag, token ) { data.pending_msgs.push( msg ); } + } + data.process_messages(); +} + pub( super ) fn layer_name( layer: Layer ) -> &'static str { match layer diff --git a/src/platform/web/input.rs b/src/platform/web/input.rs index 1be52a1..392b1ca 100644 --- a/src/platform/web/input.rs +++ b/src/platform/web/input.rs @@ -73,7 +73,11 @@ impl AppData { match input { - WebInput::Pointer( pointer ) => self.pointer_input( &pointer ), + WebInput::Pointer( pointer ) => + { + if pointer.kind == PointerKind::Enter { self.point_cursor_at( pointer.focus ); } + self.pointer_input( &pointer ); + } WebInput::Touch( touch ) => self.touch_input( &touch ), WebInput::TouchCancel => { self.reset_touch_state(); } WebInput::Key { key, action, ctrl, shift, alt } => @@ -101,6 +105,19 @@ impl AppData WebInput::Clipboard( text ) => self.clipboard = text, } } + + /// Show the cursor's shape on the surface `focus` names, which the pointer entered, as the cursor follows the pointer on Wayland. + fn point_cursor_at( &mut self, focus: SurfaceFocus ) + { + let input = match focus + { + SurfaceFocus::Main => Some( &self.main.platform.input ), + SurfaceFocus::Overlay( id ) => self.overlays.get( &id ).map( |ss| &ss.platform.input ), + }; + let Some( input ) = input.cloned() else { return }; + let _ = input.style().set_property( "cursor", self.platform.cursor ); + self.platform.cursor_target = input; + } } /// 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. diff --git a/src/platform/web/mod.rs b/src/platform/web/mod.rs index 5330387..5fd7405 100644 --- a/src/platform/web/mod.rs +++ b/src/platform/web/mod.rs @@ -7,6 +7,7 @@ //! | --- | --- | //! | `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 | +//! | `shell.rs` | The shell protocol over that connection, for a shell such as crustace: [`shell_send`] and [`set_shell_listener`] | //! | `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 | @@ -30,12 +31,14 @@ mod log; mod overlays; mod present; pub( crate ) mod run; +mod shell; mod state; mod subsurfaces; mod surface; -pub use assets::load_resources; +pub use assets::{ load_resources, load_tree }; pub use run::set_canvas; +pub use shell::{ set_shell_listener, shell_send }; pub( crate ) use state::WebPlatform; pub( crate ) use surface::WebSurfaceState; @@ -69,10 +72,14 @@ impl Platform for WebPlatform fn minimize_window( &self, _main: &WebSurfaceState ) {} - /// In a desktop a window's fullscreen is forge/web's to give, which it does not yet. + /// In a desktop a window's fullscreen is forge/web's to give; alone, the page's canvas takes the browser's. fn set_fullscreen( &self, main: &WebSurfaceState, fullscreen: bool ) -> bool { - if self.desktop.is_some() { return true; } + if let Some( desktop ) = self.desktop.as_ref() + { + desktop.set_fullscreen( main.id, fullscreen ); + return true; + } lock_keyboard( fullscreen ); if fullscreen { @@ -113,7 +120,8 @@ impl Platform for WebPlatform fn set_cursor_shape( &mut self, shape: CursorShape ) -> bool { - self.cursor_target.style().set_property( "cursor", cursor::css( shape ) ).is_ok() + self.cursor = cursor::css( shape ); + self.cursor_target.style().set_property( "cursor", self.cursor ).is_ok() } fn text_input_active( &self ) -> bool diff --git a/src/platform/web/overlays.rs b/src/platform/web/overlays.rs index 36985a1..bd6922d 100644 --- a/src/platform/web/overlays.rs +++ b/src/platform/web/overlays.rs @@ -32,6 +32,7 @@ fn overlay_role( app: &A, spec: &OverlaySpec, ( width, heigh "height": logical( height ), "exclusive_zone": zone, "keyboard": if spec.keyboard_exclusive { "exclusive" } else { "on_demand" }, + "namespace": app.overlay_namespace( spec.id ), } ) } @@ -86,7 +87,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) Vec::new() } }; - let mut platform = WebSurfaceState::new( canvas, id, gpu, listeners ); + let mut platform = WebSurfaceState::new( canvas, input, 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. diff --git a/src/platform/web/present.rs b/src/platform/web/present.rs index 8284deb..d20dfd3 100644 --- a/src/platform/web/present.rs +++ b/src/platform/web/present.rs @@ -72,7 +72,16 @@ pub( super ) fn present ( _, _, Some( _ ) ) => present_software( surface, canvas ), _ => false, }; - if let Some( desktop ) = platform.desktop.as_ref() { sync_input_region( desktop, surface, frame ); } + if let Some( desktop ) = platform.desktop.as_ref() + { + sync_input_region( desktop, surface, frame ); + if shown && !surface.presented + { + surface.presented = true; + let request = json!( { "type": "presented", "surface": surface.id } ); + if let Err( e ) = desktop.request( &request ) { web_sys::console::warn_1( &format!( "[ltk] presented: {e}" ).into() ); } + } + } if let ( true, Some( focus ) ) = ( shown, frame.frame ) { platform.owe_frame( focus ); } shown } diff --git a/src/platform/web/run.rs b/src/platform/web/run.rs index 1b86671..4d75d61 100644 --- a/src/platform/web/run.rs +++ b/src/platform/web/run.rs @@ -15,7 +15,7 @@ use wasm_bindgen::closure::Closure; use wasm_bindgen::{ JsCast, JsValue }; use web_sys::{ Document, Event, EventTarget, HtmlCanvasElement, HtmlElement, OffscreenCanvas, WebGl2RenderingContext }; -use crate::app::App; +use crate::app::{ App, ToplevelEvent }; use crate::event_loop::{ AppData, RunError, Sources, SurfaceFocus, SurfaceState }; use super::desktop::{ main_role, Connection, MAIN }; @@ -42,6 +42,8 @@ pub( super ) enum PageEvent { surface: u32, }, + /// A window of the desktop's, as `ext-foreign-toplevel-list` reports it. + Toplevel( ToplevelEvent ), } /// An event listener, registered for as long as it lives. @@ -129,6 +131,7 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> ( Some( desktop ), _ ) => { let ( canvas, input ) = desktop.create_surface( MAIN, main_role( &app ) ).map_err( RunError::Desktop )?; + if app.start_fullscreen() { desktop.set_fullscreen( MAIN, true ); } ( canvas, input, false ) } ( None, Some( canvas ) ) => ( canvas.clone(), HtmlElement::from( canvas ), false ), @@ -163,7 +166,7 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> if own { let _ = target.focus(); } let coarse = window.match_media( "(pointer: coarse)" ).ok().flatten().is_some_and( |m| m.matches() ); - let main = SurfaceState::new( WebSurfaceState::new( canvas, MAIN, offscreen.is_some(), input ), 0.0, String::new() ); + let main = SurfaceState::new( WebSurfaceState::new( canvas, target.clone(), MAIN, offscreen.is_some(), input ), 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. let mut data = AppData::new( app, platform, main, Sources::new( wake ), coarse, None ); @@ -289,6 +292,10 @@ impl AppData if self.app.on_close_requested() { self.exit_requested = true; } } PageEvent::Close { surface } => self.dismiss_overlay( surface ), + PageEvent::Toplevel( event ) => + { + if let Some( msg ) = self.app.on_toplevel_event( event ) { self.pending_msgs.push( msg ); } + } } } self.dispatch_sources(); @@ -302,6 +309,7 @@ impl AppData } self.before_draw(); + super::desktop::issue_activation_tokens( self ); super::overlays::reconcile_overlays( self ); let drawn = in_frame && self.draw_due(); super::subsurfaces::reconcile_subsurfaces( self ); diff --git a/src/platform/web/shell.rs b/src/platform/web/shell.rs new file mode 100644 index 0000000..5aca5f4 --- /dev/null +++ b/src/platform/web/shell.rs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The shell protocol over the app's connection to forge/web (forge/web/DESIGN.md, section 7): the JSON a shell speaks to forge over its socket, sent as a `shell` request and received as a `shell` event. What is sent before the app has connected waits for the connection. + +use std::cell::RefCell; + +use serde_json::{ json, Value }; +use wasm_bindgen::JsValue; + +thread_local! +{ + static REQUEST: RefCell> = const { RefCell::new( None ) }; + static PENDING: RefCell> = const { RefCell::new( Vec::new() ) }; + static LISTENER: RefCell>> = const { RefCell::new( None ) }; +} + +/// Send `message`, one of the shell protocol's JSON objects, to forge/web. +pub fn shell_send( message: &str ) +{ + let message: Value = match serde_json::from_str( message ) + { + Ok( m ) => m, + Err( e ) => + { + web_sys::console::warn_1( &format!( "[ltk] shell message is not JSON ({e}): {message}" ).into() ); + return; + } + }; + let request = REQUEST.with( |r| r.borrow().clone() ); + match request + { + Some( request ) => send( &request, message ), + None => PENDING.with( |p| p.borrow_mut().push( message ) ), + } +} + +/// Have `listener` receive each of forge/web's shell messages as JSON. +pub fn set_shell_listener( listener: impl Fn( &str ) + 'static ) +{ + LISTENER.with( |l| *l.borrow_mut() = Some( Box::new( listener ) ) ); +} + +fn send( request: &js_sys::Function, message: Value ) +{ + let json = json!( { "type": "shell", "message": message } ).to_string(); + if let Err( e ) = request.call1( &JsValue::NULL, &JsValue::from_str( &json ) ) + { + web_sys::console::warn_2( &"[ltk] forge/web refused a shell message:".into(), &e ); + } +} + +/// The connection's `request`, once connected, and `None` once it has closed. +pub( super ) fn connected( request: Option ) +{ + if let Some( request ) = &request + { + for message in PENDING.with( |p| std::mem::take( &mut *p.borrow_mut() ) ) + { + send( request, message ); + } + } + REQUEST.with( |r| *r.borrow_mut() = request ); +} + +pub( super ) fn deliver( message: &Value ) +{ + let json = message.to_string(); + LISTENER.with( |l| + { + if let Some( listener ) = l.borrow().as_ref() { listener( &json ); } + } ); +} diff --git a/src/platform/web/state.rs b/src/platform/web/state.rs index 7bdfea7..f895253 100644 --- a/src/platform/web/state.rs +++ b/src/platform/web/state.rs @@ -28,8 +28,10 @@ pub( crate ) struct WebPlatform pub gl: Option>, /// The browser took the WebGL context away; nothing is drawn until it gives it back. pub context_lost: bool, - /// Where the cursor's shape is set: the element under the pointer that takes the main surface's input. + /// Where the cursor's shape is set: the element taking the input of the surface the pointer last entered, since CSS gives each element its own. pub cursor_target: HtmlElement, + /// The cursor's shape as CSS names it, for the next surface the pointer enters. + pub cursor: &'static str, /// 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, /// The surfaces a frame was presented or asked for; the loop reports them at the next animation frame. @@ -61,6 +63,7 @@ impl WebPlatform gl, context_lost: false, cursor_target, + cursor: "default", inset_probe, frame_due: RefCell::new( Vec::new() ), next_surface: Cell::new( super::desktop::MAIN + 1 ), diff --git a/src/platform/web/subsurfaces.rs b/src/platform/web/subsurfaces.rs index b9b20dd..de67c27 100644 --- a/src/platform/web/subsurfaces.rs +++ b/src/platform/web/subsurfaces.rs @@ -151,12 +151,12 @@ pub( super ) fn reconcile_subsurfaces( data: &mut AppData ) fn create( desktop: &Connection, id: u32, spec: &SubsurfaceSpec, parent: &Parent, gpu: bool ) -> Option { - let ( canvas, _input ) = desktop.create_surface( id, role( spec.x, spec.y, parent ) ).map_err( |e| warn( spec.id, &e ) ).ok()?; + 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() ), + surface: WebSurfaceState::new( canvas, input, id, gpu, Vec::new() ), canvas: None, parent: spec.parent, parent_id: parent.id, diff --git a/src/platform/web/surface.rs b/src/platform/web/surface.rs index 10c459c..485dce2 100644 --- a/src/platform/web/surface.rs +++ b/src/platform/web/surface.rs @@ -2,7 +2,7 @@ // Copyright (C) 2026 Liberux Labs, S. L. use serde_json::Value; -use web_sys::{ CanvasRenderingContext2d, Element, HtmlCanvasElement, ImageBitmapRenderingContext }; +use web_sys::{ CanvasRenderingContext2d, Element, HtmlCanvasElement, HtmlElement, ImageBitmapRenderingContext }; use crate::app::App; use crate::event_loop::AppData; @@ -14,6 +14,8 @@ use super::run::Listener; pub( crate ) struct WebSurfaceState { pub canvas: HtmlCanvasElement, + /// Takes the surface's input: the element forge/web gives above the canvas, or the canvas itself without a desktop. + pub input: HtmlElement, /// Takes the frames painted on the shared WebGL context. pub bitmap: Option, /// Takes the software renderer's frames, without WebGL2. @@ -28,17 +30,19 @@ pub( crate ) struct WebSurfaceState pub role: Option, /// The surface's input, removed with it. pub listeners: Vec, + /// A frame has been shown, which forge/web was told of: its first, after which a launched window counts as activated. + pub presented: bool, } 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 ) -> Self + pub( super ) fn new( canvas: HtmlCanvasElement, input: HtmlElement, id: u32, gpu: bool, listeners: Vec ) -> 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 } + Self { canvas, input, bitmap, ctx2d, pixels: Vec::new(), id, input_region: None, role: None, listeners, presented: false } } } diff --git a/src/render/helpers.rs b/src/render/helpers.rs index 058662e..b31843e 100644 --- a/src/render/helpers.rs +++ b/src/render/helpers.rs @@ -12,6 +12,14 @@ use tiny_skia::{ Path, PathBuilder }; use crate::types::{ Corners, PathCmd }; +/// What names an image's pixels in both backends' image caches: a fingerprint of them, or a key its caller vouches for, kept apart so that neither is taken for the other. +#[ derive( Clone, Copy, PartialEq, Eq, Hash ) ] +pub( crate ) enum ImageId +{ + Content( u64 ), + Keyed( u64 ), +} + /// A 64-bit fingerprint of an RGBA buffer, the key of both backends' image /// caches. Hashes the full slice below 16 KB (icons, thumbnails) and a /// strided 8 × 512-byte sample above it, so a wallpaper does not pay a diff --git a/src/render/image.rs b/src/render/image.rs index 6398ccf..84ce5b3 100644 --- a/src/render/image.rs +++ b/src/render/image.rs @@ -19,7 +19,7 @@ use tiny_skia::{ IntSize, Pixmap, PixmapPaint, Transform }; use crate::types::Rect; -use super::helpers::fingerprint_rgba; +use super::helpers::{ fingerprint_rgba, ImageId }; use super::lru::Lru; use super::SoftwareCanvas; @@ -27,7 +27,7 @@ use super::SoftwareCanvas; /// cache: a phone wallpaper plus a working set of icons. const PREMUL_CACHE_MAX_BYTES: usize = 32 * 1024 * 1024; -type PremulKey = ( u32, u32, u64, u32 ); +type PremulKey = ( u32, u32, ImageId, u32 ); /// A premultiplied source and whether every pixel was fully opaque, which /// lets a 1:1 draw be a row copy. @@ -84,6 +84,16 @@ fn copy_rows( dst: &mut Pixmap, src: &Pixmap, x: i32, y: i32 ) impl SoftwareCanvas { pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 ) + { + self.draw_image( ImageId::Content( fingerprint_rgba( rgba_data ) ), rgba_data, img_w, img_h, dest, opacity ); + } + + pub fn draw_image_keyed( &mut self, key: u64, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 ) + { + self.draw_image( ImageId::Keyed( key ), rgba_data, img_w, img_h, dest, opacity ); + } + + fn draw_image( &mut self, id: ImageId, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 ) { if !super::helpers::validate_rgba_dims( "SoftwareCanvas", rgba_data, img_w, img_h ) { @@ -104,7 +114,7 @@ impl SoftwareCanvas let Some( int_size ) = tiny_skia::IntSize::from_wh( img_w, img_h ) else { return }; let alpha = opacity * self.global_alpha; - let key = ( img_w, img_h, fingerprint_rgba( rgba_data ), alpha.to_bits() ); + let key = ( img_w, img_h, id, alpha.to_bits() ); PREMUL_CACHE.with( |cell| { let mut cache = cell.borrow_mut(); diff --git a/src/render/mod.rs b/src/render/mod.rs index 5c545d9..90039e9 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -962,6 +962,16 @@ impl Canvas } } + /// [`Self::draw_image_data`] with `key` naming the pixels instead 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 them each time. Every draw with the same `key` must pass the same pixels: they are read only when the image is not cached. A key never names an image drawn through `draw_image_data`. + pub fn draw_image_keyed( &mut self, key: u64, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 ) + { + match self + { + Canvas::Software( c ) => c.draw_image_keyed( key, rgba_data, img_w, img_h, dest, opacity ), + Canvas::Gles( c ) => c.draw_image_keyed( key, rgba_data, img_w, img_h, dest, opacity ), + } + } + /// Zero pixels inside each rect — used by the partial-redraw /// path when the surface background is fully transparent. pub fn clear_rects_transparent( &mut self, rects: &[Rect] ) @@ -1305,3 +1315,42 @@ mod pixel_snap_tests assert_ne!( at_int, at_high, "x+0.6 rounds up to the next column" ); } } + +#[ cfg( test ) ] +mod keyed_image_tests +{ + use super::helpers::fingerprint_rgba; + use super::Canvas; + use crate::types::Rect; + + const AT: Rect = Rect { x: 2.0, y: 2.0, width: 4.0, height: 4.0 }; + + fn pixels( canvas: &Canvas ) -> Vec + { + let Canvas::Software( c ) = canvas else { panic!( "software canvas" ) }; + c.pixmap.data().to_vec() + } + + #[ test ] + fn a_keyed_image_draws_as_its_pixels_do() + { + let red = [ 255u8, 0, 0, 255 ].repeat( 16 ); + let mut by_content = Canvas::new( 16, 16 ); + by_content.draw_image_data( &red, 4, 4, AT, 1.0 ); + let mut by_key = Canvas::new( 16, 16 ); + by_key.draw_image_keyed( 7, &red, 4, 4, AT, 1.0 ); + assert_eq!( pixels( &by_content ), pixels( &by_key ) ); + } + + #[ test ] + fn a_key_equal_to_a_fingerprint_is_another_image() + { + let red = [ 255u8, 0, 0, 255 ].repeat( 16 ); + let blue = [ 0u8, 0, 255, 255 ].repeat( 16 ); + let mut canvas = Canvas::new( 16, 16 ); + canvas.draw_image_data( &red, 4, 4, AT, 1.0 ); + canvas.draw_image_keyed( fingerprint_rgba( &red ), &blue, 4, 4, AT, 1.0 ); + let Canvas::Software( c ) = &canvas else { panic!( "software canvas" ) }; + assert!( c.pixmap.pixel( 3, 3 ).unwrap().blue() > 200 ); + } +} diff --git a/src/time.rs b/src/time.rs index 516ab08..1ca063d 100644 --- a/src/time.rs +++ b/src/time.rs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -//! The monotonic clock, which `std` does not provide in a browser. +//! The monotonic clock, which `std` does not provide in a browser: [`Instant`] is `std`'s natively and `web_time`'s on wasm32, where `std::time::Instant::now` panics. An app that builds for both uses it in place of `std`'s. #[ cfg( not( target_arch = "wasm32" ) ) ] -pub( crate ) use std::time::Instant; +pub use std::time::Instant; #[ cfg( target_arch = "wasm32" ) ] -pub( crate ) use web_time::Instant; +pub use web_time::Instant; diff --git a/src/vfs.rs b/src/vfs.rs index 2a13745..b0401b9 100644 --- a/src/vfs.rs +++ b/src/vfs.rs @@ -1,37 +1,37 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -//! Reading themes, fonts and keyboard layouts: from the file system, or in a browser, which has none, from the files the page loaded into memory. +//! Reading themes, fonts, keyboard layouts and an app's own files: from the file system, or in a browser, which has none, from the files the page loaded into memory with `ltk::web::load_resources` or `ltk::web::load_tree`. An app that also builds for the browser reads its files through it. use std::io; use std::path::{ Path, PathBuf }; -pub( crate ) fn read_to_string( path: impl AsRef ) -> io::Result +pub fn read_to_string( path: impl AsRef ) -> io::Result { String::from_utf8( read( path )? ).map_err( |e| io::Error::new( io::ErrorKind::InvalidData, e ) ) } #[ cfg( not( target_arch = "wasm32" ) ) ] -pub( crate ) fn read( path: impl AsRef ) -> io::Result> +pub fn read( path: impl AsRef ) -> io::Result> { std::fs::read( path ) } /// The paths of the entries in `dir`, skipping any that cannot be read. #[ cfg( not( target_arch = "wasm32" ) ) ] -pub( crate ) fn read_dir( dir: impl AsRef ) -> io::Result> +pub fn read_dir( dir: impl AsRef ) -> io::Result> { Ok( std::fs::read_dir( dir )?.filter_map( Result::ok ).map( |entry| entry.path() ).collect() ) } #[ cfg( not( target_arch = "wasm32" ) ) ] -pub( crate ) fn is_file( path: impl AsRef ) -> bool +pub fn is_file( path: impl AsRef ) -> bool { path.as_ref().is_file() } #[ cfg( not( target_arch = "wasm32" ) ) ] -pub( crate ) fn is_dir( path: impl AsRef ) -> bool +pub fn is_dir( path: impl AsRef ) -> bool { path.as_ref().is_dir() } @@ -53,14 +53,14 @@ pub( crate ) fn insert( path: impl Into, bytes: Vec ) } #[ cfg( target_arch = "wasm32" ) ] -pub( crate ) fn read( path: impl AsRef ) -> io::Result> +pub fn read( path: impl AsRef ) -> io::Result> { files().get( path.as_ref() ).map( |bytes| bytes.to_vec() ).ok_or_else( || io::ErrorKind::NotFound.into() ) } /// The paths of the files and directories directly in `dir`. #[ cfg( target_arch = "wasm32" ) ] -pub( crate ) fn read_dir( dir: impl AsRef ) -> io::Result> +pub fn read_dir( dir: impl AsRef ) -> io::Result> { let dir = dir.as_ref(); let mut entries: Vec = files().keys() @@ -73,13 +73,13 @@ pub( crate ) fn read_dir( dir: impl AsRef ) -> io::Result> } #[ cfg( target_arch = "wasm32" ) ] -pub( crate ) fn is_file( path: impl AsRef ) -> bool +pub fn is_file( path: impl AsRef ) -> bool { files().contains_key( path.as_ref() ) } #[ cfg( target_arch = "wasm32" ) ] -pub( crate ) fn is_dir( path: impl AsRef ) -> bool +pub fn is_dir( path: impl AsRef ) -> bool { let path = path.as_ref(); files().keys().any( |file| file != path && file.starts_with( path ) )