From 20dc21448e9b5226111fa4a0bed1fe03b536cc86 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Mon, 19 Oct 2026 20:20:12 +0200 Subject: [PATCH] A browser backend: ltk runs on a page's canvas through WebGL2, on a core that builds for wasm32, and a surface's scale may be fractional ltk ran on Wayland and Android only. Running crustace and its apps in a browser needs, first, ltk apps running there; a window manager for the page is the next step. The runtime was already split from Wayland by the platform layer, so what kept it out of a browser was what the core and the runtime took from the operating system: calloop's epoll loop, EGL, a monotonic clock, a file system, threads and environment variables. The core builds for wasm32-unknown-unknown. `ltk::ChannelSender` is ltk's own type instead of a re-export of calloop's `Sender`, with the same `send` and `Clone`, so apps that only send and clone it build unchanged. The runtime arms its timers and channels through `event_loop/sources.rs` instead of a calloop `LoopHandle`: calloop's natively, and in a browser a queue the backend runs, which takes the due timers out, runs them on `AppData` and hands them back, and does not arm again one removed meanwhile. calloop, khronos-egl and raw-window-handle are dependencies off wasm32 only, where `egl_context.rs`, `UiSurface::try_new_gles` and `from_current_gles_loader` and the native GL names of `BorrowedGlesTexture` are left out; `sub_canvas` clones the uniform locations, which are `Copy` natively and only `Clone` on wasm. The monotonic clock is `crate::time::Instant`, `web_time`'s on wasm32, where `chrono` takes its `wasmbind` feature; the one subtraction that could go below the clock's origin, a surface's first `last_draw`, saturates. Themes, fonts, keyboard layouts and images are read through `vfs.rs`, the file system natively and on wasm32 files held in memory, so a wallpaper or an `Image::from_path` is now decoded from the file's bytes and its format told from its contents rather than its extension. The text-scale watcher, which runs gsettings on a thread, is left out on wasm32, and the built-in keyboard takes its language from `platform::language()`, `LANG` or the page's. criterion is a dev-dependency off wasm32 only, since rayon does not build there. The browser backend is the `web` feature, `platform/web/`, with wasm-bindgen, wasm-bindgen-futures, js-sys and web-sys. `build.rs` refuses it with another backend or for another target. A page's main thread must not block, so `ltk::run` sets the app up and returns, and the browser drives the loop: `AppData` lives in a closure a thread-local loop holds, which a `setTimeout` runs for messages, timers and page events and a `requestAnimationFrame` runs to draw. Event listeners and `ChannelSender::send` only queue and schedule, never touching `AppData`, so nothing they set off runs inside an iteration. The app draws on a canvas laid over the whole page, or the one `ltk::web::set_canvas` gives, through WebGL2 with the GLES canvas unchanged, its frame kept in its FBO, or without WebGL2 through `putImageData`. A lost context drops the GPU canvases and draws nothing until it is restored on a new `glow::Context`, so the glyph atlas and the caches of the old one are not reused. The main surface's logical size is the canvas's CSS size; the window's `resize` refits it, and on ltk's own canvas a hidden element padded by `env( safe-area-inset-* )` gives the insets. Input comes from Pointer Events, a mouse as the pointer and fingers and pens as touches, the wheel, in detents when it scrolls lines, and keys, whose keysym comes from `KeyboardEvent.key` and code from `.code` as the Linux input code Wayland reports; keys repeat on the browser's clock, AltGr types its character, and on macOS Command is Ctrl. ltk takes every key the browser lets a page have, and while fullscreen those `navigator.keyboard.lock()` adds in Chromium. Copies go to `navigator.clipboard.writeText`; a page reads the clipboard only from a `paste` event, so Ctrl+V is let through to the browser and held until its `paste` has put the text in `AppData::clipboard`, and a paste from the browser's menu comes with a Ctrl+V of its own. The cursor's shape is the canvas's CSS `cursor`. The built-in keyboard serves text where the page is used by touch. Panics go to the browser's console. When the app exits, the listeners are removed, the canvas receives an `ltk-exit` event and the app is dropped. The new `RunError::NoDocument` is returned where there is no page, as in a worker. Resources are the tree an Android package carries. `scripts/web-assets.sh` builds it through `android-assets.sh`, and `ltk::web::load_resources( base ).await` fetches its `MANIFEST` and every file it lists, with the manifest's hash in their URLs so a changed tree is not served from the browser's cache, into memory under `/ltk`, which becomes the resource directory. The per-script fallback fonts are read through `resources::read_font`, so a path that does not exist is found by file name in the resource directory's `fonts/`, where a page bundles them. A surface's scale may be fractional. `SurfaceState::scale_factor` is an `f32`, which `Platform::prepare_canvas`, `Presentation::scale` and the paint path take as it is; a surface's physical size is its logical size times the scale, rounded once, and `buffer_scale()` gives the whole number Wayland's buffer scale and regions need. Wayland and Android still set whole scales, so nothing changes there; the browser backend sets the page's `devicePixelRatio`, so the canvas holds the display's own pixels at any zoom. `App::on_scale_changed` keeps its `u32`, the scale rounded, and `App::on_resize` gives the exact physical size. The fallback-theme banner placed its text as if `draw_text` took the top of the line rather than its baseline, so most of the text fell above the banner, out of the surface; it is now centred as the title bar's is. Building. `make web-check` typechecks the backend. `make web-deps` installs Debian's std for wasm32 and wasm-bindgen-cli, whose version must be the one of the wasm-bindgen crate in `Cargo.lock`: the crate is pinned to 0.2.117, the last whose CLI builds with Debian's rustc 1.85, and since the lock the CLI was published with needs a newer rustc and `cargo install` ignores rust-version when it resolves, the CLI is built from its sources with a lock resolved for the rust-version it declares, into `target/tools`, where the Makefile finds it. `make web-assets` builds the resources with the in-tree default theme and the fallback fonts `WEB_FONTS` names. `examples/web_hello.rs`, a counter and a text field, and `web/index.html` are the smoke test: `make web-example` builds them into `target/web/` and `make web-serve` serves them. `make install` now copies `web/` into the registry. `README.md`, `docs/architecture.md` and `CHANGELOG.md` describe the backend, and `docs/cookbook.md` points to `ChannelSender`'s new home. Not done. In a browser there are no overlays, secondary surfaces or subsurfaces, no dead keys or input methods, no accessibility tree and no primary selection, and a canvas that changes size while the window does not is not refitted. Pasting relies on the browser sending `paste` to a focused canvas, which Chrome and Firefox do. The page fetches its whole resource tree, about 7 MB, before the app starts. There are no tests for the browser backend, `Sources`' queue or `vfs.rs`'s in-memory files, and fractional scales may leave one-pixel artefacts where edges fall between pixels. --- CHANGELOG.md | 4 + Cargo.toml | 56 +++- Makefile | 52 +++- README.md | 2 + build.rs | 11 +- docs/architecture.md | 4 +- docs/cookbook.md | 2 +- examples/web_hello.rs | 66 ++++ scripts/android-assets.sh | 1 + scripts/web-assets.sh | 9 + src/app.rs | 10 +- src/channel.rs | 68 ++++ src/chassis.rs | 2 +- src/core.rs | 22 +- src/draw/chrome.rs | 2 +- src/draw/paint.rs | 14 +- src/event_loop/app_data.rs | 26 +- src/event_loop/context_menu.rs | 4 +- src/event_loop/cursor_shape.rs | 2 +- src/event_loop/drag.rs | 2 +- src/event_loop/error.rs | 5 + src/event_loop/frame.rs | 14 +- src/event_loop/mod.rs | 3 + src/event_loop/osk.rs | 10 +- src/event_loop/perf.rs | 4 +- src/event_loop/repeat.rs | 15 +- src/event_loop/setup.rs | 52 ++-- src/event_loop/sources.rs | 190 ++++++++++++ src/event_loop/surface.rs | 27 +- src/event_loop/tests.rs | 4 +- src/event_loop/text_editing/scroll.rs | 2 +- src/event_loop/text_editing/selection.rs | 2 +- src/event_loop/text_scale.rs | 2 +- src/event_loop/tooltip.rs | 13 +- src/gles_render/framebuffer.rs | 34 +- src/gles_render/helpers.rs | 2 + src/gles_render/mod.rs | 6 +- src/gles_render/setup.rs | 204 ++++++------ src/input/gesture/mod.rs | 2 +- src/input/pointer/helpers.rs | 2 +- src/input/pointer/press.rs | 2 +- src/input/repeat/button.rs | 13 +- src/input/repeat/key.rs | 11 +- src/lib.rs | 13 +- src/osk/layout.rs | 7 +- src/osk/mod.rs | 3 +- src/platform/android/mod.rs | 2 +- src/platform/android/present.rs | 2 +- src/platform/android/run.rs | 4 +- src/platform/android/surface.rs | 4 +- src/platform/mock.rs | 2 +- src/platform/mod.rs | 29 +- src/platform/wayland/handlers.rs | 6 +- src/platform/wayland/mod.rs | 2 +- src/platform/wayland/overlays_reconcile.rs | 7 +- src/platform/wayland/present.rs | 7 +- src/platform/wayland/run.rs | 4 +- src/platform/wayland/subsurface.rs | 2 +- src/platform/wayland/surface.rs | 4 +- src/platform/web/assets.rs | 63 ++++ src/platform/web/clipboard.rs | 23 ++ src/platform/web/cursor.rs | 47 +++ src/platform/web/input.rs | 309 +++++++++++++++++++ src/platform/web/keys.rs | 179 +++++++++++ src/platform/web/log.rs | 15 + src/platform/web/mod.rs | 139 +++++++++ src/platform/web/present.rs | 105 +++++++ src/platform/web/run.rs | 342 +++++++++++++++++++++ src/platform/web/state.rs | 56 ++++ src/platform/web/surface.rs | 54 ++++ src/resources.rs | 4 +- src/system_fonts.rs | 10 +- src/theme/assets.rs | 23 +- src/theme/document.rs | 2 +- src/theme/schema/mod.rs | 2 +- src/time.rs | 9 + src/vfs.rs | 86 ++++++ src/wallpaper.rs | 5 +- src/widget/image/mod.rs | 2 +- src/widget/tab_strip/motion.rs | 2 +- web/index.html | 17 + 81 files changed, 2267 insertions(+), 303 deletions(-) create mode 100644 examples/web_hello.rs create mode 100755 scripts/web-assets.sh create mode 100644 src/channel.rs create mode 100644 src/event_loop/sources.rs create mode 100644 src/platform/web/assets.rs create mode 100644 src/platform/web/clipboard.rs create mode 100644 src/platform/web/cursor.rs create mode 100644 src/platform/web/input.rs create mode 100644 src/platform/web/keys.rs create mode 100644 src/platform/web/log.rs create mode 100644 src/platform/web/mod.rs create mode 100644 src/platform/web/present.rs create mode 100644 src/platform/web/run.rs create mode 100644 src/platform/web/state.rs create mode 100644 src/platform/web/surface.rs create mode 100644 src/time.rs create mode 100644 src/vfs.rs create mode 100644 web/index.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 1786cf2..132eab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Added +- **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. - **Android: the system clipboard, back that leaves, ltk's keyboard, `HOME` and `LANG`** — copies go to Android's `ClipboardManager` and its text is read back whenever the app gains the focus, through JNI on the activity (the `jni` crate joins the `android` feature). Back with nothing left to go back to minimises the app with `moveTaskToBack`, or closes it, as `App::back_at_root` says; the new `Platform::back_leaves` decides whether back may leave the main surface, which keeps Wayland's rule of the xdg-toplevel. Text is typed on ltk's own keyboard rather than the system's, from which a NativeActivity gets only key events. `set_android_app` sets `HOME` to the app's internal storage and, unless set, `SSL_CERT_DIR` to Android's CA certificates and `LANG` to the device's language, so desktop code finds its preferences, a TLS client its roots (rustls-native-certs knows no Android path) and the locale. - **Android: running in the background and closing** — `ltk::keep_running( Some( text ) )` starts the keep-alive service of an APK packaged with `KEEP_ALIVE=1`, a foreground service with `text` on an ongoing notification, so Android does not freeze the app once it leaves the screen; `None` stops it, and without the service nothing happens. When the app's last surface closes, ltk calls `finishAndRemoveTask` and waits for the activity to be destroyed: a second activity that Android starts in a process whose `android_main` returned never comes up, so the app should end the process after `run` returns rather than wait to be started again. `set_android_app` sets up the process only on its first call and keeps the latest activity on every call. @@ -56,6 +57,9 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Changed +- **A surface's scale may be fractional.** `SurfaceState::scale_factor` is an `f32`, the physical pixels per logical one, which `Platform::prepare_canvas`, `Presentation::scale` and the paint path take as it is; a surface's physical size is its logical size times the scale, rounded once (`physical_width`, `physical_height`), and `buffer_scale()` gives the whole number Wayland's buffer scale and regions need. Wayland and Android still set whole scales, so nothing changes there; the browser backend sets the page's `devicePixelRatio`. `App::on_scale_changed` keeps its `u32`, the scale rounded, and `App::on_resize` gives the exact physical size. +- **`ltk::ChannelSender` is ltk's own type** instead of a re-export of calloop's `Sender`, with the same `send`, returning `Result<(), std::sync::mpsc::SendError>`, and `Clone`, so apps that only send and clone it build unchanged; code that named calloop's type has to name ltk's. The runtime arms its timers and channels through `event_loop/sources.rs` instead of a calloop `LoopHandle`. calloop, khronos-egl and raw-window-handle are dependencies only off `wasm32`, where `egl_context`, `UiSurface::try_new_gles`, `UiSurface::from_current_gles_loader` and the native GL names of `BorrowedGlesTexture` (`texture_id`, `framebuffer_id`) do not exist; the monotonic clock is `crate::time::Instant`, which is `web_time::Instant` on `wasm32`, and `chrono` takes its `wasmbind` feature there. +- **Themes, fonts, keyboard layouts and images are read through `vfs.rs`**, which is the file system natively and on `wasm32` a set of files held in memory, filled through `vfs::insert`. A wallpaper or an `Image::from_path` is now decoded from the file's bytes, so its format is told from its contents rather than its extension. - **Input reaches the runtime as platform-neutral events.** `input/event.rs` defines `KeyInput`, `PointerInput` and `TouchInput`, and the pointer, touch and keyboard handlers take them through a small set of entry points (`pointer_input`, `touch_input`, `key_press`, `key_release`, `key_repeated`, `modifiers_changed`, `keyboard_enter`, `keyboard_leave`, `set_repeat_info`) instead of smithay-client-toolkit's events. The `KeyboardHandler`, `PointerHandler` and `TouchHandler` implementations moved to `platform/wayland/input.rs`, which only records the serials later Wayland requests need, resolves surfaces and translates. What input handling asks of the windowing system (window move, resize, minimise and fullscreen, a bare frame request, the cursor's shape, the input method) is done by named methods in `platform/wayland/`, so nothing under `input/` names Wayland and no entry point or `set_focus` takes a `QueueHandle`; `ResizeEdge` is ltk's own. The protocol state moved out of `AppData` into `platform::wayland::WaylandPlatform`, held as `AppData::platform`, and the compositor, shell, seat and output handlers, the data device and the primary selection moved from `event_loop/` to `platform/wayland/`. Each surface's Wayland half (`SurfaceKind`, the SHM pool, the EGL window surface and what the compositor was last told) moved from `SurfaceState` into `platform::wayland::WaylandSurfaceState`, held as `SurfaceState::platform`, and the requests above form the `platform::Platform` trait, with the surface type as its associated `Surface`. Drawing a frame is split from presenting it: `draw/paint.rs` paints a surface's view in full or under its dirty rects, the same for the software and GPU canvases, in place of the four near-identical paths of `draw/software.rs` and `draw/gles.rs`, and the backend prepares the canvas and presents it (`Platform::prepare_canvas`, `present`, `precise_damage`), in `platform/wayland/present.rs`, which also took the opaque and input regions and `pick_shm_format`. The EGL context moved into `WaylandPlatform`. Publishing the clipboard and the primary selection goes through `Platform::publish_clipboard` and `publish_primary_selection`, while their text stays with the runtime in `AppData::clipboard` and the new `AppData::primary_selection`. The runtime is generic over the platform: `AppData>` and `SurfaceState`, resolved at compile time; the backend-neutral half of a configure is `SurfaceState::set_size` and `AppData::on_main_resized`, and the backend-neutral half of a loop iteration moved from `run.rs` to `event_loop/step.rs` (`process_messages`, `draw_due`, and around them `next_wakeup`, `before_draw` and `after_draw`, which carry the deadlines, the clipboard inbox, the accessibility tree and its actions, and the app's blur, copy, paste and focus requests), with `AppData::new` and `AppData::install_sources` (`event_loop/setup.rs`) building the state and the event sources every backend has. The run loop itself, overlay and subsurface reconciliation, session management, output probing and window fitting moved from `event_loop/` to `platform/wayland/`, with the main output, the subsurfaces and the drop inbox now in `WaylandPlatform`; `AppData::secondaries` pairs each surface with `Platform::Output`. Tests drive the runtime on `platform/mock.rs`, a platform that logs what is asked of it, through input, update, painting and presentation (`event_loop/tests.rs`). The behaviour is unchanged, but for a frame whose presentation fails, which now leaves the next frame to repaint in full. This is the first step towards backends other than Wayland, starting with Android; `docs/architecture.md` describes the events, their units and what stays with a backend. - **`ltk::Keysym` comes from `xkeysym`**, the crate smithay-client-toolkit re-exported it from, so it is the same type and no code using it changes. - **`XF86Back` is no longer an alias of `Escape`.** It used to run the Escape path: close transient UI, else drop the focus and reach `on_key_with_modifiers`. It is now a back request with its own order (see `App::on_back` under Added). Visible differences for an existing app: with a text input focused, back only drops the focus and the app sees the *next* request, where it used to get the key in the same press; the key is no longer reported through `App::on_raw_key`, so hosts forwarding raw keys into an embedded view stop leaking it to the page; it no longer auto-repeats by default (`App::key_repeats`); and a `ShellMode::Window` app that handles neither `on_back` nor the key is **minimised** on back at its root, where nothing happened before — return `BackAtRoot::Ignore` from `back_at_root` to keep the old behaviour. An app that binds `XF86_Back` in `on_key` / `on_key_with_modifiers` keeps receiving it. `Escape` itself is unchanged; its transient-UI step is now shared with back as `AppData::dismiss_transient`. diff --git a/Cargo.toml b/Cargo.toml index 6d5853b..acb4453 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,15 @@ android = [ "dep:ndk", "dep:jni", ] +# The browser backend: `ltk::run` on a page's canvas through WebGL2, or a 2D +# context without it. Builds only for `wasm32-unknown-unknown` and excludes +# the other backends, so a web build disables the default features. +web = [ + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:js-sys", + "dep:web-sys", +] # Exposes `ltk::test_support` — internal helpers (widget handlers, laid-out # widget lookup, slider math) used only by ltk's own integration tests. Not # part of the stable public API; off by default so third-party builds never @@ -57,7 +66,6 @@ serde_json = "1.0" smithay-client-toolkit = { version = "0.20", features = ["calloop", "calloop-wayland-source", "xkbcommon"], optional = true } # The `Keysym` sctk re-exports, named directly so it does not depend on Wayland. xkeysym = "0.2" -calloop = "0.14" calloop-wayland-source = { version = "0.4", optional = true } tiny-skia = "0.12" unicode-bidi = "0.3" @@ -72,9 +80,7 @@ libc = "0.2" wayland-protocols = { version = "0.32", features = ["client", "unstable", "staging"], optional = true } wayland-egl = { version = "0.32", optional = true } wayland-scanner = { version = "0.31", optional = true } -khronos-egl = { version = "6", features = ["dynamic"] } glow = "0.17" -raw-window-handle = "0.6" # AT-SPI2 / accessibility is delegated to AccessKit so we get the same # Node / Role / TreeUpdate model the rest of the Rust GUI ecosystem # (egui, iced via the COSMIC fork, dioxus) is converging on. The @@ -90,7 +96,44 @@ android-activity = { version = "0.6", features = ["native-activity"], optional ndk = { version = "0.9", default-features = false, optional = true } jni = { version = "0.21", optional = true } -[dev-dependencies] +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +calloop = "0.14" +khronos-egl = { version = "6", features = ["dynamic"] } +raw-window-handle = "0.6" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +chrono = { version = "0.4", features = ["clock", "wasmbind"] } +web-time = "1" +# wasm-bindgen-cli must be this same version, and later ones need a rustc newer than Debian's 1.85. +wasm-bindgen = { version = "=0.2.117", optional = true } +wasm-bindgen-futures = { version = "0.4", optional = true } +js-sys = { version = "0.3", optional = true } +web-sys = { version = "0.3", optional = true, features = [ + "CanvasRenderingContext2d", + "ClipboardEvent", + "CssStyleDeclaration", + "DataTransfer", + "Document", + "Element", + "Event", + "EventTarget", + "HtmlCanvasElement", + "HtmlElement", + "ImageData", + "KeyboardEvent", + "MediaQueryList", + "MouseEvent", + "Navigator", + "Node", + "PointerEvent", + "Response", + "WebGl2RenderingContext", + "WheelEvent", + "Window", + "console", +] } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } [[example]] @@ -98,6 +141,11 @@ name = "android_hello" crate-type = [ "cdylib" ] required-features = [ "android" ] +[[example]] +name = "web_hello" +crate-type = [ "cdylib" ] +required-features = [ "web" ] + [[bench]] name = "lookup" harness = false diff --git a/Makefile b/Makefile index e4c4b1e..f9fb518 100644 --- a/Makefile +++ b/Makefile @@ -22,11 +22,26 @@ ANDROID_ASSETS ?= target/android/assets ANDROID_ABI ?= arm64-v8a 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. +WEB_TARGET = wasm32-unknown-unknown +WEB_CARGO = --target $(WEB_TARGET) --no-default-features --features web +WEB_OUT ?= target/web +# 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 +WASM_BINDGEN ?= $(or $(wildcard $(WEB_TOOLS)/bin/wasm-bindgen),$(shell command -v wasm-bindgen 2>/dev/null)) + # Targets are recipes, not files. Listing them as PHONY tells make to # ignore filesystem entries with the same name — without this `examples` # silently no-ops because the `examples/` directory exists, and `doc` # would do the same once `target/doc` is around. -.PHONY: all deps android-deps test doctest-md stylecheck clippy audit doc install examples clean distclean android-check android-assets android-apk android-install +.PHONY: all deps android-deps test doctest-md stylecheck clippy audit doc install examples clean distclean android-check android-assets android-apk android-install web-deps web-check web-assets web-example web-serve all: cargo build --release @@ -80,6 +95,39 @@ 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. +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) + +# Typecheck the browser backend. +web-check: + cargo check $(WEB_CARGO) + +# The in-tree default theme, the fonts it names and $(WEB_FONTS), as a page serves them. +web-assets: + EXTRA_FONTS="$(WEB_FONTS)" ./scripts/web-assets.sh $(WEB_OUT) themes default + +# examples/web_hello.rs as a page under $(WEB_OUT), with its resources. +web-example: web-assets + @test -n "$(WASM_BINDGEN)" || { echo "No wasm-bindgen in $(WEB_TOOLS)/bin or on PATH; run make web-deps"; exit 1; } + cargo build --release --example web_hello $(WEB_CARGO) + $(WASM_BINDGEN) --target web --no-typescript --out-dir $(WEB_OUT)/pkg target/$(WEB_TARGET)/release/examples/web_hello.wasm + cp web/index.html $(WEB_OUT)/ + +web-serve: web-example + cd $(WEB_OUT) && python3 -m http.server 8000 + audit: @command -v cargo-audit >/dev/null 2>&1 || cargo install cargo-audit --locked cargo audit @@ -89,7 +137,7 @@ doc: install: doc install -d $(REGISTRY) - cp -r src benches examples locales protocols keyboards scripts java build.rs Cargo.toml liberux.toml $(REGISTRY)/ + cp -r src benches examples locales protocols keyboards scripts java web build.rs Cargo.toml liberux.toml $(REGISTRY)/ cp debian/cargo-checksum.json $(REGISTRY)/.cargo-checksum.json install -d $(DOCDIR) cp -r target/doc/* $(DOCDIR)/ diff --git a/README.md b/README.md index 6b51a6d..63012ae 100644 --- a/README.md +++ b/README.md @@ -164,6 +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. - 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. @@ -203,6 +204,7 @@ Useful entry points in this repository: - `cargo run --example carousel` - `cargo run --example clip_path` - `cargo run --example mini_shell` +- `make web-serve`, then : `web_hello` in a browser, on the in-tree default theme In general: diff --git a/build.rs b/build.rs index 29659f4..77b4d85 100644 --- a/build.rs +++ b/build.rs @@ -7,11 +7,16 @@ fn main() println!( "cargo::rustc-check-cfg=cfg(backend)" ); let wayland = std::env::var_os( "CARGO_FEATURE_WAYLAND" ).is_some(); let android = std::env::var_os( "CARGO_FEATURE_ANDROID" ).is_some(); - if wayland && android + let web = std::env::var_os( "CARGO_FEATURE_WEB" ).is_some(); + if [ wayland, android, web ].iter().filter( |on| **on ).count() > 1 { - panic!( "ltk: the `wayland` and `android` features are exclusive; build for Android with `default-features = false, features = [ \"android\" ]`" ); + panic!( "ltk: the `wayland`, `android` and `web` features are exclusive; build another backend with `default-features = false, features = [ \"android\" ]` or `[ \"web\" ]`" ); } - if wayland || android + if web && std::env::var( "CARGO_CFG_TARGET_ARCH" ).as_deref() != Ok( "wasm32" ) + { + panic!( "ltk: the `web` feature builds only for `wasm32-unknown-unknown`" ); + } + if wayland || android || web { println!( "cargo::rustc-cfg=backend" ); } diff --git a/docs/architecture.md b/docs/architecture.md index 8b612f6..4bbca71 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -218,12 +218,14 @@ Behind them sit hover and focus, the gesture machine (tap, long-press, drag, scr **Testing the runtime.** `platform/mock.rs`, built only for tests, is a `Platform` that talks to no system: it paints on a software canvas, logs every request (presentations with their damage and frame surface, bare frames, cursor shapes, text-input, clipboard, window requests) and can be told to fail presentations. `event_loop/tests.rs` drives an `AppData` through the backend-neutral input entry points and `process_messages` / `draw_due`, the same code the run loop runs, and checks what reached the platform: the first frame presented whole with a frame callback, no drawing while a frame is pending, a click reaching `update`, a hover presenting only the widget's rect, a failed presentation leaving a full repaint, a secure or terminal field reaching the input method with its content type, and a copy publishing the clipboard. -**Building with and without a backend.** The Wayland backend is the `wayland` feature, on by default, which brings in smithay-client-toolkit, wayland-protocols, wayland-egl, wayland-scanner, calloop-wayland-source and accesskit_unix, the AT-SPI2 adapter. The Android backend is the `android` feature, which brings in android-activity and ndk and builds only for Android targets. The two are exclusive, `build.rs` refuses both at once, so an Android build disables the default features. `build.rs` sets `cfg( backend )` when one is enabled; it gates the runtime, that is `event_loop/`, `input/`, `platform/`, `draw/paint.rs` and `run` / `try_run` / `RunError`, while `feature = "wayland"` gates `platform/wayland/`, `protocol/`, `session_state.rs`, `EglContext::new` and `EglContext::create_surface`, `EglSurface::resize`, and the wlr-layer conversions of `Layer` and `Anchor`, and `feature = "android"` gates `platform/android/`, `EglContext::new_android` and `EglContext::create_native_surface`. `EglContext` and `EglSurface` exist with either. `platform/mod.rs` names the backend in use through two aliases, `NativePlatform` for `AppData::platform` and `NativeSurface` for `SurfaceState::platform`, each set under its backend's `cfg`. With `--no-default-features` only the runtime-free core builds: `UiSurface`, the widgets, layout, theming, rendering and `EglOffscreenContext`. +**Building with and without a backend.** The Wayland backend is the `wayland` feature, on by default, which brings in smithay-client-toolkit, wayland-protocols, wayland-egl, wayland-scanner, calloop-wayland-source and accesskit_unix, the AT-SPI2 adapter. The Android backend is the `android` feature, which brings in android-activity and ndk and builds only for Android targets. The browser backend is the `web` feature, which brings in wasm-bindgen, wasm-bindgen-futures, js-sys and web-sys and builds only for `wasm32-unknown-unknown`. The three are exclusive, `build.rs` refuses more than one and `web` for another target, so an Android or a web build disables the default features. On `wasm32` the core builds without calloop, khronos-egl and raw-window-handle: `egl_context.rs`, `UiSurface::try_new_gles` and `from_current_gles_loader` and the native GL names of `BorrowedGlesTexture` are left out, the monotonic clock is `crate::time::Instant` (`web_time`'s), and themes, fonts, layouts and images are read through `vfs.rs`, which is the file system elsewhere and files held in memory there. `build.rs` sets `cfg( backend )` when one is enabled; it gates the runtime, that is `event_loop/`, `input/`, `platform/`, `draw/paint.rs` and `run` / `try_run` / `RunError`, while `feature = "wayland"` gates `platform/wayland/`, `protocol/`, `session_state.rs`, `EglContext::new` and `EglContext::create_surface`, `EglSurface::resize`, and the wlr-layer conversions of `Layer` and `Anchor`, and `feature = "android"` gates `platform/android/`, `EglContext::new_android` and `EglContext::create_native_surface`. `EglContext` and `EglSurface` exist with either. `platform/mod.rs` names the backend in use through two aliases, `NativePlatform` for `AppData::platform` and `NativeSurface` for `SurfaceState::platform`, each set under its backend's `cfg`. With `--no-default-features` only the runtime-free core builds: `UiSurface`, the widgets, layout, theming, rendering and `EglOffscreenContext`. **Nothing in the runtime names Wayland.** `event_loop/`, `input/` and `draw/` reach the system only through `Platform`; what was Wayland-bound in `event_loop/` (the run loop, overlay and subsurface reconciliation, output probing, window fitting and session management) lives in `platform/wayland/`. `egl_context.rs` creates its `EglContext` from a Wayland connection or from Android's default display, and its `EglSurface` on a `wl_egl_window` or an `ANativeWindow`. **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. + ## On-screen keyboard Text entry on a phone comes from `zwp_text_input_v3`: a focused `TextEdit` enables the protocol and the compositor's input method, a separate process such as squeekboard, draws a keyboard and commits text through it. ltk carries a keyboard of its own for the cases where that chain is not there, and for compositors that would rather draw it themselves. Everything lives in `ltk::osk`. diff --git a/docs/cookbook.md b/docs/cookbook.md index 989b9a4..80707df 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -255,7 +255,7 @@ your `Drop` impl too. **See also**: [`SECURITY.md`](../SECURITY.md), the [`text_edit`](./widgets.md#text_edit) widget, and -[`crate::ChannelSender`](../src/app.rs). +[`crate::ChannelSender`](../src/channel.rs). --- diff --git a/examples/web_hello.rs b/examples/web_hello.rs new file mode 100644 index 0000000..e07f701 --- /dev/null +++ b/examples/web_hello.rs @@ -0,0 +1,66 @@ +//! The smallest ltk app in a browser: a counter and a text field on a canvas over the whole page, on the default theme the page serves under `ltk/`. `make web-example` builds it and its resources into `target/web/`, `make web-serve` serves it at . +//! +//! A web app is a library the page loads, so this example builds as a `cdylib` whose entry point is the `start` function wasm-bindgen runs when the module is loaded. + +use ltk::{ App, ButtonVariant, Element, button, column, spacer, text, text_edit }; +use wasm_bindgen::prelude::wasm_bindgen; + +#[ derive( Clone ) ] +enum Msg +{ + Press, + Edit( String ), + Submit, +} + +#[ derive( Default ) ] +struct Hello +{ + presses: u32, + typed: String, + submitted: String, +} + +impl App for Hello +{ + type Message = Msg; + + fn app_id( &self ) -> &str { "net.liberux.ltk.Hello" } + fn save_state( &self ) -> Option> { None } + fn restore_state( &mut self, _state: Vec ) {} + + fn view( &self ) -> Element + { + let palette = ltk::theme_palette(); + column::() + .spacing( 16.0 ) + .push( spacer() ) + .push( text( "ltk in a browser" ).size( 28.0 ).color( palette.text_primary ) ) + .push( text( format!( "Pressed {} times", self.presses ) ).size( 18.0 ).color( palette.text_secondary ) ) + .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( spacer() ) + .into() + } + + fn update( &mut self, msg: Msg ) + { + match msg + { + Msg::Press => self.presses += 1, + Msg::Edit( value ) => self.typed = value, + Msg::Submit => self.submitted = std::mem::take( &mut self.typed ), + } + } +} + +#[ wasm_bindgen( start ) ] +pub async fn start() +{ + if let Err( e ) = ltk::web::load_resources( "ltk" ).await + { + web_sys::console::warn_1( &format!( "ltk resources: {e}" ).into() ); + } + ltk::run( Hello::default() ); +} diff --git a/scripts/android-assets.sh b/scripts/android-assets.sh index bf8a4cb..17e9155 100755 --- a/scripts/android-assets.sh +++ b/scripts/android-assets.sh @@ -3,6 +3,7 @@ # themes, the font files their theme.json names by absolute path, the # on-screen keyboard's layouts, and the MANIFEST that src/platform/android/assets.rs unpacks from, whose first # line is a hash of the contents so a changed tree is unpacked again. +# web-assets.sh builds the same tree for a page through this script. # # Usage: scripts/android-assets.sh OUT_DIR [THEMES_DIR [THEME_ID...]] # THEMES_DIR defaults to /usr/share/ltk/themes, THEME_ID to `default`. diff --git a/scripts/web-assets.sh b/scripts/web-assets.sh new file mode 100755 index 0000000..0464006 --- /dev/null +++ b/scripts/web-assets.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Build the `ltk/` tree a page serves next to it and loads with +# `ltk::web::load_resources`: the same tree an Android package carries, so +# android-assets.sh builds it, with the same arguments and variables. +# +# Usage: scripts/web-assets.sh OUT_DIR [THEMES_DIR [THEME_ID...]] +# EXTRA_FONTS adds the fallback fonts a page needs beyond Latin, such as +# NotoSans-Regular.ttf or DejaVuSans.ttf, found by file name in `ltk/fonts/`. +exec "$( dirname "$0" )/android-assets.sh" "$@" diff --git a/src/app.rs b/src/app.rs index d8c4c7b..c962556 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,7 +2,7 @@ // Copyright (C) 2026 Liberux Labs, S. L. pub use xkeysym::Keysym; -pub use calloop::channel::Sender as ChannelSender; +pub use crate::channel::ChannelSender; use crate::widget::Element; @@ -1103,7 +1103,10 @@ pub trait App: 'static /// Called when the compositor reports a new integer buffer scale for the /// main surface. `scale` is the integer buffer scale (1 = standard DPI, - /// 2 = HiDPI ×2); physical pixels equal logical pixels × scale. The default + /// 2 = HiDPI ×2); physical pixels equal logical pixels × scale. In a + /// browser the scale is the page's `devicePixelRatio`, which may be + /// fractional; `scale` is then that ratio rounded, and + /// [`on_resize`](Self::on_resize) gives the exact physical size. The default /// is inert so apps that only care about physical pixels can keep using /// [`on_resize`](Self::on_resize). fn on_scale_changed( &mut self, _scale: u32 ) {} @@ -1378,7 +1381,8 @@ pub trait App: 'static fn keyboard_exclusive( &self ) -> bool { false } } -/// Run the application. Blocks until the window is closed. +/// Run the application. Blocks until the window is closed; in a browser, +/// whose page must not block, it returns once the app is running. /// /// Panics on init failure (no Wayland compositor, missing protocol, etc.). /// Embedders that need to recover gracefully — e.g. fall back to a diff --git a/src/channel.rs b/src/channel.rs new file mode 100644 index 0000000..74cd82a --- /dev/null +++ b/src/channel.rs @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! [`ChannelSender`], which delivers an app's messages into the run loop. + +use std::fmt; +use std::sync::mpsc::SendError; + +/// Delivers messages into the run loop from any thread and wakes it. Given to the app by [`crate::App::set_channel_sender`]. +pub struct ChannelSender +{ + #[ cfg( not( target_arch = "wasm32" ) ) ] + inner: calloop::channel::Sender, + #[ cfg( target_arch = "wasm32" ) ] + inner: std::sync::mpsc::Sender, +} + +impl ChannelSender +{ + /// Fails once the run loop has ended. + pub fn send( &self, msg: T ) -> Result<(), SendError> + { + self.inner.send( msg )?; + #[ cfg( target_arch = "wasm32" ) ] + wake(); + Ok( () ) + } + + #[ cfg( not( target_arch = "wasm32" ) ) ] + pub( crate ) fn from_calloop( inner: calloop::channel::Sender ) -> Self + { + Self { inner } + } +} + +impl Clone for ChannelSender +{ + fn clone( &self ) -> Self + { + Self { inner: self.inner.clone() } + } +} + +impl fmt::Debug for ChannelSender +{ + fn fmt( &self, f: &mut fmt::Formatter<'_> ) -> fmt::Result + { + f.debug_struct( "ChannelSender" ).finish_non_exhaustive() + } +} + +#[ cfg( target_arch = "wasm32" ) ] +static WAKE: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// A sender and the receiver the run loop drains; `wake` is called after every message sent. +#[ cfg( target_arch = "wasm32" ) ] +pub( crate ) fn channel( wake: fn() ) -> ( ChannelSender, std::sync::mpsc::Receiver ) +{ + let _ = WAKE.set( wake ); + let ( inner, rx ) = std::sync::mpsc::channel(); + ( ChannelSender { inner }, rx ) +} + +#[ cfg( target_arch = "wasm32" ) ] +fn wake() +{ + if let Some( wake ) = WAKE.get() { wake(); } +} diff --git a/src/chassis.rs b/src/chassis.rs index 791fe27..4fa4ba9 100644 --- a/src/chassis.rs +++ b/src/chassis.rs @@ -23,7 +23,7 @@ pub fn set_default_theme( mode: ThemeMode ) -> Result<(), String> pub fn theme_logo_rgba( size: u32 ) -> Option { let path = crate::theme_logo_horizontal()?; - let bytes = std::fs::read( &path ).ok()?; + let bytes = crate::vfs::read( &path ).ok()?; crate::decode_svg_bytes( &bytes, size ) } diff --git a/src/core.rs b/src/core.rs index 7fef425..3867ca2 100644 --- a/src/core.rs +++ b/src/core.rs @@ -9,6 +9,7 @@ //! layer-shell, xdg-shell, SHM pool, or frame-callback logic. use std::collections::HashMap; +#[ cfg( not( target_arch = "wasm32" ) ) ] use std::ffi::c_void; use std::sync::Arc; @@ -17,6 +18,7 @@ pub use crate::render::Canvas; pub use crate::widget::{ LaidOutWidget, WidgetHandlers }; use crate::draw::{ self, DrawCtx }; +#[ cfg( not( target_arch = "wasm32" ) ) ] use crate::egl_context::EglOffscreenContext; use crate::types::{ Color, Point, Rect }; use crate::widget::Element; @@ -89,6 +91,7 @@ pub struct RenderOutput pub struct UiSurface { canvas: Canvas, + #[ cfg( not( target_arch = "wasm32" ) ) ] egl_context: Option, focused_idx: Option, hovered_idx: Option, @@ -133,6 +136,7 @@ impl UiSurface /// useful for runtime-free rendering; compositors that already own a GL /// context can instead build a `Canvas::new_gles(...)` and pass it to /// [`Self::from_canvas`]. + #[ cfg( not( target_arch = "wasm32" ) ) ] pub fn try_new_gles( width: u32, height: u32 ) -> Result { let egl_context = EglOffscreenContext::new()?; @@ -143,7 +147,9 @@ impl UiSurface width, height, ); - Ok( Self::from_canvas_with_egl_context( canvas, Some( egl_context ) ) ) + let mut surface = Self::from_canvas( canvas ); + surface.egl_context = Some( egl_context ); + Ok( surface ) } /// Create a GLES-backed surface from a caller-owned, already-current GL @@ -180,6 +186,7 @@ impl UiSurface /// implicit destructor**: dropping the `UiSurface` releases GPU resources /// (textures, FBOs, programs) through the caller-owned context and will /// leak / corrupt state if that context is not current at drop time. + #[ cfg( not( target_arch = "wasm32" ) ) ] pub unsafe fn from_current_gles_loader( mut loader: F, version: GlesVersion, @@ -206,19 +213,12 @@ impl UiSurface /// the caller-owned context and will leak / corrupt state if that context /// is not current at drop time. pub fn from_canvas( canvas: Canvas ) -> Self - { - Self::from_canvas_with_egl_context( canvas, None ) - } - - fn from_canvas_with_egl_context( - canvas: Canvas, - egl_context: Option, - ) -> Self { Self { canvas, - egl_context, + #[ cfg( not( target_arch = "wasm32" ) ) ] + egl_context: None, focused_idx: None, hovered_idx: None, pressed_idx: None, @@ -473,6 +473,7 @@ impl UiSurface fn make_owned_gles_current( &self ) { + #[ cfg( not( target_arch = "wasm32" ) ) ] if let Some( egl_context ) = &self.egl_context { if let Err( e ) = egl_context.make_current() @@ -483,6 +484,7 @@ impl UiSurface } } +#[ cfg( not( target_arch = "wasm32" ) ) ] fn log_make_current_failure_once( reason: &str ) { use std::sync::Once; diff --git a/src/draw/chrome.rs b/src/draw/chrome.rs index 24d1263..8689706 100644 --- a/src/draw/chrome.rs +++ b/src/draw/chrome.rs @@ -67,6 +67,6 @@ pub( crate ) fn draw_fallback_banner( let size = 12.0; let msg_w = canvas.measure_text( msg, size ); let x = ( ( pw as f32 - msg_w ) / 2.0 ).max( 6.0 * sf ); - let y = ( banner_h - size * sf ) / 2.0; + let y = ( banner_h + size * sf ) / 2.0 - 2.0 * sf; canvas.draw_text( msg, x, y, size, Color::WHITE ); } diff --git a/src/draw/paint.rs b/src/draw/paint.rs index 633cba8..b1fdc16 100644 --- a/src/draw/paint.rs +++ b/src/draw/paint.rs @@ -14,12 +14,12 @@ use super::{ build_draw_ctx, commit_draw_ctx, compute_damage, layout_and_draw, D use super::chrome::{ draw_fallback_banner, draw_titlebar }; /// The canvas in `slot` at `pw × ph`, built by `create` with the theme's fonts and `scale` on first use. -pub( crate ) fn fit_canvas( slot: &mut Option, pw: u32, ph: u32, scale: u32, create: impl FnOnce() -> Canvas ) -> &mut Canvas +pub( crate ) fn fit_canvas( slot: &mut Option, pw: u32, ph: u32, scale: f32, create: impl FnOnce() -> Canvas ) -> &mut Canvas { let canvas = slot.get_or_insert_with( || { let mut c = create(); - c.set_dpi_scale( scale as f32 ); + c.set_dpi_scale( scale ); if let Some( reg ) = crate::theme::build_font_registry() { c.set_font_registry( Arc::new( reg ) ); @@ -29,7 +29,7 @@ pub( crate ) fn fit_canvas( slot: &mut Option, pw: u32, ph: u32, scale: if canvas.size() != ( pw, ph ) { canvas.resize( pw, ph ); - canvas.set_dpi_scale( scale as f32 ); + canvas.set_dpi_scale( scale ); } canvas } @@ -43,7 +43,7 @@ pub( crate ) fn paint_full( precise_damage: bool, pw: u32, ph: u32, - scale: u32, + scale: f32, ) -> Vec { let canvas = ss.canvas.as_mut().expect( "canvas prepared" ); @@ -82,7 +82,7 @@ pub( crate ) fn paint_partial( dirty_rects: Vec, pw: u32, ph: u32, - scale: u32, + scale: f32, ) -> Vec { let canvas = ss.canvas.as_mut().expect( "canvas prepared" ); @@ -105,11 +105,11 @@ fn paint_view( debug_layout: bool, pw: u32, ph: u32, - scale: u32, + scale: f32, ) -> DrawCtx { let canvas = ss.canvas.as_mut().expect( "canvas prepared" ); - let sf = scale as f32; + let sf = scale; let tb_h = ss.titlebar_height * sf; let inset = ss.insets; let osk_h = crate::event_loop::osk::place_osk( &mut ss.osk, pw, ph, sf, inset ); diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index 977a783..98eafec 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -8,6 +8,7 @@ use crate::platform::{ NativePlatform, Platform }; use crate::types::Point; use super::repeat::{ ButtonRepeatState, KeyRepeatState }; +use super::sources::Sources; use super::surface::{ SurfaceFocus, SurfaceState }; use super::tooltip::{ TooltipPending, TooltipVisible }; @@ -50,11 +51,8 @@ pub struct AppData> pub shift_pressed: bool, pub ctrl_pressed: bool, pub alt_pressed: bool, - /// Calloop handle for inserting timers / channels. Used by the - /// key-repeat machinery and any future feature that needs to - /// schedule work on the run loop without going through the message - /// queue. - pub loop_handle: calloop::LoopHandle<'static, Self>, + /// Timers and channels on the backend's run loop. + pub sources: Sources, /// Repeat rate (events per second) advertised by the compositor in /// `wl_keyboard.repeat_info`. `0` means "the compositor disabled /// repeat" — we honour that and never start a repeat timer. @@ -80,7 +78,7 @@ pub struct AppData> /// with [`Self::last_press_pos`] it lets the press handler /// detect a double-click on a TextEdit and turn it into a /// word-select. - pub last_press_time: Option, + pub last_press_time: Option, /// Position of the previous press. See [`Self::last_press_time`]. pub last_press_pos: Option, pub debug_layout: bool, @@ -275,7 +273,7 @@ impl AppData { if let Some( ref mut a ) = self.a11y { - let sf = self.main.scale_factor.max( 1 ) as f64; + let sf = self.main.scale_factor as f64; a.set_window_bounds( ( w as f64 ) * sf, ( h as f64 ) * sf ); a.set_window_focus( true ); } @@ -284,11 +282,11 @@ impl AppData // callbacks (`on_drag_move`, `on_drop`) work in. A configure's size is // logical, so multiply by the current buffer scale before handing the // dimensions to the app. - let sf = self.main.scale_factor.max( 1 ) as u32; - crate::types::set_viewport_size( w * sf, h * sf ); - crate::types::set_density( sf as f32 ); - self.app.on_scale_changed( sf ); - self.app.on_resize( w * sf, h * sf ); + let ( pw, ph ) = ( self.main.physical_width(), self.main.physical_height() ); + crate::types::set_viewport_size( pw, ph ); + crate::types::set_density( self.main.scale_factor ); + self.app.on_scale_changed( self.main.scale_factor.round().max( 1.0 ) as u32 ); + self.app.on_resize( pw, ph ); // `on_resize` may flip app-state that the view depends on (apps that // branch on the new dimensions, layout caches keyed by size, …), so // drop the cached tree to force a rebuild on the next draw. @@ -315,9 +313,9 @@ impl AppData pub( crate ) fn next_long_press_wakeup( &self ) -> Option { let dur = self.app.long_press_duration(); - let now = std::time::Instant::now(); + let now = crate::time::Instant::now(); let mut soonest: Option = None; - let mut consider = |start: Option| + let mut consider = |start: Option| { if let Some( s ) = start { diff --git a/src/event_loop/context_menu.rs b/src/event_loop/context_menu.rs index 040d615..f0e37b5 100644 --- a/src/event_loop/context_menu.rs +++ b/src/event_loop/context_menu.rs @@ -98,8 +98,8 @@ impl AppData } ); let ss = self.surface( focus ); - let sw = ss.width as f32 * ss.scale_factor.max( 1 ) as f32; - let sh = ss.height as f32 * ss.scale_factor.max( 1 ) as f32; + let sw = ss.width as f32 * ss.scale_factor; + let sh = ss.height as f32 * ss.scale_factor; let mut x = pos.x; let mut y = pos.y; // Clamp inside the surface with a small breathing margin. diff --git a/src/event_loop/cursor_shape.rs b/src/event_loop/cursor_shape.rs index d1958c5..4400481 100644 --- a/src/event_loop/cursor_shape.rs +++ b/src/event_loop/cursor_shape.rs @@ -130,7 +130,7 @@ impl AppData { if !matches!( focus, SurfaceFocus::Main ) { return None; } if !self.main_is_toplevel() { return None; } - let sf = self.surface( focus ).scale_factor.max( 1 ) as f32; + let sf = self.surface( focus ).scale_factor; let border = Self::RESIZE_BORDER_LOGICAL * sf; let w = self.surface( focus ).physical_width() as f32; let h = self.surface( focus ).physical_height() as f32; diff --git a/src/event_loop/drag.rs b/src/event_loop/drag.rs index da3a879..77ef259 100644 --- a/src/event_loop/drag.rs +++ b/src/event_loop/drag.rs @@ -17,7 +17,7 @@ impl AppData pub( crate ) fn check_long_press_deadlines( &mut self ) { let dur = self.app.long_press_duration(); - let now = std::time::Instant::now(); + let now = crate::time::Instant::now(); // Returns `(lp_msg_opt, ds_msg_opt, origin, text_idx_opt)`: // - `lp_msg_opt = Some` → user-set on_long_press fires; push msg diff --git a/src/event_loop/error.rs b/src/event_loop/error.rs index cd14f7c..7d8bf7a 100644 --- a/src/event_loop/error.rs +++ b/src/event_loop/error.rs @@ -41,6 +41,9 @@ pub enum RunError /// On Android, `ltk::run` was called before `android_main` handed /// its `AndroidApp` to `ltk::set_android_app`. NoAndroidApp, + /// In a browser, `ltk::run` was called where there is no page: + /// no `window` or `document`, as in a worker. + NoDocument, } impl std::fmt::Display for RunError @@ -59,6 +62,8 @@ impl std::fmt::Display for RunError write!( f, "Wayland protocol `{name}` unavailable: {detail}" ), Self::NoAndroidApp => write!( f, "no AndroidApp: call ltk::set_android_app from android_main before ltk::run" ), + Self::NoDocument => + write!( f, "no window or document: ltk::run runs on a page's main thread" ), } } } diff --git a/src/event_loop/frame.rs b/src/event_loop/frame.rs index c48dc14..c810e84 100644 --- a/src/event_loop/frame.rs +++ b/src/event_loop/frame.rs @@ -44,7 +44,7 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> b Some( SurfaceFocus::Main ), ); data.main.needs_redraw = false; - data.main.last_draw = std::time::Instant::now(); + data.main.last_draw = crate::time::Instant::now(); if !data.first_frame_committed { data.first_frame_committed = true; @@ -60,8 +60,7 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> b for ( _, ss ) in data.secondaries.iter_mut() { if !ss.configured || !ss.needs_redraw || ss.frame_pending { continue; } - let scale = ss.scale_factor.max( 1 ) as u32; - let view = data.app.secondary_view( ss.width * scale, ss.height * scale ) + let view = data.app.secondary_view( ss.physical_width(), ss.physical_height() ) .unwrap_or_else( || crate::spacer().into() ); // No frame callback: these surfaces are static between invalidations, // so there is no per-frame pacing to keep — and a callback that never @@ -69,7 +68,7 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> b draw_surface( platform, ss, &view, main_bg, None, None, debug_layout, None ); ss.needs_redraw = false; ss.frame_pending = false; - ss.last_draw = std::time::Instant::now(); + ss.last_draw = crate::time::Instant::now(); } for spec in overlays @@ -95,7 +94,7 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> b Some( focus ), ); ss.needs_redraw = false; - ss.last_draw = std::time::Instant::now(); + ss.last_draw = crate::time::Instant::now(); data.app.on_overlay_frame_committed( spec.id ); if std::mem::take( &mut ss.frame.blur_changed ) { @@ -124,12 +123,11 @@ fn draw_surface( frame: Option, ) { - let scale = ss.scale_factor.max( 1 ) as u32; + let scale = ss.scale_factor; let w = ss.width; let h = ss.height; if w == 0 || h == 0 { return; } - let pw = w * scale; - let ph = h * scale; + let ( pw, ph ) = ( ss.physical_width(), ss.physical_height() ); let canvas_ready = ss.canvas.as_ref() .map( |c| c.size() == ( pw, ph ) ) diff --git a/src/event_loop/mod.rs b/src/event_loop/mod.rs index 7a59bfb..49605e6 100644 --- a/src/event_loop/mod.rs +++ b/src/event_loop/mod.rs @@ -12,8 +12,10 @@ pub( crate ) mod osk; pub( crate ) mod perf; pub( crate ) mod repeat; pub( crate ) mod setup; +pub( crate ) mod sources; pub( crate ) mod surface; pub( crate ) mod text_editing; +#[ cfg( not( target_arch = "wasm32" ) ) ] pub( crate ) mod text_scale; pub( crate ) mod tooltip; @@ -26,6 +28,7 @@ pub( crate ) mod invalidation; mod tests; pub( crate ) use app_data::AppData; +pub( crate ) use sources::Sources; pub( crate ) use surface::{ FrameState, SurfaceFocus, SurfaceState }; pub use error::RunError; #[ cfg( feature = "test-support" ) ] diff --git a/src/event_loop/osk.rs b/src/event_loop/osk.rs index c41d5cc..d12c6da 100644 --- a/src/event_loop/osk.rs +++ b/src/event_loop/osk.rs @@ -3,7 +3,7 @@ //! The built-in keyboard under a focused text field, for a session whose compositor offers no input method. -use std::time::{ Duration, Instant }; +use std::time::Duration; use super::app_data::AppData; use super::surface::{ Insets, SurfaceFocus }; @@ -12,6 +12,7 @@ use crate::input::event::KeyInput; use crate::osk::{ Event, Modifiers, Panel }; use crate::platform::Platform; use crate::Keysym; +use crate::time::Instant; use crate::tree::find_handlers; use crate::types::{ Point, Rect }; @@ -51,12 +52,11 @@ impl AppData let ss = self.surface_mut( focus ); if ss.osk.is_none() { - let lang = std::env::var( "LANG" ).unwrap_or_default(); - ss.osk = Some( Panel::for_language( &lang ) ); + ss.osk = Some( Panel::for_language( &crate::platform::language() ) ); } if let Some( panel ) = ss.osk.as_mut() { panel.set_purpose( purpose ); } - let sf = ss.scale_factor.max( 1 ) as f32; - let ( pw, ph ) = ( ss.width * ss.scale_factor.max( 1 ) as u32, ss.height * ss.scale_factor.max( 1 ) as u32 ); + let sf = ss.scale_factor; + let ( pw, ph ) = ( ss.physical_width(), ss.physical_height() ); place_osk( &mut ss.osk, pw, ph, sf, ss.insets ); ss.request_redraw(); } diff --git a/src/event_loop/perf.rs b/src/event_loop/perf.rs index 2c70654..67e66d2 100644 --- a/src/event_loop/perf.rs +++ b/src/event_loop/perf.rs @@ -13,7 +13,9 @@ //! [`App::cap_software_animation`](crate::App::cap_software_animation)). use std::sync::OnceLock; -use std::time::{ Duration, Instant }; +use std::time::Duration; + +use crate::time::Instant; /// A settled animation should return `false` well before this; staying `true` /// this long is almost always a forgotten `is_animating = false`. diff --git a/src/event_loop/repeat.rs b/src/event_loop/repeat.rs index 4afc4ff..40b13e0 100644 --- a/src/event_loop/repeat.rs +++ b/src/event_loop/repeat.rs @@ -1,10 +1,10 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use calloop::RegistrationToken; - use crate::input::event::KeyInput; +use super::sources::TimerToken; + /// Snapshot of the key whose repeat timer is currently armed. /// /// Stored on [`super::app_data::AppData::key_repeat`] between press and @@ -17,7 +17,7 @@ use crate::input::event::KeyInput; pub( crate ) struct KeyRepeatState { pub event: KeyInput, - pub token: RegistrationToken, + pub token: TimerToken, } /// Snapshot of the button whose press-and-hold repeat timer is @@ -42,10 +42,9 @@ pub( crate ) struct KeyRepeatState /// wrong widget's message. pub( crate ) struct ButtonRepeatState { - /// calloop registration handle so [`super::app_data::AppData::stop_button_repeat`] - /// can remove the timer from the event loop without leaking the - /// source. The surface focus and pressed `flat_idx` live in - /// the timer's closure capture instead of here — the only + /// Lets [`super::app_data::AppData::stop_button_repeat`] remove the + /// timer from the run loop. The surface focus and pressed `flat_idx` + /// live in the timer's closure capture instead of here — the only /// caller that needs to act on them is the closure itself. - pub token: RegistrationToken, + pub token: TimerToken, } diff --git a/src/event_loop/setup.rs b/src/event_loop/setup.rs index 252d9c8..8f90b74 100644 --- a/src/event_loop/setup.rs +++ b/src/event_loop/setup.rs @@ -5,10 +5,6 @@ use std::collections::HashMap; -use calloop::LoopHandle; -use calloop::channel::{ self, Event }; -use calloop::timer::{ TimeoutAction, Timer }; - use crate::a11y::A11yState; use crate::app::App; use crate::platform::Platform; @@ -16,11 +12,12 @@ use crate::types::Point; use super::{ AppData, RunError, SurfaceFocus, SurfaceState }; use super::perf::PerfState; +use super::sources::Sources; impl AppData { /// `app` on `platform` with `main` as its only surface, nothing focused, pressed or queued. - pub( crate ) fn new( app: A, platform: P, main: SurfaceState, loop_handle: LoopHandle<'static, Self>, builtin_osk: bool, a11y: Option ) -> Self + pub( crate ) fn new( app: A, platform: P, main: SurfaceState, sources: Sources, builtin_osk: bool, a11y: Option ) -> Self { let fullscreen = app.start_fullscreen(); Self @@ -39,7 +36,7 @@ impl AppData shift_pressed: false, ctrl_pressed: false, alt_pressed: false, - loop_handle, + sources, compositor_repeat_rate: 0, compositor_repeat_delay: 0, key_repeat: None, @@ -74,44 +71,39 @@ impl AppData /// Let the app send messages from any thread, follow the desktop's text scale and wake on [`App::poll_interval`]. pub( crate ) fn install_sources( &mut self ) -> Result<(), RunError> { - let handle = self.loop_handle.clone(); - // Only queued: the run loop's `App::invalidate_after` decides which surfaces the message redraws. - let ( sender, messages ) = channel::channel::(); - handle.insert_source( messages, |event, _, data: &mut Self| - { - if let Event::Msg( msg ) = event { data.pending_msgs.push( msg ); } - } ) - .map_err( |e| RunError::EventLoop( format!( "channel insert_source: {e:?}" ) ) )?; + let sender = self.sources.channel( |msg, data: &mut Self| data.pending_msgs.push( msg ) ) + .map_err( |e| RunError::EventLoop( format!( "channel insert_source: {e}" ) ) )?; self.app.set_channel_sender( sender ); // Font resolution multiplies by `text_scale()` at paint time, so a new factor only needs a repaint. - let ( tx, factors ) = channel::channel::(); - handle.insert_source( factors, |event, _, data: &mut Self| + #[ cfg( not( target_arch = "wasm32" ) ) ] { - let Event::Msg( factor ) = event else { return }; - if ( factor - crate::types::text_scale() ).abs() < f32::EPSILON { return; } - crate::types::set_text_scale( factor ); - data.dirty_caches(); - data.main.request_redraw(); - for ss in data.overlays.values_mut() + let tx = self.sources.channel( |factor: f32, data: &mut Self| { - ss.request_redraw(); - } - } ) - .map_err( |e| RunError::EventLoop( format!( "text-scale insert_source: {e:?}" ) ) )?; - super::text_scale::spawn_watcher( tx ); + if ( factor - crate::types::text_scale() ).abs() < f32::EPSILON { return; } + crate::types::set_text_scale( factor ); + data.dirty_caches(); + data.main.request_redraw(); + for ss in data.overlays.values_mut() + { + ss.request_redraw(); + } + } ) + .map_err( |e| RunError::EventLoop( format!( "text-scale insert_source: {e}" ) ) )?; + super::text_scale::spawn_watcher( tx ); + } if let Some( dur ) = self.app.poll_interval() { self.perf.warn_poll_interval( dur ); - handle.insert_source( Timer::from_duration( dur ), |_, _, data: &mut Self| + self.sources.insert_timer( dur, |data: &mut Self| { let msgs = data.app.poll_external(); data.pending_msgs.extend( msgs ); - TimeoutAction::ToDuration( data.app.poll_interval().unwrap_or( std::time::Duration::from_secs( 1 ) ) ) + Some( data.app.poll_interval().unwrap_or( std::time::Duration::from_secs( 1 ) ) ) } ) - .map_err( |e| RunError::EventLoop( format!( "poll timer insert_source: {e:?}" ) ) )?; + .map_err( |e| RunError::EventLoop( format!( "poll timer insert_source: {e}" ) ) )?; } Ok( () ) } diff --git a/src/event_loop/sources.rs b/src/event_loop/sources.rs new file mode 100644 index 0000000..292581a --- /dev/null +++ b/src/event_loop/sources.rs @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The timers and channels the runtime puts on the backend's run loop, held by `AppData::sources`: calloop's natively; in a browser, a queue the web backend runs from its own loop. + +use std::time::Duration; + +#[ cfg( not( target_arch = "wasm32" ) ) ] +use calloop::channel::Event; +#[ cfg( not( target_arch = "wasm32" ) ) ] +use calloop::timer::{ TimeoutAction, Timer }; +#[ cfg( not( target_arch = "wasm32" ) ) ] +use calloop::{ LoopHandle, RegistrationToken }; + +use crate::app::ChannelSender; + +#[ cfg( not( target_arch = "wasm32" ) ) ] +pub( crate ) struct Sources +{ + handle: LoopHandle<'static, D>, +} + +/// Names an armed timer to [`Sources::remove`]. +#[ cfg( not( target_arch = "wasm32" ) ) ] +pub( crate ) struct TimerToken( RegistrationToken ); + +#[ cfg( not( target_arch = "wasm32" ) ) ] +impl Sources +{ + pub( crate ) fn new( handle: LoopHandle<'static, D> ) -> Self + { + Self { handle } + } + + /// Run `tick` after `delay`, and again after every duration it returns, until it returns `None`. + pub( crate ) fn insert_timer( &self, delay: Duration, mut tick: impl FnMut( &mut D ) -> Option + 'static ) -> Result + { + self.handle.insert_source( Timer::from_duration( delay ), move |_, _, data| + { + match tick( data ) + { + Some( next ) => TimeoutAction::ToDuration( next ), + None => TimeoutAction::Drop, + } + } ) + .map( TimerToken ) + .map_err( |e| format!( "{e:?}" ) ) + } + + pub( crate ) fn remove( &self, token: TimerToken ) + { + self.handle.remove( token.0 ); + } + + /// A sender whose messages reach `on_msg` on the run loop. + pub( crate ) fn channel( &self, mut on_msg: impl FnMut( T, &mut D ) + 'static ) -> Result, String> + { + let ( sender, messages ) = calloop::channel::channel::(); + self.handle.insert_source( messages, move |event, _, data| + { + if let Event::Msg( msg ) = event { on_msg( msg, data ); } + } ) + .map_err( |e| format!( "{e:?}" ) )?; + Ok( ChannelSender::from_calloop( sender ) ) + } +} + +#[ cfg( target_arch = "wasm32" ) ] +pub( crate ) type Tick = Box Option>; + +#[ cfg( target_arch = "wasm32" ) ] +pub( crate ) type Drain = Box; + +#[ cfg( target_arch = "wasm32" ) ] +struct Armed +{ + id: u64, + due: crate::time::Instant, + tick: Tick, +} + +/// The backend takes the due timers out with [`Self::take_due`], runs them on its `AppData` and hands them back to [`Self::rearm`]; likewise the channels with [`Self::take_channels`] and [`Self::restore_channels`]. +#[ cfg( target_arch = "wasm32" ) ] +pub( crate ) struct Sources +{ + wake: fn(), + next_id: std::cell::Cell, + armed: std::cell::RefCell>>, + /// Taken out by `take_due` and not handed back yet. + running: std::cell::RefCell>, + /// Removed while running, so `rearm` drops them. + cancelled: std::cell::RefCell>, + channels: std::cell::RefCell>>, +} + +#[ cfg( target_arch = "wasm32" ) ] +pub( crate ) struct TimerToken( u64 ); + +#[ cfg( target_arch = "wasm32" ) ] +impl Sources +{ + /// `wake` asks the backend for an iteration once a message is sent. + pub( crate ) fn new( wake: fn() ) -> Self + { + Self + { + wake, + next_id: std::cell::Cell::new( 0 ), + armed: std::cell::RefCell::new( Vec::new() ), + running: std::cell::RefCell::new( Vec::new() ), + cancelled: std::cell::RefCell::new( Vec::new() ), + channels: std::cell::RefCell::new( Vec::new() ), + } + } + + /// Run `tick` after `delay`, and again after every duration it returns, until it returns `None`. + pub( crate ) fn insert_timer( &self, delay: Duration, tick: impl FnMut( &mut D ) -> Option + 'static ) -> Result + { + let id = self.next_id.get(); + self.next_id.set( id + 1 ); + self.armed.borrow_mut().push( Armed { id, due: crate::time::Instant::now() + delay, tick: Box::new( tick ) } ); + Ok( TimerToken( id ) ) + } + + pub( crate ) fn remove( &self, token: TimerToken ) + { + let mut armed = self.armed.borrow_mut(); + if let Some( i ) = armed.iter().position( |t| t.id == token.0 ) + { + armed.remove( i ); + } else if self.running.borrow().contains( &token.0 ) { + self.cancelled.borrow_mut().push( token.0 ); + } + } + + /// A sender whose messages reach `on_msg` when the backend drains the channels. + pub( crate ) fn channel( &self, mut on_msg: impl FnMut( T, &mut D ) + 'static ) -> Result, String> + { + let ( sender, messages ) = crate::channel::channel::( self.wake ); + self.channels.borrow_mut().push( Box::new( move |data| + { + while let Ok( msg ) = messages.try_recv() { on_msg( msg, data ); } + } ) ); + Ok( sender ) + } + + /// The timers due by `now`, with their ids. + pub( crate ) fn take_due( &self, now: crate::time::Instant ) -> Vec<( u64, Tick )> + { + let ( due, later ): ( Vec<_>, Vec<_> ) = std::mem::take( &mut *self.armed.borrow_mut() ).into_iter().partition( |t| t.due <= now ); + self.armed.borrow_mut().extend( later ); + self.running.borrow_mut().extend( due.iter().map( |t| t.id ) ); + due.into_iter().map( |t| ( t.id, t.tick ) ).collect() + } + + /// Arm a timer [`Self::take_due`] gave out again after `next`, unless it returned `None` or was removed meanwhile. + pub( crate ) fn rearm( &self, id: u64, tick: Tick, next: Option ) + { + self.running.borrow_mut().retain( |r| *r != id ); + let mut cancelled = self.cancelled.borrow_mut(); + if let Some( i ) = cancelled.iter().position( |c| *c == id ) + { + cancelled.remove( i ); + return; + } + if let Some( next ) = next + { + self.armed.borrow_mut().push( Armed { id, due: crate::time::Instant::now() + next, tick } ); + } + } + + /// When the next timer is due. + pub( crate ) fn next_due( &self ) -> Option + { + self.armed.borrow().iter().map( |t| t.due ).min() + } + + pub( crate ) fn take_channels( &self ) -> Vec> + { + std::mem::take( &mut *self.channels.borrow_mut() ) + } + + /// Hand back what [`Self::take_channels`] took, keeping any channel created meanwhile. + pub( crate ) fn restore_channels( &self, mut drains: Vec> ) + { + let mut channels = self.channels.borrow_mut(); + drains.append( &mut channels ); + *channels = drains; + } +} diff --git a/src/event_loop/surface.rs b/src/event_loop/surface.rs index e743885..8ebb69d 100644 --- a/src/event_loop/surface.rs +++ b/src/event_loop/surface.rs @@ -134,7 +134,7 @@ pub( crate ) struct SurfaceState /// `false` so the draw path can take the partial-redraw fast path. /// Always reset to `false` at the end of a successful draw. pub content_dirty: bool, - pub last_draw: std::time::Instant, + pub last_draw: crate::time::Instant, pub focused_idx: Option, pub focused_id: Option, pub hovered_idx: Option, @@ -174,9 +174,10 @@ pub( crate ) struct SurfaceState pub titlebar_title: String, /// Rect of the close button in the title bar (for hit testing). pub titlebar_close_rect: Rect, - /// Compositor scale factor (integer, e.g. 1 or 2). Used to create - /// high-DPI buffers and set `dpi_scale` on the canvas. - pub scale_factor: i32, + /// Physical pixels per logical pixel, set by the backend: whole on + /// Wayland and Android, the browser's `devicePixelRatio` on the web. + /// Used to size the buffers and set `dpi_scale` on the canvas. + pub scale_factor: f32, pub layer_anchor: Option, /// `true` after the surface has been committed and we are waiting for the /// matching `wl_surface.frame` callback to fire. Drawing is gated on this @@ -203,7 +204,7 @@ impl SurfaceState configured: false, needs_redraw: false, content_dirty: true, - last_draw: std::time::Instant::now() - std::time::Duration::from_millis( 100 ), + last_draw: crate::time::Instant::now().checked_sub( std::time::Duration::from_millis( 100 ) ).unwrap_or_else( crate::time::Instant::now ), focused_idx: None, focused_id: None, hovered_idx: None, @@ -218,7 +219,7 @@ impl SurfaceState insets: Insets::default(), titlebar_title, titlebar_close_rect: Rect::default(), - scale_factor: 1, + scale_factor: 1.0, layer_anchor: None, frame_pending: false, frame_tick_due: false, @@ -228,23 +229,29 @@ impl SurfaceState /// Convert a logical (surface-local) position to physical pixel coordinates. pub( crate ) fn to_physical( &self, x: f64, y: f64 ) -> Point { - let sf = self.scale_factor.max( 1 ) as f32; + let sf = self.scale_factor; Point { x: x as f32 * sf, y: y as f32 * sf } } - /// Surface width in physical pixels (logical × buffer scale). Matches the + /// Surface width in physical pixels (logical × scale, rounded). Matches the /// coordinate space used by pointer/touch callbacks and layout, so swipe /// thresholds can be compared against physical `dx` / `start.x` without /// unit mixing. pub( crate ) fn physical_width( &self ) -> u32 { - self.width * self.scale_factor.max( 1 ) as u32 + ( self.width as f32 * self.scale_factor ).round() as u32 } /// Surface height in physical pixels. See [`Self::physical_width`]. pub( crate ) fn physical_height( &self ) -> u32 { - self.height * self.scale_factor.max( 1 ) as u32 + ( self.height as f32 * self.scale_factor ).round() as u32 + } + + /// The scale as a whole number, for protocols that take no other: Wayland's buffer scale. + pub( crate ) fn buffer_scale( &self ) -> i32 + { + ( self.scale_factor.round() as i32 ).max( 1 ) } /// Request a redraw and mark the surface's content as dirty. diff --git a/src/event_loop/tests.rs b/src/event_loop/tests.rs index c705b40..06021f3 100644 --- a/src/event_loop/tests.rs +++ b/src/event_loop/tests.rs @@ -15,7 +15,7 @@ use crate::types::Rect; use crate::widget::WidgetHandlers; use crate::{ button, column, text_edit, Element, Keysym }; -use super::{ AppData, SurfaceFocus, SurfaceState }; +use super::{ AppData, Sources, SurfaceFocus, SurfaceState }; use super::surface::Insets; const WIDTH: u32 = 400; @@ -79,7 +79,7 @@ impl Harness let globals = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() ); let event_loop: EventLoop<'static, AppData> = EventLoop::try_new().expect( "event loop" ); let main = SurfaceState::new( (), 0.0, String::new() ); - let mut data = AppData::new( app, MockPlatform::default(), main, event_loop.handle(), false, None ); + let mut data = AppData::new( app, MockPlatform::default(), main, Sources::new( event_loop.handle() ), false, None ); data.main.set_size( WIDTH, HEIGHT ); data.on_main_resized( WIDTH, HEIGHT ); Self { data, _event_loop: event_loop, _globals: globals } diff --git a/src/event_loop/text_editing/scroll.rs b/src/event_loop/text_editing/scroll.rs index 4bb0002..e4b253b 100644 --- a/src/event_loop/text_editing/scroll.rs +++ b/src/event_loop/text_editing/scroll.rs @@ -33,7 +33,7 @@ impl AppData { notches * WHEEL_LINES * window.metrics.line_h } else { - logical_px * ss.scale_factor.max( 1 ) as f32 + logical_px * ss.scale_factor }; if !window.can_scroll( delta_px ) { return false; } let ( next, moved ) = scroll.scrolled( window.first_line, window.max_first, cursor_pos, delta_px, window.metrics.line_h ); diff --git a/src/event_loop/text_editing/selection.rs b/src/event_loop/text_editing/selection.rs index e70bfe5..8590919 100644 --- a/src/event_loop/text_editing/selection.rs +++ b/src/event_loop/text_editing/selection.rs @@ -50,7 +50,7 @@ impl AppData { const WINDOW_MS: u128 = 400; const RADIUS: f32 = 6.0; - let now = std::time::Instant::now(); + let now = crate::time::Instant::now(); let is_double = match ( self.last_press_time, self.last_press_pos ) { ( Some( t ), Some( p ) ) => diff --git a/src/event_loop/text_scale.rs b/src/event_loop/text_scale.rs index 328e8f8..38d0334 100644 --- a/src/event_loop/text_scale.rs +++ b/src/event_loop/text_scale.rs @@ -36,7 +36,7 @@ fn unblock_signals( cmd: &mut Command ) -> &mut Command /// Spawn the watcher thread. `tx` delivers each observed factor to the /// run loop; the initial `gsettings get` value is sent first. -pub( super ) fn spawn_watcher( tx: calloop::channel::Sender ) +pub( super ) fn spawn_watcher( tx: crate::app::ChannelSender ) { let _ = std::thread::Builder::new() .name( "ltk-text-scale".into() ) diff --git a/src/event_loop/tooltip.rs b/src/event_loop/tooltip.rs index 20a61c1..695382c 100644 --- a/src/event_loop/tooltip.rs +++ b/src/event_loop/tooltip.rs @@ -14,7 +14,7 @@ pub struct TooltipPending { pub focus: SurfaceFocus, pub flat_idx: usize, - pub deadline: std::time::Instant, + pub deadline: crate::time::Instant, pub text: String, pub anchor: Rect, } @@ -47,7 +47,7 @@ impl AppData { focus, flat_idx, - deadline: std::time::Instant::now() + TOOLTIP_DELAY, + deadline: crate::time::Instant::now() + TOOLTIP_DELAY, text, anchor, } ); @@ -68,13 +68,13 @@ impl AppData pub( crate ) fn next_tooltip_wakeup( &self ) -> Option { let p = self.tooltip_pending.as_ref()?; - Some( p.deadline.saturating_duration_since( std::time::Instant::now() ) ) + Some( p.deadline.saturating_duration_since( crate::time::Instant::now() ) ) } pub( crate ) fn check_tooltip_deadline( &mut self ) { let Some( p ) = self.tooltip_pending.as_ref() else { return }; - if std::time::Instant::now() < p.deadline { return; } + if crate::time::Instant::now() < p.deadline { return; } let p = self.tooltip_pending.take().unwrap(); self.tooltip_visible = Some( TooltipVisible { @@ -106,11 +106,10 @@ impl AppData // the surface exists; before that, main's is the best predictor. let sf = self.overlays.get( &id ) .map( |ss| ss.scale_factor ) - .unwrap_or( self.main.scale_factor ) - .max( 1 ) as f32; + .unwrap_or( self.main.scale_factor ); let ( ox, oy ) = self.surface_offset_for( v.focus ); - let src_scale = self.try_surface( v.focus )?.scale_factor.max( 1 ) as f32; + let src_scale = self.try_surface( v.focus )?.scale_factor; let anchor_x = ( ox + v.anchor.x / src_scale ) * sf; let anchor_y = ( oy + v.anchor.y / src_scale ) * sf; let anchor_w = v.anchor.width / src_scale * sf; diff --git a/src/gles_render/framebuffer.rs b/src/gles_render/framebuffer.rs index 3af1569..2bd7e4d 100644 --- a/src/gles_render/framebuffer.rs +++ b/src/gles_render/framebuffer.rs @@ -12,9 +12,11 @@ use glow::HasContext; -use crate::types::Rect; +use crate::types::{ CompositeMode, Rect }; -use super::helpers::{ alloc_fbo_tex, native_framebuffer_id, native_texture_id, ortho_rect }; +use super::helpers::{ alloc_fbo_tex, ortho_rect }; +#[ cfg( not( target_arch = "wasm32" ) ) ] +use super::helpers::{ native_framebuffer_id, native_texture_id }; use super::raii::{ FboBinding, ProgramBinding }; use super::{ BorrowedGlesTexture, GlesCanvas }; @@ -117,6 +119,32 @@ impl GlesCanvas } } } + self.apply_composite(); + } + + /// Set the global blend function to this canvas's [`CompositeMode`] over premultiplied colour. + pub( super ) fn apply_composite( &self ) + { + let ( src, dst ) = match self.composite + { + CompositeMode::SrcOver => ( glow::ONE, glow::ONE_MINUS_SRC_ALPHA ), + CompositeMode::Clear => ( glow::ZERO, glow::ZERO ), + CompositeMode::Src => ( glow::ONE, glow::ZERO ), + CompositeMode::Dst => ( glow::ZERO, glow::ONE ), + CompositeMode::DstOver => ( glow::ONE_MINUS_DST_ALPHA, glow::ONE ), + CompositeMode::SrcIn => ( glow::DST_ALPHA, glow::ZERO ), + CompositeMode::DstIn => ( glow::ZERO, glow::SRC_ALPHA ), + CompositeMode::SrcOut => ( glow::ONE_MINUS_DST_ALPHA, glow::ZERO ), + CompositeMode::DstOut => ( glow::ZERO, glow::ONE_MINUS_SRC_ALPHA ), + CompositeMode::SrcAtop => ( glow::DST_ALPHA, glow::ONE_MINUS_SRC_ALPHA ), + CompositeMode::DstAtop => ( glow::ONE_MINUS_DST_ALPHA, glow::SRC_ALPHA ), + CompositeMode::Xor => ( glow::ONE_MINUS_DST_ALPHA, glow::ONE_MINUS_SRC_ALPHA ), + CompositeMode::Plus => ( glow::ONE, glow::ONE ), + CompositeMode::Multiply => ( glow::DST_COLOR, glow::ZERO ), + CompositeMode::Screen => ( glow::ONE, glow::ONE_MINUS_SRC_COLOR ), + }; + // SAFETY: plain blend-state change on the current context. + unsafe { self.gl.blend_func( src, dst ); } } /// Return a borrowed descriptor for the FBO color texture @@ -133,7 +161,9 @@ impl GlesCanvas { BorrowedGlesTexture { + #[ cfg( not( target_arch = "wasm32" ) ) ] texture_id: native_texture_id( self.fbo_tex ), + #[ cfg( not( target_arch = "wasm32" ) ) ] framebuffer_id: native_framebuffer_id( self.fbo ), texture: self.fbo_tex, framebuffer: self.fbo, diff --git a/src/gles_render/helpers.rs b/src/gles_render/helpers.rs index 43edd63..c632082 100644 --- a/src/gles_render/helpers.rs +++ b/src/gles_render/helpers.rs @@ -208,11 +208,13 @@ pub( super ) fn bytemuck_cast_slice( floats: &[f32] ) -> &[u8] } } +#[ cfg( not( target_arch = "wasm32" ) ) ] pub( super ) fn native_texture_id( texture: glow::Texture ) -> u32 { texture.0.get() } +#[ cfg( not( target_arch = "wasm32" ) ) ] pub( super ) fn native_framebuffer_id( framebuffer: glow::Framebuffer ) -> u32 { framebuffer.0.get() diff --git a/src/gles_render/mod.rs b/src/gles_render/mod.rs index 60b6443..65b16b3 100644 --- a/src/gles_render/mod.rs +++ b/src/gles_render/mod.rs @@ -53,7 +53,7 @@ use crate::font_face::FontFace; use glow::HasContext; use crate::theme::FontRegistry; -use crate::types::Rect; +use crate::types::{ CompositeMode, Rect }; pub( crate ) mod shaders; pub( crate ) mod helpers; @@ -89,8 +89,10 @@ pub enum GlesVersion pub struct BorrowedGlesTexture { /// Native GL texture name for the canvas color attachment. + #[ cfg( not( target_arch = "wasm32" ) ) ] pub texture_id: u32, /// Native GL framebuffer name that owns `texture_id` as color attachment 0. + #[ cfg( not( target_arch = "wasm32" ) ) ] pub framebuffer_id: u32, /// Glow texture handle for callers already using glow. pub texture: glow::Texture, @@ -147,6 +149,8 @@ pub struct GlesCanvas /// sub-canvases. pub( crate ) density: Option, pub global_alpha: f32, + /// Blend applied by every draw on this canvas; not inherited by sub-canvases. + pub( crate ) composite: CompositeMode, pub width: u32, pub height: u32, diff --git a/src/gles_render/setup.rs b/src/gles_render/setup.rs index 182f27b..036f890 100644 --- a/src/gles_render/setup.rs +++ b/src/gles_render/setup.rs @@ -18,6 +18,7 @@ use crate::font_face::{ FontFace, LineMetrics, Metrics }; use glow::HasContext; use crate::theme::{ FontRegistry, FontStyle }; +use crate::types::CompositeMode; use super::helpers::{ alloc_fbo_tex, bytemuck_cast_slice, compile_program, compile_program_with_attribs }; use super::shaders:: @@ -308,6 +309,7 @@ impl GlesCanvas layout_viewport: None, density: None, global_alpha: 1.0, + composite: CompositeMode::SrcOver, width, height, rect_program, @@ -452,6 +454,8 @@ impl GlesCanvas /// The returned canvas inherits the parent's `dpi_scale` and `global_alpha` /// (so glyphs render at the same pixel size), and shares the parent's glyph /// atlas and image cache. + // `glow::UniformLocation` is `Copy` natively but only `Clone` on wasm. + #[ allow( clippy::clone_on_copy ) ] pub fn sub_canvas( &self, width: u32, height: u32 ) -> GlesCanvas { let gl = Arc::clone( &self.gl ); @@ -490,6 +494,7 @@ impl GlesCanvas ( self.width as f32, self.height as f32 ) ) ), density: self.density, global_alpha: self.global_alpha, + composite: CompositeMode::SrcOver, width, height, rect_program: self.rect_program, @@ -508,102 +513,102 @@ impl GlesCanvas backdrop_fast_composite_program: self.backdrop_fast_composite_program, quad_vao: self.quad_vao, _quad_vbo: self._quad_vbo, - u_rect_mvp: self.u_rect_mvp, - u_rect_color: self.u_rect_color, - u_rect_size: self.u_rect_size, - u_rect_radii: self.u_rect_radii, - u_rect_stroke: self.u_rect_stroke, - u_rect_pad: self.u_rect_pad, - u_tex_mvp: self.u_tex_mvp, - u_tex_opacity: self.u_tex_opacity, - u_tex_sampler: self.u_tex_sampler, - u_glyph_mvp: self.u_glyph_mvp, - u_glyph_color: self.u_glyph_color, - u_glyph_opacity: self.u_glyph_opacity, - u_glyph_sampler: self.u_glyph_sampler, - u_glyph_uv_offset: self.u_glyph_uv_offset, - u_glyph_uv_scale: self.u_glyph_uv_scale, + u_rect_mvp: self.u_rect_mvp.clone(), + u_rect_color: self.u_rect_color.clone(), + u_rect_size: self.u_rect_size.clone(), + u_rect_radii: self.u_rect_radii.clone(), + u_rect_stroke: self.u_rect_stroke.clone(), + u_rect_pad: self.u_rect_pad.clone(), + u_tex_mvp: self.u_tex_mvp.clone(), + u_tex_opacity: self.u_tex_opacity.clone(), + u_tex_sampler: self.u_tex_sampler.clone(), + u_glyph_mvp: self.u_glyph_mvp.clone(), + u_glyph_color: self.u_glyph_color.clone(), + u_glyph_opacity: self.u_glyph_opacity.clone(), + u_glyph_sampler: self.u_glyph_sampler.clone(), + u_glyph_uv_offset: self.u_glyph_uv_offset.clone(), + u_glyph_uv_scale: self.u_glyph_uv_scale.clone(), glyph_batch_program: self.glyph_batch_program, - u_glyph_batch_color: self.u_glyph_batch_color, - u_glyph_batch_opacity: self.u_glyph_batch_opacity, - u_glyph_batch_sampler: self.u_glyph_batch_sampler, + u_glyph_batch_color: self.u_glyph_batch_color.clone(), + u_glyph_batch_opacity: self.u_glyph_batch_opacity.clone(), + u_glyph_batch_sampler: self.u_glyph_batch_sampler.clone(), glyph_batch_vao: self.glyph_batch_vao, glyph_batch_vbo: self.glyph_batch_vbo, - u_blit_sampler: self.u_blit_sampler, - u_subblit_mvp: self.u_subblit_mvp, - u_subblit_sampler: self.u_subblit_sampler, - u_subblit_opacity: self.u_subblit_opacity, - u_subblit_fade_bottom: self.u_subblit_fade_bottom, - u_subblit_height_px: self.u_subblit_height_px, - u_lingrad_mvp: self.u_lingrad_mvp, - u_lingrad_lut: self.u_lingrad_lut, - u_lingrad_dir: self.u_lingrad_dir, - u_lingrad_size: self.u_lingrad_size, - u_lingrad_line_length: self.u_lingrad_line_length, - u_lingrad_radii: self.u_lingrad_radii, - u_lingrad_pad: self.u_lingrad_pad, - u_lingrad_lut_domain_min: self.u_lingrad_lut_domain_min, - u_lingrad_lut_domain_span: self.u_lingrad_lut_domain_span, - u_radgrad_mvp: self.u_radgrad_mvp, - u_radgrad_lut: self.u_radgrad_lut, - u_radgrad_center: self.u_radgrad_center, - u_radgrad_radius_frac: self.u_radgrad_radius_frac, - u_radgrad_size: self.u_radgrad_size, - u_radgrad_radii: self.u_radgrad_radii, - u_radgrad_pad: self.u_radgrad_pad, - u_radgrad_lut_domain_min: self.u_radgrad_lut_domain_min, - u_radgrad_lut_domain_span: self.u_radgrad_lut_domain_span, - u_shadow_mvp: self.u_shadow_mvp, - u_shadow_size: self.u_shadow_size, - u_shadow_padding: self.u_shadow_padding, - u_shadow_radii: self.u_shadow_radii, - u_shadow_spread: self.u_shadow_spread, - u_shadow_sigma: self.u_shadow_sigma, - u_shadow_color: self.u_shadow_color, - u_inset_mvp: self.u_inset_mvp, - u_inset_size: self.u_inset_size, - u_inset_padding: self.u_inset_padding, - u_inset_radii: self.u_inset_radii, - u_inset_spread: self.u_inset_spread, - u_inset_sigma: self.u_inset_sigma, - u_inset_offset: self.u_inset_offset, - u_inset_color: self.u_inset_color, - u_inset_ov_mvp: self.u_inset_ov_mvp, - u_inset_ov_size: self.u_inset_ov_size, - u_inset_ov_padding: self.u_inset_ov_padding, - u_inset_ov_radii: self.u_inset_ov_radii, - u_inset_ov_spread: self.u_inset_ov_spread, - u_inset_ov_sigma: self.u_inset_ov_sigma, - u_inset_ov_offset: self.u_inset_ov_offset, - u_inset_ov_color: self.u_inset_ov_color, - u_inset_ov_snapshot: self.u_inset_ov_snapshot, - u_inset_ov_canvas_size: self.u_inset_ov_canvas_size, - u_bd_h_source: self.u_bd_h_source, - u_bd_h_texel: self.u_bd_h_texel, - u_bd_h_canvas_size: self.u_bd_h_canvas_size, - u_bd_h_sigma: self.u_bd_h_sigma, - u_bd_c_mvp: self.u_bd_c_mvp, - u_bd_c_source: self.u_bd_c_source, - u_bd_c_canvas_size: self.u_bd_c_canvas_size, - u_bd_c_texel: self.u_bd_c_texel, - u_bd_c_sigma: self.u_bd_c_sigma, - u_bd_c_size: self.u_bd_c_size, - u_bd_c_padding: self.u_bd_c_padding, - u_bd_c_radii: self.u_bd_c_radii, - u_bd_c_tint: self.u_bd_c_tint, - u_bd_fh_source: self.u_bd_fh_source, - u_bd_fh_texel: self.u_bd_fh_texel, - u_bd_fh_canvas_size: self.u_bd_fh_canvas_size, - u_bd_fh_sigma: self.u_bd_fh_sigma, - u_bd_fc_mvp: self.u_bd_fc_mvp, - u_bd_fc_source: self.u_bd_fc_source, - u_bd_fc_canvas_size: self.u_bd_fc_canvas_size, - u_bd_fc_texel: self.u_bd_fc_texel, - u_bd_fc_sigma: self.u_bd_fc_sigma, - u_bd_fc_size: self.u_bd_fc_size, - u_bd_fc_padding: self.u_bd_fc_padding, - u_bd_fc_radii: self.u_bd_fc_radii, - u_bd_fc_tint: self.u_bd_fc_tint, + u_blit_sampler: self.u_blit_sampler.clone(), + u_subblit_mvp: self.u_subblit_mvp.clone(), + u_subblit_sampler: self.u_subblit_sampler.clone(), + u_subblit_opacity: self.u_subblit_opacity.clone(), + u_subblit_fade_bottom: self.u_subblit_fade_bottom.clone(), + u_subblit_height_px: self.u_subblit_height_px.clone(), + u_lingrad_mvp: self.u_lingrad_mvp.clone(), + u_lingrad_lut: self.u_lingrad_lut.clone(), + u_lingrad_dir: self.u_lingrad_dir.clone(), + u_lingrad_size: self.u_lingrad_size.clone(), + u_lingrad_line_length: self.u_lingrad_line_length.clone(), + u_lingrad_radii: self.u_lingrad_radii.clone(), + u_lingrad_pad: self.u_lingrad_pad.clone(), + u_lingrad_lut_domain_min: self.u_lingrad_lut_domain_min.clone(), + u_lingrad_lut_domain_span: self.u_lingrad_lut_domain_span.clone(), + u_radgrad_mvp: self.u_radgrad_mvp.clone(), + u_radgrad_lut: self.u_radgrad_lut.clone(), + u_radgrad_center: self.u_radgrad_center.clone(), + u_radgrad_radius_frac: self.u_radgrad_radius_frac.clone(), + u_radgrad_size: self.u_radgrad_size.clone(), + u_radgrad_radii: self.u_radgrad_radii.clone(), + u_radgrad_pad: self.u_radgrad_pad.clone(), + u_radgrad_lut_domain_min: self.u_radgrad_lut_domain_min.clone(), + u_radgrad_lut_domain_span: self.u_radgrad_lut_domain_span.clone(), + u_shadow_mvp: self.u_shadow_mvp.clone(), + u_shadow_size: self.u_shadow_size.clone(), + u_shadow_padding: self.u_shadow_padding.clone(), + u_shadow_radii: self.u_shadow_radii.clone(), + u_shadow_spread: self.u_shadow_spread.clone(), + u_shadow_sigma: self.u_shadow_sigma.clone(), + u_shadow_color: self.u_shadow_color.clone(), + u_inset_mvp: self.u_inset_mvp.clone(), + u_inset_size: self.u_inset_size.clone(), + u_inset_padding: self.u_inset_padding.clone(), + u_inset_radii: self.u_inset_radii.clone(), + u_inset_spread: self.u_inset_spread.clone(), + u_inset_sigma: self.u_inset_sigma.clone(), + u_inset_offset: self.u_inset_offset.clone(), + u_inset_color: self.u_inset_color.clone(), + u_inset_ov_mvp: self.u_inset_ov_mvp.clone(), + u_inset_ov_size: self.u_inset_ov_size.clone(), + u_inset_ov_padding: self.u_inset_ov_padding.clone(), + u_inset_ov_radii: self.u_inset_ov_radii.clone(), + u_inset_ov_spread: self.u_inset_ov_spread.clone(), + u_inset_ov_sigma: self.u_inset_ov_sigma.clone(), + u_inset_ov_offset: self.u_inset_ov_offset.clone(), + u_inset_ov_color: self.u_inset_ov_color.clone(), + u_inset_ov_snapshot: self.u_inset_ov_snapshot.clone(), + u_inset_ov_canvas_size: self.u_inset_ov_canvas_size.clone(), + u_bd_h_source: self.u_bd_h_source.clone(), + u_bd_h_texel: self.u_bd_h_texel.clone(), + u_bd_h_canvas_size: self.u_bd_h_canvas_size.clone(), + u_bd_h_sigma: self.u_bd_h_sigma.clone(), + u_bd_c_mvp: self.u_bd_c_mvp.clone(), + u_bd_c_source: self.u_bd_c_source.clone(), + u_bd_c_canvas_size: self.u_bd_c_canvas_size.clone(), + u_bd_c_texel: self.u_bd_c_texel.clone(), + u_bd_c_sigma: self.u_bd_c_sigma.clone(), + u_bd_c_size: self.u_bd_c_size.clone(), + u_bd_c_padding: self.u_bd_c_padding.clone(), + u_bd_c_radii: self.u_bd_c_radii.clone(), + u_bd_c_tint: self.u_bd_c_tint.clone(), + u_bd_fh_source: self.u_bd_fh_source.clone(), + u_bd_fh_texel: self.u_bd_fh_texel.clone(), + u_bd_fh_canvas_size: self.u_bd_fh_canvas_size.clone(), + u_bd_fh_sigma: self.u_bd_fh_sigma.clone(), + u_bd_fc_mvp: self.u_bd_fc_mvp.clone(), + u_bd_fc_source: self.u_bd_fc_source.clone(), + u_bd_fc_canvas_size: self.u_bd_fc_canvas_size.clone(), + u_bd_fc_texel: self.u_bd_fc_texel.clone(), + u_bd_fc_sigma: self.u_bd_fc_sigma.clone(), + u_bd_fc_size: self.u_bd_fc_size.clone(), + u_bd_fc_padding: self.u_bd_fc_padding.clone(), + u_bd_fc_radii: self.u_bd_fc_radii.clone(), + u_bd_fc_tint: self.u_bd_fc_tint.clone(), glyph_atlas: std::rc::Rc::clone( &self.glyph_atlas ), image_cache: std::rc::Rc::clone( &self.image_cache ), gradient_lut_cache: HashMap::new(), @@ -613,11 +618,11 @@ impl GlesCanvas clip_mask_tex: None, clip_bbox: crate::types::Rect::default(), clip_composite_program: self.clip_composite_program, - u_clip_mvp: self.u_clip_mvp, - u_clip_layer: self.u_clip_layer, - u_clip_mask: self.u_clip_mask, - u_clip_canvas: self.u_clip_canvas, - u_clip_bbox: self.u_clip_bbox, + u_clip_mvp: self.u_clip_mvp.clone(), + u_clip_layer: self.u_clip_layer.clone(), + u_clip_mask: self.u_clip_mask.clone(), + u_clip_canvas: self.u_clip_canvas.clone(), + u_clip_bbox: self.u_clip_bbox.clone(), fbo, fbo_tex, aux_a: None, @@ -658,6 +663,9 @@ impl GlesCanvas /// Set the global alpha multiplier applied to every draw. pub fn set_global_alpha( &mut self, a: f32 ) { self.global_alpha = a; } + /// How the following draws, blits included, combine with the canvas. + pub fn set_composite_mode( &mut self, mode: CompositeMode ) { self.composite = mode; } + /// The canvas default font, used when no specific face is resolved. pub fn font( &self ) -> &FontFace { &self.font } diff --git a/src/input/gesture/mod.rs b/src/input/gesture/mod.rs index 713da9f..643be08 100644 --- a/src/input/gesture/mod.rs +++ b/src/input/gesture/mod.rs @@ -32,8 +32,8 @@ //! and reacts to the flip. use std::collections::HashMap; -use std::time::Instant; +use crate::time::Instant; use crate::tree::{ find_handlers, find_widget, find_widget_at }; use crate::types::{ Point, Rect }; use crate::widget::LaidOutWidget; diff --git a/src/input/pointer/helpers.rs b/src/input/pointer/helpers.rs index f71e164..657e161 100644 --- a/src/input/pointer/helpers.rs +++ b/src/input/pointer/helpers.rs @@ -28,7 +28,7 @@ impl AppData overscroll_thresh: self.app.overscroll_dismiss_threshold(), surface_width: ss.physical_width(), surface_height: ss.physical_height(), - scale: ss.scale_factor.max( 1 ) as f32, + scale: ss.scale_factor, } } } diff --git a/src/input/pointer/press.rs b/src/input/pointer/press.rs index 5ce9d11..c9c2c58 100644 --- a/src/input/pointer/press.rs +++ b/src/input/pointer/press.rs @@ -98,7 +98,7 @@ impl AppData // Client-side title bar interaction — pointer-only // (touch never hits a titlebar; layer-shell surfaces // have titlebar_height == 0). - let sf = self.surface( focus ).scale_factor.max( 1 ) as f32; + let sf = self.surface( focus ).scale_factor; let tb_h = self.surface( focus ).titlebar_height * sf; if tb_h > 0.0 && pos.y < tb_h { diff --git a/src/input/repeat/button.rs b/src/input/repeat/button.rs index d8908bf..edc29db 100644 --- a/src/input/repeat/button.rs +++ b/src/input/repeat/button.rs @@ -1,12 +1,10 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use calloop::timer::{ Timer, TimeoutAction }; -use crate::platform::Platform; - use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus }; use crate::event_loop::repeat::ButtonRepeatState; +use crate::platform::Platform; impl AppData { @@ -48,8 +46,7 @@ impl AppData let interval = std::time::Duration::from_millis( 120 ); let delay = self.effective_repeat_delay(); - let timer = Timer::from_duration( delay ); - let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData| + let token = self.sources.insert_timer( delay, move |data: &mut AppData| { let live_msg = crate::tree::find_handlers( &data.surface( focus ).frame.widget_rects, @@ -61,7 +58,7 @@ impl AppData Some( m ) => { data.pending_msgs.push( m ); - TimeoutAction::ToDuration( interval ) + Some( interval ) } None => { @@ -69,7 +66,7 @@ impl AppData // has an on_press → drop ourselves so the timer // does not fire forever against a stale slot. data.button_repeat = None; - TimeoutAction::Drop + None } } } ); @@ -84,7 +81,7 @@ impl AppData { if let Some( state ) = self.button_repeat.take() { - self.loop_handle.remove( state.token ); + self.sources.remove( state.token ); } } } diff --git a/src/input/repeat/key.rs b/src/input/repeat/key.rs index 234b2a6..ef26ddb 100644 --- a/src/input/repeat/key.rs +++ b/src/input/repeat/key.rs @@ -3,8 +3,6 @@ use std::time::Duration; -use calloop::timer::{ Timer, TimeoutAction }; - use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus }; use crate::event_loop::repeat::KeyRepeatState; @@ -65,7 +63,7 @@ impl AppData /// Schedule a key-repeat timer for the given event. No-op when the /// app's [`crate::App::key_repeats`] gate returns `false` for this - /// keysym, when repeat is disabled, or when the calloop timer + /// keysym, when repeat is disabled, or when the timer /// insertion fails (in which case held keys simply do not repeat). pub( crate ) fn start_key_repeat( &mut self, focus: SurfaceFocus, event: KeyInput ) { @@ -78,11 +76,10 @@ impl AppData let delay = self.effective_repeat_delay(); let event_for_timer = event.clone(); - let timer = Timer::from_duration( delay ); - let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData| + let token = self.sources.insert_timer( delay, move |data: &mut AppData| { data.dispatch_key( focus, event_for_timer.clone() ); - TimeoutAction::ToDuration( interval ) + Some( interval ) } ); if let Ok( token ) = token { self.key_repeat = Some( KeyRepeatState { event, token } ); } } @@ -92,7 +89,7 @@ impl AppData { if let Some( state ) = self.key_repeat.take() { - self.loop_handle.remove( state.token ); + self.sources.remove( state.token ); } } } diff --git a/src/lib.rs b/src/lib.rs index 1caf9f6..09e46c4 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -299,6 +299,7 @@ pub( crate ) mod a11y; pub( crate ) mod widget; pub( crate ) mod layout; pub( crate ) mod app; +pub( crate ) mod channel; pub mod theme; pub mod wallpaper; pub mod chassis; @@ -318,7 +319,10 @@ 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 gles_render; +#[ cfg( not( target_arch = "wasm32" ) ) ] pub mod egl_context; pub mod core; @@ -364,7 +368,7 @@ pub use osk::Purpose as InputPurpose; pub use wallpaper::{ WallpaperBundle, ImageData }; pub use nav_stack::NavStack; pub use chassis::{ set_default_theme, theme_logo_rgba, theme_icon_tinted, branding_bundle_or_solid, wallpaper_bundle_or_solid, backdrop }; -pub use types::{ Color, Corners, CursorShape, Length, LengthBase, PathCmd, Point, Rect, Size, WidgetId }; +pub use types::{ Color, CompositeMode, Corners, CursorShape, Length, LengthBase, PathCmd, Point, Rect, Size, WidgetId }; pub use types::{ WidgetScaling, FLUID_MIN, FLUID_MAX }; pub use types::{ fluid_reference, set_fluid_reference }; pub use types::{ density, set_density }; @@ -423,6 +427,13 @@ pub use widget::carousel::{ Carousel, carousel }; pub use app::{ run, try_run, RunError }; #[ cfg( feature = "android" ) ] pub use platform::android::{ keep_running, set_android_app, AndroidApp }; + +/// What only the browser backend offers. +#[ cfg( feature = "web" ) ] +pub mod web +{ + pub use crate::platform::web::{ load_resources, set_canvas }; +} pub use resources::{ resource_dir, set_resource_dir }; pub use xkeysym::Keysym; diff --git a/src/osk/layout.rs b/src/osk/layout.rs index b9f360f..888afe7 100644 --- a/src/osk/layout.rs +++ b/src/osk/layout.rs @@ -244,7 +244,7 @@ pub fn load( name: &str ) -> Option { search_paths().into_iter() .map( |dir| dir.join( format!( "{name}.json" ) ) ) - .find_map( |path| std::fs::read_to_string( &path ).ok() ) + .find_map( |path| crate::vfs::read_to_string( &path ).ok() ) .and_then( |json| Layout::from_json( &json ).map_err( |e| eprintln!( "[ltk] keyboard layout {name}: {e}" ) ).ok() ) } @@ -252,10 +252,9 @@ pub fn load( name: &str ) -> Option pub fn available() -> Vec { let mut names: Vec = search_paths().into_iter() - .filter_map( |dir| std::fs::read_dir( dir ).ok() ) + .filter_map( |dir| crate::vfs::read_dir( dir ).ok() ) .flatten() - .flatten() - .filter_map( |entry| entry.path().file_stem().and_then( |s| s.to_str() ).map( str::to_string ).filter( |_| entry.path().extension().is_some_and( |e| e == "json" ) ) ) + .filter_map( |path| path.file_stem().and_then( |s| s.to_str() ).map( str::to_string ).filter( |_| path.extension().is_some_and( |e| e == "json" ) ) ) .filter( |name| !name.starts_with( "bar-" ) ) .collect(); names.sort(); diff --git a/src/osk/mod.rs b/src/osk/mod.rs index 807f75b..1e98950 100644 --- a/src/osk/mod.rs +++ b/src/osk/mod.rs @@ -9,8 +9,9 @@ mod draw; pub mod layout; use std::collections::HashMap; -use std::time::{ Duration, Instant }; +use std::time::Duration; +use crate::time::Instant; use crate::types::{ Point, Rect }; use crate::Keysym; diff --git a/src/platform/android/mod.rs b/src/platform/android/mod.rs index 485e4b0..949e7be 100644 --- a/src/platform/android/mod.rs +++ b/src/platform/android/mod.rs @@ -92,7 +92,7 @@ impl Platform for AndroidPlatform self.egl_context.is_none() || surface.egl_surface.is_none() } - fn prepare_canvas( &self, surface: &mut AndroidSurfaceState, canvas: &mut Option, pw: u32, ph: u32, scale: u32 ) -> bool + fn prepare_canvas( &self, surface: &mut AndroidSurfaceState, canvas: &mut Option, pw: u32, ph: u32, scale: f32 ) -> bool { present::prepare_canvas( self, surface, canvas, pw, ph, scale ) } diff --git a/src/platform/android/present.rs b/src/platform/android/present.rs index 60b880f..8628fc7 100644 --- a/src/platform/android/present.rs +++ b/src/platform/android/present.rs @@ -22,7 +22,7 @@ pub( super ) fn prepare_canvas canvas: &mut Option, pw: u32, ph: u32, - scale: u32, + scale: f32, ) -> bool { if let ( Some( ctx ), Some( es ) ) = ( platform.egl_context.as_ref(), surface.egl_surface.as_ref() ) diff --git a/src/platform/android/run.rs b/src/platform/android/run.rs index 6a6eafd..43e7fdc 100644 --- a/src/platform/android/run.rs +++ b/src/platform/android/run.rs @@ -15,7 +15,7 @@ use ndk::looper::{ FdEvent, ForeignLooper }; use crate::app::App; use crate::egl_context::{ log_software_fallback, EglContext }; -use crate::event_loop::{ AppData, RunError, SurfaceFocus, SurfaceState }; +use crate::event_loop::{ AppData, RunError, Sources, SurfaceFocus, SurfaceState }; use super::{ AndroidPlatform, AndroidSurfaceState }; @@ -96,7 +96,7 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> let platform = AndroidPlatform::new( activity.clone(), egl_context ); let main = SurfaceState::new( AndroidSurfaceState::default(), 0.0, String::new() ); // ltk's own keyboard: a NativeActivity gets only key events from the system's, so composed text and suggestions would be lost. - let mut data = AppData::new( app, platform, main, event_loop.handle(), true, None ); + let mut data = AppData::new( app, platform, main, Sources::new( event_loop.handle() ), true, None ); data.install_sources()?; while !data.exit_requested diff --git a/src/platform/android/surface.rs b/src/platform/android/surface.rs index 1261702..dde8877 100644 --- a/src/platform/android/surface.rs +++ b/src/platform/android/surface.rs @@ -79,9 +79,9 @@ impl AppData .unwrap_or_else( || content_edges( self.platform.activity.content_rect(), ( ww, wh ) ) ); self.main.insets = logical_insets( edges, self.platform.px_per_logical ); - if scale as i32 != self.main.scale_factor + if scale as f32 != self.main.scale_factor { - self.main.scale_factor = scale as i32; + self.main.scale_factor = scale as f32; if let Some( ref mut canvas ) = self.main.canvas { canvas.set_dpi_scale( scale as f32 ); } } self.main.set_size( w, h ); diff --git a/src/platform/mock.rs b/src/platform/mock.rs index d77791d..6bd5e72 100644 --- a/src/platform/mock.rs +++ b/src/platform/mock.rs @@ -104,7 +104,7 @@ impl Platform for MockPlatform true } - fn prepare_canvas( &self, _surface: &mut (), canvas: &mut Option, pw: u32, ph: u32, scale: u32 ) -> bool + fn prepare_canvas( &self, _surface: &mut (), canvas: &mut Option, pw: u32, ph: u32, scale: f32 ) -> bool { fit_canvas( canvas, pw, ph, scale, || Canvas::new( pw, ph ) ); true diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 34d398b..8323e59 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -7,12 +7,14 @@ //! //! A backend also owns the run loop: it connects to the system, builds `AppData` with `AppData::new`, installs the shared event sources with `AppData::install_sources`, and on every wake-up calls `AppData::before_draw`, `AppData::draw_due` and `AppData::after_draw` around its own work. //! -//! The backends are `wayland`, behind the `wayland` feature, and `android`, behind the `android` feature; `build.rs` refuses both at once. Each backend sets the two aliases below and `run` / `try_run` under its own `cfg`, and `build.rs` sets `cfg( backend )`, which builds the runtime, when one is enabled. +//! The backends are `wayland`, `android` and `web`, each behind the feature of its name; `build.rs` refuses more than one. Each backend sets the two aliases below and `run` / `try_run` under its own `cfg`, and `build.rs` sets `cfg( backend )`, which builds the runtime, when one is enabled. #[ cfg( feature = "android" ) ] pub( crate ) mod android; #[ cfg( feature = "wayland" ) ] pub( crate ) mod wayland; +#[ cfg( feature = "web" ) ] +pub( crate ) mod web; #[ cfg( test ) ] pub( crate ) mod mock; @@ -20,6 +22,8 @@ pub( crate ) mod mock; pub( crate ) use android::run::{ run, try_run }; #[ cfg( feature = "wayland" ) ] pub( crate ) use wayland::run::{ run, try_run }; +#[ cfg( feature = "web" ) ] +pub( crate ) use web::run::{ run, try_run }; use crate::event_loop::SurfaceFocus; use crate::event_loop::cursor_shape::ResizeEdge; @@ -32,12 +36,29 @@ use crate::types::{ Color, CursorShape, Rect }; pub( crate ) type NativePlatform = wayland::WaylandPlatform; #[ cfg( feature = "android" ) ] pub( crate ) type NativePlatform = android::AndroidPlatform; +#[ cfg( feature = "web" ) ] +pub( crate ) type NativePlatform = web::WebPlatform; /// The backend's half of every surface, held by `SurfaceState::platform`. #[ cfg( feature = "wayland" ) ] pub( crate ) type NativeSurface = wayland::WaylandSurfaceState; #[ cfg( feature = "android" ) ] pub( crate ) type NativeSurface = android::AndroidSurfaceState; +#[ cfg( feature = "web" ) ] +pub( crate ) type NativeSurface = web::WebSurfaceState; + +/// The user's language as `LANG` gives it (`es_ES.UTF-8`), or in a browser as the page does (`es-ES`). +#[ cfg( not( feature = "web" ) ) ] +pub( crate ) fn language() -> String +{ + std::env::var( "LANG" ).unwrap_or_default() +} + +#[ cfg( feature = "web" ) ] +pub( crate ) fn language() -> String +{ + web_sys::window().and_then( |w| w.navigator().language() ).unwrap_or_default() +} /// What [`Platform::present`] needs to know about a painted frame. Rects are in physical pixels, top-left origin. pub( crate ) struct Presentation<'a> @@ -49,10 +70,10 @@ pub( crate ) struct Presentation<'a> pub opaque_region: Option<&'a [Rect]>, /// Where the surface takes input; `None` everywhere, empty nowhere. pub input_region: Option<&'a [Rect]>, - /// Logical size and buffer scale. + /// Logical size and the physical pixels per logical one. pub width: u32, pub height: u32, - pub scale: u32, + pub scale: f32, /// Report the next frame to the runtime as one of this surface; `None` for a surface that is not paced by frames. pub frame: Option, } @@ -72,7 +93,7 @@ pub( crate ) trait Platform: 'static fn precise_damage( &self, surface: &Self::Surface ) -> bool; /// Make `canvas` ready to paint `surface` at `pw × ph` physical pixels, creating it on the first frame; `false` when the surface cannot be drawn now. - fn prepare_canvas( &self, surface: &mut Self::Surface, canvas: &mut Option, pw: u32, ph: u32, scale: u32 ) -> bool; + fn prepare_canvas( &self, surface: &mut Self::Surface, canvas: &mut Option, pw: u32, ph: u32, scale: f32 ) -> bool; /// Show what was painted on `canvas` since [`Self::prepare_canvas`]; `false` when nothing reached the screen. fn present( &self, surface: &mut Self::Surface, canvas: &mut Canvas, frame: &Presentation<'_> ) -> bool; diff --git a/src/platform/wayland/handlers.rs b/src/platform/wayland/handlers.rs index a28bd56..5eb180e 100644 --- a/src/platform/wayland/handlers.rs +++ b/src/platform/wayland/handlers.rs @@ -57,8 +57,8 @@ fn apply_surface_scale( new_factor: i32, ) -> Option<( u32, u32 )> { - if new_factor == ss.scale_factor { return None; } - ss.scale_factor = new_factor; + if new_factor as f32 == ss.scale_factor { return None; } + ss.scale_factor = new_factor as f32; surface.set_buffer_scale( new_factor ); if let Some( ref mut canvas ) = ss.canvas { @@ -279,7 +279,7 @@ impl WindowHandler for AppData if let Some( SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( window.wl_surface() ) { let Some( ss ) = self.overlays.get_mut( &id ) else { return }; - let sf = ss.scale_factor.max( 1 ) as f32; + let sf = ss.scale_factor; let ( rw, rh ) = ss.platform.last_requested_size; let fallback = ( ( rw as f32 / sf ).round().max( 1.0 ) as u32, ( rh as f32 / sf ).round().max( 1.0 ) as u32 ); let w = configure.new_size.0.map( |v| v.get() ).unwrap_or( fallback.0 ); diff --git a/src/platform/wayland/mod.rs b/src/platform/wayland/mod.rs index 4477bce..39f8600 100644 --- a/src/platform/wayland/mod.rs +++ b/src/platform/wayland/mod.rs @@ -96,7 +96,7 @@ impl Platform for WaylandPlatform self.egl_context.is_none() || surface.egl_surface.is_none() } - fn prepare_canvas( &self, surface: &mut WaylandSurfaceState, canvas: &mut Option, pw: u32, ph: u32, scale: u32 ) -> bool + fn prepare_canvas( &self, surface: &mut WaylandSurfaceState, canvas: &mut Option, pw: u32, ph: u32, scale: f32 ) -> bool { present::prepare_canvas( self, surface, canvas, pw, ph, scale ) } diff --git a/src/platform/wayland/overlays_reconcile.rs b/src/platform/wayland/overlays_reconcile.rs index 8e55e8a..e1b548a 100644 --- a/src/platform/wayland/overlays_reconcile.rs +++ b/src/platform/wayland/overlays_reconcile.rs @@ -122,8 +122,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) SurfaceKind::Window( ref w ) => Some( w.xdg_surface().clone() ), _ => None, }; - let parent_scale_i = data.main.scale_factor.max( 1 ); - let parent_scale = parent_scale_i as f32; + let parent_scale = data.main.scale_factor; // `OverlaySpec::size` carries `Length`s resolved against the main // surface's physical viewport (the app's layout space), so an overlay // sized with `Length::widget( … )` scales with the display exactly like @@ -377,7 +376,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) window.set_max_size( Some( ( w, h ) ) ); window.commit(); let mut ss = SurfaceState::new( WaylandSurfaceState::from( SurfaceKind::Window( window ) ), 0.0, String::new() ); - ss.scale_factor = parent_scale_i; + ss.scale_factor = parent_scale; ss.platform.last_requested_size = resolved_size; overlays_m.insert( spec.id, ss ); continue; @@ -407,7 +406,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) // HiDPI buffer and lays out at the right size from frame one, // instead of rendering at scale 1 until `scale_factor_changed` // lands a frame or two later. - ss.scale_factor = parent_scale_i; + ss.scale_factor = parent_scale; ss.platform.last_requested_size = resolved_size; ss.platform.last_requested_zone = to_logical_zone( spec.exclusive_zone ); ss.layer_anchor = Some( spec.anchor ); diff --git a/src/platform/wayland/present.rs b/src/platform/wayland/present.rs index ff6cd83..277f6aa 100644 --- a/src/platform/wayland/present.rs +++ b/src/platform/wayland/present.rs @@ -41,7 +41,7 @@ pub( super ) fn prepare_canvas canvas: &mut Option, pw: u32, ph: u32, - scale: u32, + scale: f32, ) -> bool { if let ( Some( ctx ), Some( es ) ) = ( platform.egl_context.as_ref(), surface.egl_surface.as_ref() ) @@ -113,8 +113,9 @@ fn present_shm /// The state that rides on the commit besides the buffer. The frame callback must be asked before the commit so the compositor ties it to this frame. fn describe( platform: &WaylandPlatform, wl_surface: &WlSurface, opaque_applied: &mut Option>, frame: &Presentation<'_> ) { - sync_opaque_region( wl_surface, &platform.compositor_state, opaque_applied, frame.bg, frame.opaque_region, frame.width, frame.height, frame.scale ); - apply_input_region( wl_surface, &platform.compositor_state, frame.input_region, frame.scale ); + let scale = frame.scale.round().max( 1.0 ) as u32; + sync_opaque_region( wl_surface, &platform.compositor_state, opaque_applied, frame.bg, frame.opaque_region, frame.width, frame.height, scale ); + apply_input_region( wl_surface, &platform.compositor_state, frame.input_region, scale ); if let Some( focus ) = frame.frame { let _ = wl_surface.frame( &platform.qh, focus ); diff --git a/src/platform/wayland/run.rs b/src/platform/wayland/run.rs index 1c5fae5..ec9f30e 100644 --- a/src/platform/wayland/run.rs +++ b/src/platform/wayland/run.rs @@ -26,7 +26,7 @@ use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource; use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3; use crate::app::App; -use crate::event_loop::{ AppData, RunError, SurfaceState }; +use crate::event_loop::{ AppData, RunError, Sources, SurfaceState }; use super::{ LayerConfig, SurfaceKind, WaylandPlatform, WaylandSurfaceState }; use super::overlays_reconcile::reconcile_overlays; @@ -339,7 +339,7 @@ pub( crate ) fn try_run( mut app: A ) -> Result<(), RunError> let mut main_wl = WaylandSurfaceState::from( surface_kind ); main_wl.titlebar_base = titlebar_height; let main = SurfaceState::new( main_wl, titlebar_height, titlebar_title ); - let mut data = AppData::new( app, platform, main, event_loop.handle(), builtin_osk, a11y ); + let mut data = AppData::new( app, platform, main, Sources::new( event_loop.handle() ), builtin_osk, a11y ); data.install_sources()?; if data.platform.session.store.is_some() diff --git a/src/platform/wayland/subsurface.rs b/src/platform/wayland/subsurface.rs index a24f367..527f454 100644 --- a/src/platform/wayland/subsurface.rs +++ b/src/platform/wayland/subsurface.rs @@ -151,7 +151,7 @@ fn resolve_parent( data: &AppData, parent: SubsurfaceParent ) -> Opti if !ss.configured { return None; } if ss.width == 0 || ss.height == 0 { return None; } let surface = ss.platform.surface.try_wl_surface()?.clone(); - let scale = ss.scale_factor.max( 1 ) as u32; + let scale = ss.buffer_scale() as u32; Some( ( surface, ss.width * scale, ss.height * scale, scale ) ) } diff --git a/src/platform/wayland/surface.rs b/src/platform/wayland/surface.rs index 7646ccb..9d8a433 100644 --- a/src/platform/wayland/surface.rs +++ b/src/platform/wayland/surface.rs @@ -194,7 +194,7 @@ impl SurfaceState h: u32, ) { - let scale = self.scale_factor.max( 1 ) as u32; + let scale = self.buffer_scale() as u32; let pw = w.saturating_mul( scale ); let ph = h.saturating_mul( scale ); let pool_bytes = (pw as u64) @@ -264,7 +264,7 @@ impl SurfaceState } } - wl.surface.wl_surface().set_buffer_scale( self.scale_factor.max( 1 ) ); + wl.surface.wl_surface().set_buffer_scale( scale as i32 ); self.set_size( w, h ); } } diff --git a/src/platform/web/assets.rs b/src/platform/web/assets.rs new file mode 100644 index 0000000..7cd9d49 --- /dev/null +++ b/src/platform/web/assets.rs @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The resources a page serves next to it: the tree `scripts/web-assets.sh` builds, the one an Android package carries, fetched into memory (`vfs.rs`) and used through [`crate::set_resource_dir`], so themes, fonts and keyboard layouts are read as from files anywhere else. +//! +//! `MANIFEST` lists the tree, one path per line relative to it; empty lines and lines starting with `#` are skipped, and the first line is `# ` and a hash of the contents. + +use std::path::{ Component, Path }; + +use wasm_bindgen::{ JsCast, JsValue }; +use wasm_bindgen_futures::JsFuture; +use web_sys::{ Response, Window }; + +/// Where the fetched tree lives in memory, which is the resource directory. +const ROOT: &str = "/ltk"; + +/// Fetch the resources served at `base`, a URL relative to the page such as `"ltk"`, and use them. Await it before `ltk::run`, so the theme is read from them; a failure leaves ltk on its built-in theme and font. +/// +/// The files are fetched at once, each with the manifest's hash in its URL, so a changed tree is never served from the browser's cache; the manifest itself always comes from the server. +pub async fn load_resources( base: &str ) -> Result<(), String> +{ + let window = web_sys::window().ok_or( "no window" )?; + let base = base.trim_end_matches( '/' ); + let manifest = fetch( &window, &format!( "{base}/MANIFEST?{}", js_sys::Date::now() ) ).await?; + let manifest = String::from_utf8( manifest ).map_err( |e| format!( "MANIFEST: {e}" ) )?; + let version = manifest.lines().next().and_then( |line| line.strip_prefix( "# " ) ).unwrap_or( "" ); + + let mut pending = Vec::new(); + for line in manifest.lines().map( str::trim ).filter( |l| !l.is_empty() && !l.starts_with( '#' ) ) + { + if !Path::new( line ).components().all( |c| matches!( c, Component::Normal( _ ) ) ) + { + return Err( format!( "{line}: not a path inside {base}/" ) ); + } + let url = format!( "{base}/{}?{version}", String::from( js_sys::encode_uri( line ) ) ); + pending.push( ( line, window.fetch_with_str( &url ) ) ); + } + for ( path, request ) in pending + { + let bytes = body( request ).await.map_err( |e| format!( "{path}: {e}" ) )?; + crate::vfs::insert( Path::new( ROOT ).join( path ), bytes ); + } + crate::resources::set_resource_dir( ROOT ); + Ok( () ) +} + +async fn fetch( window: &Window, url: &str ) -> Result, String> +{ + body( window.fetch_with_str( url ) ).await.map_err( |e| format!( "{url}: {e}" ) ) +} + +async fn body( request: js_sys::Promise ) -> Result, String> +{ + let response: Response = JsFuture::from( request ).await.map_err( describe )?.dyn_into().map_err( describe )?; + if !response.ok() { return Err( format!( "HTTP {}", response.status() ) ); } + let buffer = JsFuture::from( response.array_buffer().map_err( describe )? ).await.map_err( describe )?; + Ok( js_sys::Uint8Array::new( &buffer ).to_vec() ) +} + +fn describe( error: JsValue ) -> String +{ + error.as_string().unwrap_or_else( || format!( "{error:?}" ) ) +} diff --git a/src/platform/web/clipboard.rs b/src/platform/web/clipboard.rs new file mode 100644 index 0000000..d351a68 --- /dev/null +++ b/src/platform/web/clipboard.rs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Copies to the system clipboard through `navigator.clipboard`, which web-sys keeps behind its unstable APIs. Pasting needs no reading: the page's `paste` event carries the text (`input.rs`). + +use wasm_bindgen::closure::Closure; +use wasm_bindgen::{ JsCast, JsValue }; + +/// Offer `text` to other applications. The browser allows it only shortly after the user acted on the page, which a copy always follows. +pub( super ) fn write( text: &str ) +{ + let Some( window ) = web_sys::window() else { return }; + let Ok( clipboard ) = js_sys::Reflect::get( &window.navigator(), &JsValue::from_str( "clipboard" ) ) else { return }; + if clipboard.is_undefined() { return; } + let Ok( write_text ) = js_sys::Reflect::get( &clipboard, &JsValue::from_str( "writeText" ) ) else { return }; + let Some( write_text ) = write_text.dyn_ref::() else { return }; + let Ok( promise ) = write_text.call1( &clipboard, &JsValue::from_str( text ) ) else { return }; + let refused = Closure::once_into_js( |_: JsValue| web_sys::console::warn_1( &"[ltk] the browser refused to write the clipboard".into() ) ); + if let Ok( catch ) = js_sys::Reflect::get( &promise, &JsValue::from_str( "catch" ) ) + { + if let Some( catch ) = catch.dyn_ref::() { let _ = catch.call1( &promise, &refused ); } + } +} diff --git a/src/platform/web/cursor.rs b/src/platform/web/cursor.rs new file mode 100644 index 0000000..2b72b8a --- /dev/null +++ b/src/platform/web/cursor.rs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::CursorShape; + +/// The value of CSS `cursor` for `shape`; the shapes are CSS's own. +pub( super ) fn css( shape: CursorShape ) -> &'static str +{ + use CursorShape as C; + match shape + { + C::Default => "default", + C::ContextMenu => "context-menu", + C::Help => "help", + C::Pointer => "pointer", + C::Progress => "progress", + C::Wait => "wait", + C::Cell => "cell", + C::Crosshair => "crosshair", + C::Text => "text", + C::VerticalText => "vertical-text", + C::Alias => "alias", + C::Copy => "copy", + C::Move => "move", + C::NoDrop => "no-drop", + C::NotAllowed => "not-allowed", + C::Grab => "grab", + C::Grabbing => "grabbing", + C::EResize => "e-resize", + C::NResize => "n-resize", + C::NeResize => "ne-resize", + C::NwResize => "nw-resize", + C::SResize => "s-resize", + C::SeResize => "se-resize", + C::SwResize => "sw-resize", + C::WResize => "w-resize", + C::EwResize => "ew-resize", + C::NsResize => "ns-resize", + C::NeswResize => "nesw-resize", + C::NwseResize => "nwse-resize", + C::ColResize => "col-resize", + C::RowResize => "row-resize", + C::AllScroll => "all-scroll", + C::ZoomIn => "zoom-in", + C::ZoomOut => "zoom-out", + } +} diff --git a/src/platform/web/input.rs b/src/platform/web/input.rs new file mode 100644 index 0000000..e941800 --- /dev/null +++ b/src/platform/web/input.rs @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The canvas's pointer, wheel, key and focus events turned into [`crate::input::event`]. +//! +//! A mouse is a pointer; fingers and pens are touches. Positions are `offsetX` / `offsetY`, in CSS pixels, which are the surface's logical ones. Keys repeat on the browser's clock, so the runtime's repeat stops at each of its repeats. On macOS Command is Ctrl, as its shortcuts expect. The listeners only queue what they translate; the loop hands it to the runtime. Dead keys and input methods are not composed yet. +//! +//! A page reads the clipboard only from a `paste` event, which the browser sends after a Ctrl+V it was let handle. So Ctrl+V is held back until its `paste` brings the text, and goes to the runtime after it. + +use std::cell::{ Cell, RefCell }; +use std::rc::Rc; +use std::sync::OnceLock; + +use wasm_bindgen::{ JsCast, JsValue }; +use web_sys::{ ClipboardEvent, Event, HtmlCanvasElement, KeyboardEvent, PointerEvent, WheelEvent }; + +use crate::app::App; +use crate::event_loop::{ AppData, RunError, SurfaceFocus }; +use crate::input::event::{ Axis, AxisSource, KeyInput, PointerButton, PointerInput, PointerKind, TouchInput, TouchPhase }; +use crate::Keysym; + +use super::keys; +use super::run::{ push, Listener, PageEvent }; + +/// Logical pixels per wheel detent, as libinput reports one. +const WHEEL_STEP: f64 = 15.0; + +/// Lines a browser scrolls per wheel detent in `DOM_DELTA_LINE` mode. +const LINES_PER_NOTCH: f64 = 3.0; + +const DOM_DELTA_LINE: u32 = 1; +const DOM_DELTA_PAGE: u32 = 2; + +/// The Linux input code of V, for a paste that comes with no key. +const KEY_V: u32 = 47; + +thread_local! +{ + /// The Ctrl+V waiting for its `paste` event. + static HELD_PASTE: RefCell> = const { RefCell::new( None ) }; +} + +pub( super ) enum KeyAction +{ + Press, + Repeat, + Release, +} + +/// What a listener translated, for the loop to hand to the runtime. +pub( super ) enum WebInput +{ + Pointer( PointerInput ), + Touch( TouchInput ), + TouchCancel, + Key + { + key: KeyInput, + action: KeyAction, + ctrl: bool, + shift: bool, + alt: bool, + }, + Focus( bool ), + /// What a `paste` brought, before the key that pastes it. + Clipboard( String ), +} + +impl AppData +{ + pub( super ) fn web_input( &mut self, input: WebInput ) + { + match input + { + WebInput::Pointer( pointer ) => self.pointer_input( &pointer ), + WebInput::Touch( touch ) => self.touch_input( &touch ), + WebInput::TouchCancel => { self.reset_touch_state(); } + WebInput::Key { key, action, ctrl, shift, alt } => + { + if ( ctrl, shift, alt ) != ( self.ctrl_pressed, self.shift_pressed, self.alt_pressed ) + { + self.modifiers_changed( ctrl, shift, alt ); + } + match action + { + KeyAction::Press => self.key_press( key ), + KeyAction::Repeat => self.key_repeated( key ), + KeyAction::Release => self.key_release( &key ), + } + } + WebInput::Focus( true ) => self.keyboard_enter( SurfaceFocus::Main ), + WebInput::Focus( false ) => + { + // The keys held when the canvas lost the focus are released where the page does not see it. + if self.ctrl_pressed || self.shift_pressed || self.alt_pressed { self.modifiers_changed( false, false, false ); } + self.keyboard_leave(); + } + WebInput::Clipboard( text ) => self.clipboard = text, + } + } +} + +/// Listen on `canvas` for every input the runtime takes. +pub( super ) fn listen( canvas: &HtmlCanvasElement ) -> Result, RunError> +{ + let hovering = Rc::new( Cell::new( false ) ); + let mut listeners = Vec::new(); + + let ( target, over ) = ( canvas.clone(), Rc::clone( &hovering ) ); + listeners.push( Listener::new( canvas, "pointerdown", move |event| pointer_down( &target, &over, &event ) )? ); + let over = Rc::clone( &hovering ); + listeners.push( Listener::new( canvas, "pointermove", move |event| pointer_move( &over, &event ) )? ); + let over = Rc::clone( &hovering ); + listeners.push( Listener::new( canvas, "pointerup", move |event| pointer_up( &over, &event ) )? ); + listeners.push( Listener::new( canvas, "pointercancel", |event| pointer_cancel( &event ) )? ); + let over = Rc::clone( &hovering ); + listeners.push( Listener::new( canvas, "pointerleave", move |event| pointer_leave( &over, &event ) )? ); + let ( over, target ) = ( Rc::clone( &hovering ), canvas.clone() ); + listeners.push( Listener::new( canvas, "wheel", move |event| wheel( &target, &over, &event ) )? ); + listeners.push( Listener::new( canvas, "contextmenu", |event| event.prevent_default() )? ); + listeners.push( Listener::new( canvas, "keydown", |event| key( &event, true ) )? ); + listeners.push( Listener::new( canvas, "keyup", |event| key( &event, false ) )? ); + listeners.push( Listener::new( canvas, "focus", |_| queue( WebInput::Focus( true ) ) )? ); + listeners.push( Listener::new( canvas, "blur", |_| queue( WebInput::Focus( false ) ) )? ); + listeners.push( Listener::new( canvas, "paste", |event| paste( &event ) )? ); + Ok( listeners ) +} + +fn queue( input: WebInput ) +{ + push( PageEvent::Input( input ) ); +} + +/// Where `event` happened on the canvas, in its CSS pixels. Read as numbers, since web-sys rounds `offsetX` to an integer. +fn position( event: &Event ) -> ( f64, f64 ) +{ + let get = |name: &str| js_sys::Reflect::get( event, &JsValue::from_str( name ) ).ok().and_then( |v| v.as_f64() ).unwrap_or( 0.0 ); + ( get( "offsetX" ), get( "offsetY" ) ) +} + +/// Milliseconds since the page loaded, which only differences are read of. +fn time( event: &Event ) -> u32 +{ + event.time_stamp() as u32 +} + +fn is_mouse( event: &PointerEvent ) -> bool +{ + event.pointer_type() == "mouse" +} + +/// A mouse event, after the `Enter` the runtime expects first when the pointer was over the canvas before any `pointerenter`. +fn mouse( hovering: &Cell, position: ( f64, f64 ), kind: PointerKind ) +{ + if !hovering.replace( true ) && kind != PointerKind::Enter + { + queue( WebInput::Pointer( PointerInput { focus: SurfaceFocus::Main, position, kind: PointerKind::Enter } ) ); + } + queue( WebInput::Pointer( PointerInput { focus: SurfaceFocus::Main, position, kind } ) ); +} + +fn button( button: i16 ) -> PointerButton +{ + match button + { + 0 => PointerButton::Left, + 1 => PointerButton::Middle, + 2 => PointerButton::Right, + 3 => PointerButton::Back, + _ => PointerButton::Other, + } +} + +/// The canvas takes the keyboard focus and every later event of the pointer, wherever it goes. +fn pointer_down( canvas: &HtmlCanvasElement, hovering: &Cell, event: &Event ) +{ + let Some( e ) = event.dyn_ref::() else { return }; + event.prevent_default(); + let _ = canvas.focus(); + let _ = canvas.set_pointer_capture( e.pointer_id() ); + let position = position( event ); + if is_mouse( e ) + { + mouse( hovering, position, PointerKind::Press( button( e.button() ) ) ); + } else { + queue( WebInput::Touch( TouchInput { id: e.pointer_id(), time: time( event ), phase: TouchPhase::Down { focus: SurfaceFocus::Main, position } } ) ); + } +} + +/// A pen hovering moves with no button held, which is not a touch yet. +fn pointer_move( hovering: &Cell, event: &Event ) +{ + let Some( e ) = event.dyn_ref::() else { return }; + let position = position( event ); + if is_mouse( e ) + { + mouse( hovering, position, PointerKind::Motion ); + } else if e.buttons() != 0 { + queue( WebInput::Touch( TouchInput { id: e.pointer_id(), time: time( event ), phase: TouchPhase::Motion { position } } ) ); + } +} + +fn pointer_up( hovering: &Cell, event: &Event ) +{ + let Some( e ) = event.dyn_ref::() else { return }; + if is_mouse( e ) + { + mouse( hovering, position( event ), PointerKind::Release( button( e.button() ) ) ); + } else { + queue( WebInput::Touch( TouchInput { id: e.pointer_id(), time: time( event ), phase: TouchPhase::Up } ) ); + } +} + +fn pointer_cancel( event: &Event ) +{ + let Some( e ) = event.dyn_ref::() else { return }; + if !is_mouse( e ) { queue( WebInput::TouchCancel ); } +} + +fn pointer_leave( hovering: &Cell, event: &Event ) +{ + let Some( e ) = event.dyn_ref::() else { return }; + if !is_mouse( e ) || !hovering.replace( false ) { return; } + queue( WebInput::Pointer( PointerInput { focus: SurfaceFocus::Main, position: position( event ), kind: PointerKind::Leave } ) ); +} + +/// A wheel scrolls lines, in detents; a touchpad scrolls pixels. The page does not scroll or zoom under the canvas. +fn wheel( canvas: &HtmlCanvasElement, hovering: &Cell, event: &Event ) +{ + let Some( e ) = event.dyn_ref::() else { return }; + event.prevent_default(); + let ( horizontal, vertical, source ) = match e.delta_mode() + { + DOM_DELTA_LINE => + { + let notches = |lines: f64| Axis { distance: lines / LINES_PER_NOTCH * WHEEL_STEP, notches: ( lines / LINES_PER_NOTCH ) as f32 }; + ( notches( e.delta_x() ), notches( e.delta_y() ), AxisSource::Wheel ) + } + DOM_DELTA_PAGE => + { + let ( w, h ) = ( canvas.client_width() as f64, canvas.client_height() as f64 ); + ( Axis { distance: e.delta_x() * w, notches: 0.0 }, Axis { distance: e.delta_y() * h, notches: 0.0 }, AxisSource::Continuous ) + } + _ => ( Axis { distance: e.delta_x(), notches: 0.0 }, Axis { distance: e.delta_y(), notches: 0.0 }, AxisSource::Continuous ), + }; + mouse( hovering, position( event ), PointerKind::Axis { horizontal, vertical, source } ); +} + +fn is_mac() -> bool +{ + static MAC: OnceLock = OnceLock::new(); + *MAC.get_or_init( || web_sys::window().and_then( |w| w.navigator().user_agent().ok() ).is_some_and( |ua| ua.contains( "Mac" ) ) ) +} + +/// ltk takes every key it is given, browser shortcuts included where the browser lets a page have them, except the Ctrl+V the browser must handle to send its `paste`. +fn key( event: &Event, down: bool ) +{ + let Some( e ) = event.dyn_ref::() else { return }; + // AltGr reaches the page as Ctrl and Alt on some systems; it types a character, it is not a shortcut. + let alt_graph = e.get_modifier_state( "AltGraph" ); + let ( ctrl, alt ) = if alt_graph { ( false, false ) } else { ( e.ctrl_key() || ( is_mac() && e.meta_key() ), e.alt_key() ) }; + let name = e.key(); + let paste = down && ctrl && name.eq_ignore_ascii_case( "v" ); + if down && !paste { event.prevent_default(); } + let typed = single_char( &name ); + let Some( keysym ) = keys::keysym( &name, e.location() ).or( typed.map( Keysym::from_char ) ) else { return }; + let text = typed.filter( |c| !ctrl && !e.meta_key() && !c.is_control() ).map( String::from ); + let action = match ( down, e.repeat() ) + { + ( true, true ) => KeyAction::Repeat, + ( true, false ) => KeyAction::Press, + ( false, _ ) => KeyAction::Release, + }; + let key = KeyInput { keysym, keycode: keys::evdev( &e.code() ), text }; + let input = WebInput::Key { key, action, ctrl, shift: e.shift_key(), alt }; + if paste + { + HELD_PASTE.with( |held| *held.borrow_mut() = Some( input ) ); + } else { + queue( input ); + } +} + +/// The clipboard's text, then the Ctrl+V held for it; a paste from the browser's menu comes with no key, so one is made for it. +fn paste( event: &Event ) +{ + let Some( e ) = event.dyn_ref::() else { return }; + event.prevent_default(); + let text = e.clipboard_data().and_then( |data| data.get_data( "text/plain" ).ok() ).unwrap_or_default(); + queue( WebInput::Clipboard( text ) ); + match HELD_PASTE.with( |held| held.borrow_mut().take() ) + { + Some( held ) => queue( held ), + None => + { + let key = || KeyInput { keysym: Keysym::v, keycode: KEY_V, text: None }; + queue( WebInput::Key { key: key(), action: KeyAction::Press, ctrl: true, shift: false, alt: false } ); + queue( WebInput::Key { key: key(), action: KeyAction::Release, ctrl: false, shift: false, alt: false } ); + } + } +} + +/// `KeyboardEvent.key` when it is the character the key types rather than a key's name. +fn single_char( name: &str ) -> Option +{ + let mut chars = name.chars(); + let c = chars.next()?; + chars.next().is_none().then_some( c ) +} diff --git a/src/platform/web/keys.rs b/src/platform/web/keys.rs new file mode 100644 index 0000000..2ef077f --- /dev/null +++ b/src/platform/web/keys.rs @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::Keysym; + +/// `KeyboardEvent.location` of the right key of a pair. +const LOCATION_RIGHT: u32 = 2; +const LOCATION_NUMPAD: u32 = 3; + +/// The keysym of a key that types no character, from `KeyboardEvent.key` and `.location`. `None` for the others, whose keysym is the character they type. +pub( super ) fn keysym( key: &str, location: u32 ) -> Option +{ + let right = location == LOCATION_RIGHT; + Some( match key + { + "ArrowLeft" => Keysym::Left, + "ArrowRight" => Keysym::Right, + "ArrowUp" => Keysym::Up, + "ArrowDown" => Keysym::Down, + "Enter" if location == LOCATION_NUMPAD => Keysym::KP_Enter, + "Enter" => Keysym::Return, + "Backspace" => Keysym::BackSpace, + "Delete" => Keysym::Delete, + "Tab" => Keysym::Tab, + "Escape" => Keysym::Escape, + "Home" => Keysym::Home, + "End" => Keysym::End, + "PageUp" => Keysym::Page_Up, + "PageDown" => Keysym::Page_Down, + "Insert" => Keysym::Insert, + "F1" => Keysym::F1, + "F2" => Keysym::F2, + "F3" => Keysym::F3, + "F4" => Keysym::F4, + "F5" => Keysym::F5, + "F6" => Keysym::F6, + "F7" => Keysym::F7, + "F8" => Keysym::F8, + "F9" => Keysym::F9, + "F10" => Keysym::F10, + "F11" => Keysym::F11, + "F12" => Keysym::F12, + "Shift" if right => Keysym::Shift_R, + "Shift" => Keysym::Shift_L, + "Control" if right => Keysym::Control_R, + "Control" => Keysym::Control_L, + "Alt" if right => Keysym::Alt_R, + "Alt" => Keysym::Alt_L, + "AltGraph" => Keysym::ISO_Level3_Shift, + "Meta" if right => Keysym::Super_R, + "Meta" => Keysym::Super_L, + "CapsLock" => Keysym::Caps_Lock, + "NumLock" => Keysym::Num_Lock, + "ScrollLock" => Keysym::Scroll_Lock, + "PrintScreen" => Keysym::Print, + "Pause" => Keysym::Pause, + "ContextMenu" => Keysym::Menu, + "BrowserBack" => Keysym::XF86_Back, + "BrowserForward" => Keysym::XF86_Forward, + "Cut" => Keysym::XF86_Cut, + "Copy" => Keysym::XF86_Copy, + "Paste" => Keysym::XF86_Paste, + _ => return None, + } ) +} + +/// The Linux input code of the physical key `KeyboardEvent.code` names, the numbering Wayland reports keys in; 0 for a key not listed. +pub( super ) fn evdev( code: &str ) -> u32 +{ + match code + { + "Escape" => 1, + "Digit1" => 2, + "Digit2" => 3, + "Digit3" => 4, + "Digit4" => 5, + "Digit5" => 6, + "Digit6" => 7, + "Digit7" => 8, + "Digit8" => 9, + "Digit9" => 10, + "Digit0" => 11, + "Minus" => 12, + "Equal" => 13, + "Backspace" => 14, + "Tab" => 15, + "KeyQ" => 16, + "KeyW" => 17, + "KeyE" => 18, + "KeyR" => 19, + "KeyT" => 20, + "KeyY" => 21, + "KeyU" => 22, + "KeyI" => 23, + "KeyO" => 24, + "KeyP" => 25, + "BracketLeft" => 26, + "BracketRight" => 27, + "Enter" => 28, + "ControlLeft" => 29, + "KeyA" => 30, + "KeyS" => 31, + "KeyD" => 32, + "KeyF" => 33, + "KeyG" => 34, + "KeyH" => 35, + "KeyJ" => 36, + "KeyK" => 37, + "KeyL" => 38, + "Semicolon" => 39, + "Quote" => 40, + "Backquote" => 41, + "ShiftLeft" => 42, + "Backslash" => 43, + "KeyZ" => 44, + "KeyX" => 45, + "KeyC" => 46, + "KeyV" => 47, + "KeyB" => 48, + "KeyN" => 49, + "KeyM" => 50, + "Comma" => 51, + "Period" => 52, + "Slash" => 53, + "ShiftRight" => 54, + "NumpadMultiply" => 55, + "AltLeft" => 56, + "Space" => 57, + "CapsLock" => 58, + "F1" => 59, + "F2" => 60, + "F3" => 61, + "F4" => 62, + "F5" => 63, + "F6" => 64, + "F7" => 65, + "F8" => 66, + "F9" => 67, + "F10" => 68, + "NumLock" => 69, + "ScrollLock" => 70, + "Numpad7" => 71, + "Numpad8" => 72, + "Numpad9" => 73, + "NumpadSubtract" => 74, + "Numpad4" => 75, + "Numpad5" => 76, + "Numpad6" => 77, + "NumpadAdd" => 78, + "Numpad1" => 79, + "Numpad2" => 80, + "Numpad3" => 81, + "Numpad0" => 82, + "NumpadDecimal" => 83, + "IntlBackslash" => 86, + "F11" => 87, + "F12" => 88, + "NumpadEnter" => 96, + "ControlRight" => 97, + "NumpadDivide" => 98, + "PrintScreen" => 99, + "AltRight" => 100, + "Home" => 102, + "ArrowUp" => 103, + "PageUp" => 104, + "ArrowLeft" => 105, + "ArrowRight" => 106, + "End" => 107, + "ArrowDown" => 108, + "PageDown" => 109, + "Insert" => 110, + "Delete" => 111, + "Pause" => 119, + "MetaLeft" => 125, + "MetaRight" => 126, + "ContextMenu" => 127, + _ => 0, + } +} diff --git a/src/platform/web/log.rs b/src/platform/web/log.rs new file mode 100644 index 0000000..1838295 --- /dev/null +++ b/src/platform/web/log.rs @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Panics into the browser's console, which otherwise only shows the trap they end in. Standard output and error go nowhere in `wasm32-unknown-unknown`. + +use std::sync::Once; + +pub( super ) fn install_panic_hook() +{ + static HOOK: Once = Once::new(); + HOOK.call_once( || + { + std::panic::set_hook( Box::new( |info| web_sys::console::error_1( &info.to_string().into() ) ) ); + } ); +} diff --git a/src/platform/web/mod.rs b/src/platform/web/mod.rs new file mode 100644 index 0000000..dca453c --- /dev/null +++ b/src/platform/web/mod.rs @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The browser backend: `ltk::run` on a page's canvas, built for `wasm32-unknown-unknown`. +//! +//! | File | What it does for the runtime | +//! | --- | --- | +//! | `assets.rs` | The themes, fonts and keyboard layouts a page serves, fetched into memory as the app's resource directory | +//! | `log.rs` | Panics into the browser's console | +//! | `state.rs` | [`WebPlatform`]: the canvas, its WebGL2 or 2D context, and what the runtime asked of the page | +//! | `surface.rs` | [`WebSurfaceState`]; sizing the main surface to the canvas | +//! | `present.rs` | Putting a painted canvas on the page: the GPU canvas's blit, or the software one's pixels through `putImageData` | +//! | `input.rs`, `keys.rs` | The canvas's pointer, wheel, key and focus events turned into [`crate::input::event`], `KeyboardEvent.key` into keysyms and `.code` into Linux input codes | +//! | `cursor.rs` | The cursor's shapes as CSS names them | +//! | `clipboard.rs` | Copies to the system clipboard; pastes come from the page's `paste` event | +//! | `run.rs` | `ltk::run`: the loop that `setTimeout` and `requestAnimationFrame` drive, and the page's event listeners | +//! +//! The page has one surface, the main one, on one canvas. Overlays, secondary surfaces and subsurfaces are not created. + +mod assets; +mod clipboard; +mod cursor; +mod input; +mod keys; +mod log; +mod present; +pub( crate ) mod run; +mod state; +mod surface; + +pub use assets::load_resources; +pub use run::set_canvas; +pub( crate ) use state::WebPlatform; +pub( crate ) use surface::WebSurfaceState; + +use crate::app::App; +use crate::event_loop::SurfaceFocus; +use crate::event_loop::cursor_shape::ResizeEdge; +use crate::osk::Purpose; +use crate::render::Canvas; +use crate::types::CursorShape; + +use super::{ Platform, Presentation }; + +impl Platform for WebPlatform +{ + type Surface = WebSurfaceState; + type Output = (); + + fn is_toplevel( &self, _main: &WebSurfaceState ) -> bool + { + false + } + + fn back_leaves( &self, _main: &WebSurfaceState ) -> bool + { + false + } + + fn begin_window_move( &self, _main: &WebSurfaceState ) {} + + fn begin_window_resize( &self, _main: &WebSurfaceState, _edge: ResizeEdge ) {} + + fn minimize_window( &self, _main: &WebSurfaceState ) {} + + fn set_fullscreen( &self, _main: &WebSurfaceState, fullscreen: bool ) -> bool + { + lock_keyboard( fullscreen ); + if fullscreen + { + let _ = self.canvas.request_fullscreen(); + } else if let Some( document ) = self.canvas.owner_document() { + document.exit_fullscreen(); + } + true + } + + fn request_frame( &self, _surface: &WebSurfaceState, focus: SurfaceFocus ) -> bool + { + self.frame_due.set( Some( focus ) ); + true + } + + fn precise_damage( &self, _surface: &WebSurfaceState ) -> bool + { + false + } + + fn prepare_canvas( &self, surface: &mut WebSurfaceState, canvas: &mut Option, pw: u32, ph: u32, scale: f32 ) -> bool + { + present::prepare_canvas( self, surface, canvas, pw, ph, scale ) + } + + fn present( &self, surface: &mut WebSurfaceState, canvas: &mut Canvas, frame: &Presentation<'_> ) -> bool + { + present::present( self, surface, canvas, frame ) + } + + fn publish_clipboard( &mut self, text: &str ) + { + clipboard::write( text ); + } + + fn publish_primary_selection( &mut self, _text: &str ) {} + + fn set_cursor_shape( &mut self, shape: CursorShape ) -> bool + { + self.canvas.style().set_property( "cursor", cursor::css( shape ) ).is_ok() + } + + fn text_input_active( &self ) -> bool + { + self.text_input + } + + fn activate_text_input( &mut self, _secure: bool, _purpose: Purpose ) + { + self.text_input = true; + } + + fn deactivate_text_input( &mut self ) + { + self.text_input = false; + } + + fn reenable_text_input( &self, _secure: bool, _purpose: Purpose ) {} +} + +/// Let the page have the keys the browser keeps for itself (Ctrl+W, Ctrl+T, Escape) while it is fullscreen, through `navigator.keyboard`, which only Chromium has and web-sys keeps behind its unstable APIs. +fn lock_keyboard( lock: bool ) +{ + use wasm_bindgen::{ JsCast, JsValue }; + let Some( window ) = web_sys::window() else { return }; + let Ok( keyboard ) = js_sys::Reflect::get( &window.navigator(), &JsValue::from_str( "keyboard" ) ) else { return }; + if keyboard.is_undefined() { return; } + let method = if lock { "lock" } else { "unlock" }; + let Ok( call ) = js_sys::Reflect::get( &keyboard, &JsValue::from_str( method ) ) else { return }; + if let Some( call ) = call.dyn_ref::() { let _ = call.call0( &keyboard ); } +} diff --git a/src/platform/web/present.rs b/src/platform/web/present.rs new file mode 100644 index 0000000..0a88ad9 --- /dev/null +++ b/src/platform/web/present.rs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Putting a painted canvas on the page: the GPU canvas blits its FBO onto the WebGL2 drawing buffer, which the browser shows when the task ends; the software one's pixels go through `putImageData`. The browser may take the WebGL context away, and give it back, at any time. + +use std::sync::Arc; + +use wasm_bindgen::Clamped; +use web_sys::ImageData; + +use crate::app::App; +use crate::draw::fit_canvas; +use crate::event_loop::AppData; +use crate::gles_render::GlesVersion; +use crate::platform::Presentation; +use crate::render::Canvas; + +use super::{ WebPlatform, WebSurfaceState }; + +pub( super ) fn prepare_canvas +( + platform: &WebPlatform, + _surface: &mut WebSurfaceState, + canvas: &mut Option, + pw: u32, + ph: u32, + scale: f32, +) -> bool +{ + if platform.context_lost { return false; } + if platform.canvas.width() != pw { platform.canvas.set_width( pw ); } + if platform.canvas.height() != ph { platform.canvas.set_height( ph ); } + match platform.gl.as_ref() + { + Some( gl ) => fit_canvas( canvas, pw, ph, scale, || Canvas::new_gles( Arc::clone( gl ), GlesVersion::V3, pw, ph ) ), + None => fit_canvas( canvas, pw, ph, scale, || Canvas::new( pw, ph ) ), + }; + true +} + +pub( super ) fn present +( + platform: &WebPlatform, + surface: &mut WebSurfaceState, + canvas: &mut Canvas, + frame: &Presentation<'_>, +) -> bool +{ + let shown = match platform.ctx2d.as_ref() + { + None => + { + canvas.present(); + true + } + Some( ctx ) => present_software( ctx, surface, canvas ), + }; + if shown && frame.frame.is_some() { platform.frame_due.set( frame.frame ); } + shown +} + +fn present_software( ctx: &web_sys::CanvasRenderingContext2d, surface: &mut WebSurfaceState, canvas: &Canvas ) -> bool +{ + let ( pw, ph ) = canvas.size(); + surface.pixels.resize( pw as usize * ph as usize * 4, 0 ); + canvas.write_to_wayland_buf( &mut surface.pixels, false ); + unpremultiply( &mut surface.pixels ); + let Ok( image ) = ImageData::new_with_u8_clamped_array_and_sh( Clamped( &surface.pixels ), pw, ph ) else { return false }; + ctx.put_image_data( &image, 0.0, 0.0 ).is_ok() +} + +impl AppData +{ + /// Every texture, framebuffer and program went with the context: drop the canvases built on it. + pub( super ) fn webgl_context_lost( &mut self ) + { + self.platform.context_lost = true; + self.main.canvas = None; + self.main.frame.scroll_canvases.clear(); + self.main.frame.viewport_canvases.clear(); + } + + /// Draw again on a fresh `glow::Context`, so the glyph atlas and the caches the old one kept are not found for it. + pub( super ) fn webgl_context_restored( &mut self ) + { + let Some( webgl ) = self.platform.webgl.clone() else { return }; + self.platform.gl = Some( Arc::new( glow::Context::from_webgl2_context( webgl ) ) ); + self.platform.context_lost = false; + self.main.request_redraw(); + } +} + +/// `ImageData` holds straight alpha; the canvas, premultiplied. +fn unpremultiply( rgba: &mut [u8] ) +{ + for px in rgba.chunks_exact_mut( 4 ) + { + let a = px[ 3 ] as u16; + if a == 0 || a == 255 { continue; } + for c in &mut px[ .. 3 ] + { + *c = ( ( *c as u16 * 255 + a / 2 ) / a ).min( 255 ) as u8; + } + } +} diff --git a/src/platform/web/run.rs b/src/platform/web/run.rs new file mode 100644 index 0000000..97462ff --- /dev/null +++ b/src/platform/web/run.rs @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! `ltk::run` in a browser. +//! +//! A page's main thread must not block, so `run` sets the app up and returns, and the browser drives the loop: `AppData` lives in a closure a thread-local [`Loop`] holds, which a `setTimeout` runs for messages, timers and page events and a `requestAnimationFrame` runs to draw. Page events and `ChannelSender::send` only queue and schedule, never touching `AppData`, so nothing they set off can run inside an iteration. + +use std::cell::{ Cell, RefCell }; +use std::collections::VecDeque; +use std::rc::Rc; +use std::time::Duration; + +use wasm_bindgen::closure::Closure; +use wasm_bindgen::{ JsCast, JsValue }; +use web_sys::{ CanvasRenderingContext2d, Document, Event, EventTarget, HtmlCanvasElement, WebGl2RenderingContext }; + +use crate::app::App; +use crate::event_loop::{ AppData, RunError, Sources, SurfaceState }; + +use super::input::WebInput; +use super::{ WebPlatform, WebSurfaceState }; + +/// What the page reports between iterations. +pub( super ) enum PageEvent +{ + Resized, + Input( WebInput ), + ContextLost, + ContextRestored, +} + +/// An event listener, registered while it lives in `WebPlatform::listeners`. +pub( crate ) struct Listener +{ + target: EventTarget, + kind: &'static str, + closure: Closure, +} + +impl Listener +{ + pub( super ) fn new( target: &EventTarget, kind: &'static str, handle: impl FnMut( Event ) + 'static ) -> Result + { + let closure = Closure::::new( handle ); + target.add_event_listener_with_callback( kind, closure.as_ref().unchecked_ref() ).map_err( |_| RunError::NoDocument )?; + Ok( Self { target: target.clone(), kind, closure } ) + } + + pub( super ) fn remove( &self ) + { + let _ = self.target.remove_event_listener_with_callback( self.kind, self.closure.as_ref().unchecked_ref() ); + } +} + +/// What an iteration asks of the next. +struct Next +{ + frame: bool, + after: Option, + exit: bool, +} + +struct Loop +{ + iterate: RefCell Next>>, + /// The pending timeout and when it fires, in milliseconds since the epoch. + timeout: Cell>, + frame: Cell>, + ended: Cell, + on_timeout: Closure, + on_frame: Closure, +} + +thread_local! +{ + static LOOP: RefCell>> = const { RefCell::new( None ) }; + static EVENTS: RefCell> = const { RefCell::new( VecDeque::new() ) }; + static CANVAS: RefCell> = const { RefCell::new( None ) }; +} + +/// Draw the app on `canvas` rather than on one ltk lays over the whole page. Call it before `ltk::run`. +pub fn set_canvas( canvas: HtmlCanvasElement ) +{ + CANVAS.with( |c| *c.borrow_mut() = Some( canvas ) ); +} + +pub( crate ) fn run( app: A ) +{ + if let Err( e ) = try_run( app ) + { + panic!( "ltk::run failed during init: {e}" ); + } +} + +/// Set the app up on the page and return; the browser runs it from then on. +pub( crate ) fn try_run( app: A ) -> Result<(), RunError> +{ + super::log::install_panic_hook(); + let window = web_sys::window().ok_or( RunError::NoDocument )?; + let document = window.document().ok_or( RunError::NoDocument )?; + let ( canvas, own ) = match CANVAS.with( |c| c.borrow_mut().take() ) + { + Some( canvas ) => ( canvas, false ), + None => ( page_canvas( &document )?, true ), + }; + canvas.set_tab_index( 0 ); + let style = canvas.style(); + let _ = style.set_property( "touch-action", "none" ); + let _ = style.set_property( "outline", "none" ); + + let webgl = webgl2( &canvas ); + let ctx2d = if webgl.is_none() { context_2d( &canvas ) } else { None }; + if webgl.is_none() { web_sys::console::warn_1( &"[ltk] no WebGL2 context: drawing in software".into() ); } + crate::render::set_software_render( webgl.is_none() ); + + let mut listeners = super::input::listen( &canvas )?; + listeners.push( Listener::new( &window, "resize", |_| push( PageEvent::Resized ) )? ); + if webgl.is_some() + { + // Without `preventDefault` the browser never gives the context back. + listeners.push( Listener::new( &canvas, "webglcontextlost", |event| + { + event.prevent_default(); + push( PageEvent::ContextLost ); + } )? ); + listeners.push( Listener::new( &canvas, "webglcontextrestored", |_| push( PageEvent::ContextRestored ) )? ); + } + let inset_probe = if own { Some( inset_probe( &document )? ) } else { None }; + if own { let _ = canvas.focus(); } + + let coarse = window.match_media( "(pointer: coarse)" ).ok().flatten().is_some_and( |m| m.matches() ); + let platform = WebPlatform::new( canvas, webgl, ctx2d, listeners, inset_probe ); + let main = SurfaceState::new( WebSurfaceState::default(), 0.0, String::new() ); + // ltk's own keyboard where the page is used by touch, since the page cannot raise the system's for a canvas. + let mut data = AppData::new( app, platform, main, Sources::new( wake ), coarse, None ); + data.install_sources()?; + data.fit_canvas_element(); + + let mut data = Some( data ); + let iterate: Box Next> = Box::new( move |in_frame| + { + let Some( app ) = data.as_mut() else { return Next { frame: false, after: None, exit: true } }; + let next = app.iterate( in_frame ); + if next.exit { data = None; } + next + } ); + let lp = Loop + { + iterate: RefCell::new( iterate ), + timeout: Cell::new( None ), + frame: Cell::new( None ), + ended: Cell::new( false ), + on_timeout: Closure::new( || run_iteration( false ) ), + on_frame: Closure::new( |_: f64| run_iteration( true ) ), + }; + LOOP.with( |l| *l.borrow_mut() = Some( Rc::new( lp ) ) ); + wake(); + Ok( () ) +} + +/// Ask for an iteration as soon as the page is idle. +pub( super ) fn wake() +{ + with_loop( |lp| lp.schedule_in( 0.0 ) ); +} + +pub( super ) fn push( event: PageEvent ) +{ + EVENTS.with( |q| q.borrow_mut().push_back( event ) ); + wake(); +} + +fn with_loop( f: impl FnOnce( &Loop ) ) +{ + if let Some( lp ) = LOOP.with( |l| l.borrow().clone() ) { f( &lp ); } +} + +fn run_iteration( in_frame: bool ) +{ + with_loop( |lp| + { + if in_frame { lp.frame.set( None ); } else { lp.timeout.set( None ); } + if lp.ended.get() { return; } + let Ok( mut iterate ) = lp.iterate.try_borrow_mut() else { return }; + let next = ( *iterate )( in_frame ); + drop( iterate ); + if next.exit + { + lp.end(); + return; + } + if next.frame { lp.request_frame(); } + if let Some( after ) = next.after { lp.schedule_in( after.as_secs_f64() * 1000.0 ); } + } ); +} + +impl Loop +{ + /// Run an iteration in `ms` milliseconds, unless one is already due sooner. + fn schedule_in( &self, ms: f64 ) + { + if self.ended.get() { return; } + let Some( window ) = web_sys::window() else { return }; + let at = js_sys::Date::now() + ms; + if let Some( ( id, due ) ) = self.timeout.get() + { + if due <= at { return; } + window.clear_timeout_with_handle( id ); + } + let delay = ms.ceil().clamp( 0.0, i32::MAX as f64 ) as i32; + if let Ok( id ) = window.set_timeout_with_callback_and_timeout_and_arguments_0( self.on_timeout.as_ref().unchecked_ref(), delay ) + { + self.timeout.set( Some( ( id, at ) ) ); + } + } + + fn request_frame( &self ) + { + if self.frame.get().is_some() { return; } + let Some( window ) = web_sys::window() else { return }; + if let Ok( id ) = window.request_animation_frame( self.on_frame.as_ref().unchecked_ref() ) + { + self.frame.set( Some( id ) ); + } + } + + /// Run no more iterations. The closures stay alive, since one of them is running. + fn end( &self ) + { + self.ended.set( true ); + let Some( window ) = web_sys::window() else { return }; + if let Some( ( id, _ ) ) = self.timeout.take() { window.clear_timeout_with_handle( id ); } + if let Some( id ) = self.frame.take() { let _ = window.cancel_animation_frame( id ); } + } +} + +impl AppData +{ + /// What the page reported, the due timers and messages, then the runtime's steps; drawing only in an animation frame. + fn iterate( &mut self, in_frame: bool ) -> Next + { + while let Some( event ) = EVENTS.with( |q| q.borrow_mut().pop_front() ) + { + match event + { + PageEvent::Resized => self.fit_canvas_element(), + PageEvent::Input( input ) => self.web_input( input ), + PageEvent::ContextLost => self.webgl_context_lost(), + PageEvent::ContextRestored => self.webgl_context_restored(), + } + } + self.dispatch_sources(); + if in_frame + { + if let Some( focus ) = self.platform.frame_due.take() { self.frame_presented( focus, false ); } + } + + self.before_draw(); + let drawn = in_frame && self.draw_due(); + self.after_draw( drawn ); + + if self.exit_requested + { + self.leave_page(); + return Next { frame: false, after: None, exit: true }; + } + let main = &self.main; + let frame = ( main.configured && main.needs_redraw && !main.frame_pending ) || self.platform.frame_due.get().is_some(); + let now = crate::time::Instant::now(); + let after = [ self.next_wakeup(), self.sources.next_due().map( |due| due.saturating_duration_since( now ) ) ] + .into_iter() + .flatten() + .min(); + Next { frame, after, exit: false } + } + + fn dispatch_sources( &mut self ) + { + for ( id, mut tick ) in self.sources.take_due( crate::time::Instant::now() ) + { + let next = tick( self ); + self.sources.rearm( id, tick, next ); + } + let mut drains = self.sources.take_channels(); + for drain in &mut drains + { + drain( self ); + } + self.sources.restore_channels( drains ); + } + + /// Stop listening to the page and tell it, with an `ltk-exit` event on the canvas, that the app has ended. + fn leave_page( &mut self ) + { + for listener in &self.platform.listeners + { + listener.remove(); + } + if let Some( probe ) = self.platform.inset_probe.take() { probe.remove(); } + if let Ok( event ) = Event::new( "ltk-exit" ) + { + let _ = self.platform.canvas.dispatch_event( &event ); + } + } +} + +/// A canvas over the whole page, for an app that named none. +fn page_canvas( document: &Document ) -> Result +{ + let canvas: HtmlCanvasElement = document.create_element( "canvas" ).ok() + .and_then( |e| e.dyn_into().ok() ) + .ok_or( RunError::NoDocument )?; + let _ = canvas.set_attribute( "style", "position: fixed; inset: 0; width: 100%; height: 100%; display: block; touch-action: none" ); + document.body().ok_or( RunError::NoDocument )?.append_child( &canvas ).map_err( |_| RunError::NoDocument )?; + Ok( canvas ) +} + +/// An invisible element padded by the page's safe-area insets, read back as numbers by `fit_canvas_element`. +fn inset_probe( document: &Document ) -> Result +{ + let probe = document.create_element( "div" ).map_err( |_| RunError::NoDocument )?; + let _ = probe.set_attribute( "style", "position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; \ + padding: env( safe-area-inset-top ) env( safe-area-inset-right ) env( safe-area-inset-bottom ) env( safe-area-inset-left )" ); + document.body().ok_or( RunError::NoDocument )?.append_child( &probe ).map_err( |_| RunError::NoDocument )?; + Ok( probe ) +} + +/// The WebGL2 context ltk's GPU canvas draws through: premultiplied alpha as it writes it, and no depth, stencil or multisampling, which it does not use. +fn webgl2( canvas: &HtmlCanvasElement ) -> Option +{ + let options = js_sys::Object::new(); + for ( key, value ) in [ ( "alpha", true ), ( "premultipliedAlpha", true ), ( "antialias", false ), ( "depth", false ), ( "stencil", false ), ( "preserveDrawingBuffer", false ) ] + { + js_sys::Reflect::set( &options, &JsValue::from_str( key ), &JsValue::from_bool( value ) ).ok()?; + } + canvas.get_context_with_context_options( "webgl2", &options ).ok()??.dyn_into().ok() +} + +fn context_2d( canvas: &HtmlCanvasElement ) -> Option +{ + canvas.get_context( "2d" ).ok()??.dyn_into().ok() +} diff --git a/src/platform/web/state.rs b/src/platform/web/state.rs new file mode 100644 index 0000000..abde3ef --- /dev/null +++ b/src/platform/web/state.rs @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use std::cell::Cell; +use std::marker::PhantomData; +use std::sync::Arc; + +use web_sys::{ CanvasRenderingContext2d, Element, HtmlCanvasElement, WebGl2RenderingContext }; + +use crate::app::App; +use crate::event_loop::SurfaceFocus; + +use super::run::Listener; + +/// What the runtime's requests go through in a browser: the canvas, its context and what the loop must report back to the runtime. +pub( crate ) struct WebPlatform +{ + pub canvas: HtmlCanvasElement, + /// The canvas's WebGL2 context; `None` when the browser has none, and the canvas is drawn through `ctx2d`. + pub webgl: Option, + /// `webgl` as the GPU canvas draws through it, wrapped anew when the context is restored. + pub gl: Option>, + pub ctx2d: Option, + /// The browser took the WebGL context away; nothing is drawn until it gives it back. + pub context_lost: bool, + /// A hidden element padded by `env( safe-area-inset-* )`, which tells what a phone's notch and bars cover; only on ltk's own canvas over the page. + pub inset_probe: Option, + /// A frame was presented or asked for; the loop reports it at the next animation frame. + pub frame_due: Cell>, + /// Text input was asked for and not ended; ltk's own keyboard serves it. + pub text_input: bool, + /// The canvas's input and the window's `resize`, removed when the app exits. + pub listeners: Vec, + _app: PhantomData A>, +} + +impl WebPlatform +{ + pub( crate ) fn new( canvas: HtmlCanvasElement, webgl: Option, ctx2d: Option, listeners: Vec, inset_probe: Option ) -> Self + { + let gl = webgl.clone().map( |context| Arc::new( glow::Context::from_webgl2_context( context ) ) ); + Self + { + canvas, + webgl, + gl, + ctx2d, + context_lost: false, + inset_probe, + frame_due: Cell::new( None ), + text_input: false, + listeners, + _app: PhantomData, + } + } +} diff --git a/src/platform/web/surface.rs b/src/platform/web/surface.rs new file mode 100644 index 0000000..ddaddf0 --- /dev/null +++ b/src/platform/web/surface.rs @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use web_sys::Element; + +use crate::app::App; +use crate::event_loop::AppData; +use crate::event_loop::surface::Insets; + +/// The main surface's browser half. +#[ derive( Default ) ] +pub( crate ) struct WebSurfaceState +{ + /// The software renderer's frame, unpremultiplied for `putImageData`. + pub pixels: Vec, +} + +/// The safe-area insets `probe` is padded by, in CSS pixels; none without a probe. +fn safe_area( probe: Option<&Element> ) -> Insets +{ + let ( Some( probe ), Some( window ) ) = ( probe, web_sys::window() ) else { return Insets::default() }; + let Ok( Some( style ) ) = window.get_computed_style( probe ) else { return Insets::default() }; + let side = |name: &str| style.get_property_value( name ).ok() + .and_then( |value| value.trim().trim_end_matches( "px" ).parse::().ok() ) + .unwrap_or( 0.0 ); + Insets { top: side( "padding-top" ), right: side( "padding-right" ), bottom: side( "padding-bottom" ), left: side( "padding-left" ) } +} + +impl AppData +{ + /// Size the main surface to the canvas, whose CSS pixels are the surface's logical ones at the page's `devicePixelRatio`, so the canvas holds the display's own pixels, and keep the view out of what a phone's notch and bars cover. + pub( super ) fn fit_canvas_element( &mut self ) + { + let insets = safe_area( self.platform.inset_probe.as_ref() ); + if insets != self.main.insets + { + self.main.insets = insets; + self.main.request_redraw(); + } + + let canvas = &self.platform.canvas; + let ( w, h ) = ( canvas.client_width().max( 1 ) as u32, canvas.client_height().max( 1 ) as u32 ); + let scale = web_sys::window().map_or( 1.0, |w| w.device_pixel_ratio() ) as f32; + if self.main.configured && ( w, h ) == ( self.main.width, self.main.height ) && scale == self.main.scale_factor { return; } + + if scale != self.main.scale_factor + { + self.main.scale_factor = scale; + if let Some( ref mut canvas ) = self.main.canvas { canvas.set_dpi_scale( scale ); } + } + self.main.set_size( w, h ); + self.on_main_resized( w, h ); + } +} diff --git a/src/resources.rs b/src/resources.rs index 4b54141..e3cc322 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -31,12 +31,12 @@ pub( crate ) fn read_font( path: &Path ) -> io::Result> fn read_font_in( path: &Path, bundle: Option<&Path> ) -> io::Result> { - match std::fs::read( path ) + match crate::vfs::read( path ) { Err( e ) if e.kind() == io::ErrorKind::NotFound => { let Some( ( dir, name ) ) = bundle.zip( path.file_name() ) else { return Err( e ) }; - std::fs::read( dir.join( "fonts" ).join( name ) ) + crate::vfs::read( dir.join( "fonts" ).join( name ) ) } read => read, } diff --git a/src/system_fonts.rs b/src/system_fonts.rs index 1f21a82..4bf5b28 100644 --- a/src/system_fonts.rs +++ b/src/system_fonts.rs @@ -58,7 +58,9 @@ impl FontHandle /// Per-script fallback font path with the TrueType-collection face /// index to load (most files are single-face → 0; CJK `.ttc` archives /// carry many faces, see the SC face on the canonical Adobe-built -/// `NotoSansCJK-Regular.ttc`). +/// `NotoSansCJK-Regular.ttc`). A path that does not exist is looked up by +/// its file name in the resource directory's `fonts/`, which is where a +/// browser finds the fallbacks a page bundles. struct FallbackFontSpec { path: &'static str, @@ -152,7 +154,7 @@ fn slot_bytes( idx: usize ) -> Option>> { slots()[ idx ].bytes.get_or_init( || { - std::fs::read( FALLBACK_FONT_CANDIDATES[ idx ].path ).ok().map( Arc::new ) + crate::resources::read_font( std::path::Path::new( FALLBACK_FONT_CANDIDATES[ idx ].path ) ).ok().map( Arc::new ) } ) .clone() } @@ -225,7 +227,7 @@ const SYSTEM_FONT_CANDIDATES: &[&str] = &[]; pub ( crate ) fn find_font_opt() -> Option { SYSTEM_FONT_CANDIDATES.iter() - .find( |p| std::path::Path::new( p ).exists() ) + .find( |p| crate::vfs::is_file( p ) ) .copied() .map( str::to_string ) } @@ -239,7 +241,7 @@ pub ( crate ) fn load_default_font_bytes() -> Vec { if let Some( path ) = find_font_opt() { - if let Ok( bytes ) = std::fs::read( &path ) + if let Ok( bytes ) = crate::vfs::read( &path ) { return bytes; } diff --git a/src/theme/assets.rs b/src/theme/assets.rs index ed10e9b..40281d8 100644 --- a/src/theme/assets.rs +++ b/src/theme/assets.rs @@ -65,7 +65,7 @@ pub fn app_icon( name: &str ) -> Option let state = ensure_active(); let root = state.document.root.as_ref()?; let path = root.join( "icons/apps" ).join( format!( "{}.svg", name ) ); - if path.is_file() { Some( path ) } else { None } + if crate::vfs::is_file( &path ) { Some( path ) } else { None } } /// Path to `app-default.svg` inside the active theme's icon directory. @@ -76,7 +76,7 @@ pub fn app_default_icon() -> Option let state = ensure_active(); let root = state.document.root.as_ref()?; let path = root.join( "icons/app-default.svg" ); - if path.is_file() { Some( path ) } else { None } + if crate::vfs::is_file( &path ) { Some( path ) } else { None } } /// Path to the launcher-logo SVG for the currently active mode. @@ -149,11 +149,11 @@ pub fn branding_asset( name: &str, ext: &str ) -> Option ThemeMode::Light => ( "light", "dark" ), }; let preferred = branding.join( pref_dir ).join( &filename ); - if preferred.is_file() { return Some( preferred ); } + if crate::vfs::is_file( &preferred ) { return Some( preferred ); } let alternate = branding.join( alt_dir ).join( &filename ); - if alternate.is_file() { return Some( alternate ); } + if crate::vfs::is_file( &alternate ) { return Some( alternate ); } let modeless = branding.join( &filename ); - if modeless.is_file() { return Some( modeless ); } + if crate::vfs::is_file( &modeless ) { return Some( modeless ); } None } @@ -206,7 +206,7 @@ pub fn branding_raster( name: &str, sw: u32, sh: u32 ) -> Option ]; for dir in &candidates { - if !dir.is_dir() { continue; } + if !crate::vfs::is_dir( dir ) { continue; } if let Some( best ) = pick_best_raster( dir, sw, sh ) { return Some( best ); @@ -238,12 +238,11 @@ pub fn branding_image( name: &str, sw: u32, sh: u32 ) -> Option /// when the directory holds no parseable raster files. fn pick_best_raster( dir: &Path, sw: u32, sh: u32 ) -> Option { - let entries = std::fs::read_dir( dir ).ok()?; + let entries = crate::vfs::read_dir( dir ).ok()?; let mut covering: Option<( u32, u32, PathBuf )> = None; let mut fallback: Option<( u32, u32, PathBuf )> = None; - for entry in entries.flatten() + for path in entries { - let path = entry.path(); let Some( stem ) = path.file_stem().and_then( |s| s.to_str() ) else { continue }; let ext = path.extension().and_then( |e| e.to_str() ).unwrap_or( "" ); if !matches!( ext.to_ascii_lowercase().as_str(), "webp" | "png" | "jpg" | "jpeg" ) @@ -407,9 +406,9 @@ pub fn icon_path( name: &str ) -> Option let state = ensure_active(); let root = state.document.root.as_ref()?; let filled = root.join( "icons/catalogue/filled" ).join( format!( "{name}.svg" ) ); - if filled.is_file() { return Some( filled ); } + if crate::vfs::is_file( &filled ) { return Some( filled ); } let line = root.join( "icons/catalogue/line" ).join( format!( "{name}.svg" ) ); - if line.is_file() { return Some( line ); } + if crate::vfs::is_file( &line ) { return Some( line ); } None } @@ -438,7 +437,7 @@ pub fn icon_rgba( name: &str, size: u32 ) -> Option<( Arc>, u32, u32 )> } // Slow path: read + decode + populate cache. - let bytes = std::fs::read( &path ).ok()?; + let bytes = crate::vfs::read( &path ).ok()?; let result = decode_svg_bytes( &bytes, size )?; if let Ok( mut guard ) = SVG_CACHE.lock() { diff --git a/src/theme/document.rs b/src/theme/document.rs index 888634b..ace619b 100644 --- a/src/theme/document.rs +++ b/src/theme/document.rs @@ -102,7 +102,7 @@ impl ThemeDocument for base in search_paths() { let dir = base.join( id ); - if dir.join( "theme.json" ).is_file() + if crate::vfs::is_file( dir.join( "theme.json" ) ) { return Self::load_from_dir( &dir ); } diff --git a/src/theme/schema/mod.rs b/src/theme/schema/mod.rs index 3313153..f448731 100644 --- a/src/theme/schema/mod.rs +++ b/src/theme/schema/mod.rs @@ -375,7 +375,7 @@ pub fn parse_document_json( text: &str, root: Option<&Path> ) pub fn load_document_from_dir( dir: &Path ) -> Result { let json_path = dir.join( "theme.json" ); - let text = std::fs::read_to_string( &json_path ) + let text = crate::vfs::read_to_string( &json_path ) .map_err( |e| ThemeError::Io( json_path.clone(), e ) )?; parse_document_json( &text, Some( dir ) ) } diff --git a/src/time.rs b/src/time.rs new file mode 100644 index 0000000..516ab08 --- /dev/null +++ b/src/time.rs @@ -0,0 +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. + +#[ cfg( not( target_arch = "wasm32" ) ) ] +pub( crate ) use std::time::Instant; +#[ cfg( target_arch = "wasm32" ) ] +pub( crate ) use web_time::Instant; diff --git a/src/vfs.rs b/src/vfs.rs new file mode 100644 index 0000000..2a13745 --- /dev/null +++ b/src/vfs.rs @@ -0,0 +1,86 @@ +// 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. + +use std::io; +use std::path::{ Path, PathBuf }; + +pub( crate ) 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> +{ + 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> +{ + 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 +{ + path.as_ref().is_file() +} + +#[ cfg( not( target_arch = "wasm32" ) ) ] +pub( crate ) fn is_dir( path: impl AsRef ) -> bool +{ + path.as_ref().is_dir() +} + +#[ cfg( target_arch = "wasm32" ) ] +static FILES: std::sync::Mutex>> = std::sync::Mutex::new( std::collections::BTreeMap::new() ); + +#[ cfg( target_arch = "wasm32" ) ] +fn files() -> std::sync::MutexGuard<'static, std::collections::BTreeMap>> +{ + FILES.lock().unwrap_or_else( std::sync::PoisonError::into_inner ) +} + +/// Make `bytes` the contents of the file at `path`. +#[ cfg( target_arch = "wasm32" ) ] +pub( crate ) fn insert( path: impl Into, bytes: Vec ) +{ + files().insert( path.into(), bytes.into() ); +} + +#[ cfg( target_arch = "wasm32" ) ] +pub( crate ) 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> +{ + let dir = dir.as_ref(); + let mut entries: Vec = files().keys() + .filter_map( |path| path.strip_prefix( dir ).ok()?.components().next() ) + .map( |first| dir.join( first ) ) + .collect(); + entries.dedup(); + if entries.is_empty() { return Err( io::ErrorKind::NotFound.into() ); } + Ok( entries ) +} + +#[ cfg( target_arch = "wasm32" ) ] +pub( crate ) 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 +{ + let path = path.as_ref(); + files().keys().any( |file| file != path && file.starts_with( path ) ) +} diff --git a/src/wallpaper.rs b/src/wallpaper.rs index b9a4eb3..3080343 100644 --- a/src/wallpaper.rs +++ b/src/wallpaper.rs @@ -179,10 +179,7 @@ fn decode_bytes( bytes: &[u8] ) -> Result fn decode_path( path: &Path ) -> Result { - use image::GenericImageView as _; - let img = image::open( path )?; - let ( w, h ) = img.dimensions(); - Ok( ( Arc::new( img.to_rgba8().into_raw() ), w, h ) ) + decode_bytes( &crate::vfs::read( path )? ) } /// Take the left-most slice of `src` whose width matches the `(target_w, target_h)` diff --git a/src/widget/image/mod.rs b/src/widget/image/mod.rs index d492a93..e0934d8 100644 --- a/src/widget/image/mod.rs +++ b/src/widget/image/mod.rs @@ -62,7 +62,7 @@ impl Image /// supported by the [`image`](https://crates.io/crates/image) crate. pub fn from_path( path: &str ) -> Result> { - let img = image::open( path )?.into_rgba8(); + let img = image::load_from_memory( &crate::vfs::read( path )? )?.into_rgba8(); let ( width, height ) = img.dimensions(); Ok( Self { rgba: Arc::new( img.into_raw() ), width, height, cover: false, display_size: None, short_side: None, opacity: 1.0 } ) } diff --git a/src/widget/tab_strip/motion.rs b/src/widget/tab_strip/motion.rs index fa2e9be..57312cb 100644 --- a/src/widget/tab_strip/motion.rs +++ b/src/widget/tab_strip/motion.rs @@ -5,8 +5,8 @@ //! dragged. use std::sync::{ Arc, Mutex, MutexGuard, PoisonError }; -use std::time::Instant; +use crate::time::Instant; use crate::types::Rect; use crate::widget::scroll::offset_into_view; diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..5fa8d94 --- /dev/null +++ b/web/index.html @@ -0,0 +1,17 @@ + + + + + + ltk hello + + + + + +