From cacf871ddd8b2c6a153ab3aa2864237cbce174c5 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Fri, 16 Oct 2026 23:46:50 +0200 Subject: [PATCH] An Android backend, a platform layer that keeps Wayland out of the runtime, the built-in keyboard for an app's own text input, and APKs packaged with Debian's own tools ltk only ran on a Wayland compositor: the runtime, the input handlers and the draw paths named smithay-client-toolkit's types throughout, so no other windowing system could be added without touching all of them. Prompt, the Eydos terminal, has to run on an Android phone, with its sessions and its sharing, and needs a toolkit that runs there. The platform layer. 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 few entry points (`pointer_input`, `touch_input`, `key_press`, `key_release`, `key_repeated`, `modifiers_changed`, `keyboard_enter`, `keyboard_leave`, `set_repeat_info`). The sctk handler implementations moved to `platform/wayland/input.rs`, which records the serials later requests need, resolves surfaces and translates. What the runtime asks of the windowing system (moving, resizing, minimising and fullscreening the window, a bare frame request, the cursor's shape, the input method, publishing the clipboard and the primary selection, preparing a canvas, presenting it and its damage) forms the `platform::Platform` trait, with its surface type as `Platform::Surface`. The protocol state moved from `AppData` into `platform::wayland::WaylandPlatform`, held as `AppData::platform`, and each surface's Wayland half (its kind, SHM pool, EGL window surface and what the compositor was last told) from `SurfaceState` into `WaylandSurfaceState`, held as `SurfaceState::platform`. The compositor, shell, seat and output handlers, the data device, the primary selection, the run loop, overlay and subsurface reconciliation, session management, output probing and window fitting moved from `event_loop/` to `platform/wayland/`. Drawing is split from presenting: `draw/paint.rs` paints a surface 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 presents it (`platform/wayland/present.rs`, which also took the opaque and input regions and `pick_shm_format`). The runtime is generic over the platform, `AppData>` and `SurfaceState`, resolved at compile time. The backend-neutral half of a loop iteration is `event_loop/step.rs` (`process_messages`, `draw_due`, `next_wakeup`, `before_draw`, `after_draw`), and `event_loop/setup.rs` builds the state and the event sources every backend has; the accessibility tree and its actions moved to `event_loop/a11y.rs`. `platform/mock.rs` is a platform that logs what is asked of it, and `event_loop/tests.rs` drives the runtime on it through input, update, painting and presentation. The behaviour on Wayland is unchanged, except that a frame whose presentation fails leaves the next one to repaint in full. `ltk::Keysym` now comes from `xkeysym`, the crate sctk re-exported it from, so it is the same type. Features. `wayland`, on by default, brings the Wayland backend and its dependencies, now optional, AT-SPI2 accessibility among them. `android` brings android-activity 0.6, ndk 0.9 and jni 0.21. A new `build.rs` refuses both at once and sets `cfg( backend )` when one is on, which builds the runtime behind `run` and `try_run`. Without a backend only the runtime-free core builds: `UiSurface`, widgets, layout, theming and rendering. The Android backend. `ltk::run` runs inside a NativeActivity once `android_main` has handed its `AndroidApp`, re-exported as `ltk::AndroidApp`, to the new `ltk::set_android_app`; without it `run` fails with `RunError::NoAndroidApp`. The first call in the process sends standard output and error, panics included, to logcat, 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, and unpacks the bundled resources; every call keeps the latest activity, since Android starts the next one in the same process. The backend draws through EGL on the activity's window, or in software into a locked window buffer, and sizes the main surface at the display's density in whole multiples of 160 dpi. Fingers and styluses become touches, a mouse the pointer, and key events keysyms and text through the device's character map. calloop's descriptor rides on the activity's `ALooper`, so the app's channel and timers keep working. Text is typed on ltk's own keyboard, since a NativeActivity gets only key events from the system's. Through JNI on the activity (`platform/android/java.rs`): copies go to `ClipboardManager` and its text is read back whenever the app gains the focus; back with nothing left to go back to minimises the app with `moveTaskToBack` or closes it, as `App::back_at_root` says, which the new `Platform::back_leaves` decides per backend; and the window's insets come from `getRootWindowInsets()` on Android 11 and later, the status bar and any cutout on top and only bars that take taps on the other edges, so there is no gap above the view nor below it in gesture navigation, and a bar of buttons still keeps its room; earlier releases fall back to the content rect. When the app's last surface closes, ltk calls `finishAndRemoveTask` and waits for the activity to be destroyed, since a second activity started in a process whose `android_main` has returned never came up; an app should end the process after `run` returns. `ltk::keep_running( Some( text ) )` starts a foreground service with `text` on an ongoing notification, so Android does not freeze the app once it leaves the screen, and `None` stops it. Surface insets. `SurfaceState::insets` holds the logical pixels the system covers along each edge; the view is laid out inside them while the background fills the whole surface, and positions stay absolute, so hit testing is unchanged. The built-in keyboard is placed inside them too, above a bar of navigation buttons. On Wayland they stay zero. Resources in a package. `ltk::set_resource_dir( dir )` names an app's own copy of ltk's resources and `ltk::resource_dir()` returns it, so an app also finds its own bundled files there. Its `themes/` is searched after the user's themes and before `/usr/share/ltk/themes`, its `keyboards/` before `/usr/share`, and a font a theme names by an absolute path that does not exist is read from its `fonts/` by file name, so a theme written against Debian's paths works unchanged. On Android `set_android_app` unpacks `assets/ltk/` into the internal storage and uses it as the resource directory; `assets/ltk/MANIFEST` lists the tree, since the asset manager cannot list subdirectories, and its first line, a hash of the contents, decides whether to unpack again. On Android the per-script fallback fonts come from `/system/fonts` and the primary face is the embedded Sora. APKs. `scripts/android-assets.sh` builds that tree from installed themes, without their cursors, with the fonts each `theme.json` names and ltk's keyboard layouts. `scripts/android-apk.sh OUT.apk LIB.so PACKAGE LABEL [ASSETS_DIR]` packages an app built as a `cdylib` with Debian's aapt, zipalign and apksigner, with no Gradle and no SDK download: it writes the manifest, with the library loaded as `libmain.so`, optional permissions and a theme without an action bar, which the content rect would otherwise leave room for; links it against `android-framework-res`; adds the assets and the library; aligns and signs, creating a debug keystore when there is none. `ICON` takes an SVG, rasterised with `rsvg-convert` for every density, or a PNG. `KEEP_ALIVE=1` compiles `java/net/liberux/ltk/KeepAlive.java` with javac and `dx` from art-standalone against the platform 34 `android.jar`, against which aapt then links too, since Debian's framework-res predates the `specialUse` service type, and declares the service with `KEEP_ALIVE_USE` as its reason and the permissions it needs. `examples/android_hello.rs` is the smoke test: `make android-check` typechecks the backend with Debian's rustc, `rust-src` and NDK through `-Zbuild-std`, `make android-apk` and `make android-install` package and install the example, `make deps` and `make android-deps` install what the targets need, and `make install` now copies `examples`, `scripts`, `java` and `build.rs` into the registry. The built-in keyboard for 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`, so 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, it only served ltk's text fields, so such an app had no keyboard where the compositor offers no input method. Clippy. `make clippy` is clean again: derived `Default`s replace the hand-written ones, `div_ceil`, `then_some`, struct update syntax for strokes, the map entry API in the text renderer, function paths in place of closures in the examples and tests, and fewer casts and explicit lifetimes. `README.md`, `docs/architecture.md` and `CHANGELOG.md` describe the features, the backend, the events and their units, and what stays with a backend. Not done. On Android there are no overlays, secondary surfaces or subsurfaces, no dead keys from a physical keyboard and no saved state for the activity. Why a second activity in the same process never comes up was not found. The keep-alive service does not ask for the notification permission, so on Android 13 and later its notification may not show until it is granted in the app's settings. --- CHANGELOG.md | 12 + Cargo.toml | 43 +- Makefile | 54 +- README.md | 10 +- build.rs | 18 + docs/architecture.md | 72 +- examples/android_hello.rs | 56 + examples/carousel.rs | 3 +- examples/clip_path.rs | 2 +- examples/dialog.rs | 2 +- examples/sliders.rs | 18 +- java/net/liberux/ltk/KeepAlive.java | 56 + scripts/android-apk.sh | 160 +++ scripts/android-assets.sh | 64 ++ src/a11y/mod.rs | 43 +- src/a11y/tree.rs | 1 + src/app.rs | 11 +- src/draw/chrome.rs | 81 +- src/draw/gles.rs | 218 ---- src/draw/mod.rs | 31 +- src/draw/paint.rs | 147 +++ src/draw/software.rs | 243 ---- src/egl_context.rs | 157 ++- src/event_loop/a11y.rs | 185 +++ src/event_loop/app_data.rs | 264 +---- src/event_loop/clipboard.rs | 40 +- src/event_loop/context_menu.rs | 3 +- src/event_loop/cursor_shape.rs | 77 +- src/event_loop/drag.rs | 7 +- src/event_loop/error.rs | 9 +- src/event_loop/focus.rs | 13 +- src/event_loop/frame.rs | 121 +- src/event_loop/invalidation.rs | 3 +- src/event_loop/mod.rs | 18 +- src/event_loop/osk.rs | 70 +- src/event_loop/repeat.rs | 7 +- src/event_loop/run.rs | 1017 ----------------- src/event_loop/setup.rs | 118 ++ src/event_loop/step.rs | 242 ++++ src/event_loop/surface.rs | 287 +---- src/event_loop/tests.rs | 266 +++++ src/event_loop/text_editing/cursor.rs | 3 +- src/event_loop/text_editing/ime.rs | 91 +- src/event_loop/text_editing/insert_delete.rs | 3 +- src/event_loop/text_editing/scroll.rs | 3 +- src/event_loop/text_editing/selection.rs | 10 +- src/event_loop/tooltip.rs | 7 +- src/font_face.rs | 2 +- src/gles_render/image.rs | 9 +- src/gles_render/primitives.rs | 3 +- src/gles_render/raii.rs | 4 +- src/input/dispatch/coords.rs | 3 +- src/input/dispatch/outcomes.rs | 8 +- src/input/dispatch/password_toggle.rs | 3 +- src/input/event.rs | 244 ++++ src/input/keyboard/back.rs | 17 +- src/input/keyboard/dispatch.rs | 27 +- src/input/keyboard/mod.rs | 194 +--- src/input/keyboard/nav.rs | 3 +- src/input/keyboard/shortcuts.rs | 17 +- src/input/keyboard/text_keys.rs | 24 +- src/input/mod.rs | 7 +- src/input/pointer/enter.rs | 40 +- src/input/pointer/helpers.rs | 3 +- src/input/pointer/mod.rs | 83 +- src/input/pointer/motion.rs | 26 +- src/input/pointer/press.rs | 93 +- src/input/pointer/release.rs | 27 +- src/input/pointer/scroll.rs | 69 +- src/input/repeat/button.rs | 7 +- src/input/repeat/key.rs | 16 +- src/input/touch/mod.rs | 122 +- src/layout/column.rs | 5 +- src/layout/wrap_grid.rs | 142 +-- src/lib.rs | 26 +- src/osk/layout.rs | 1 + src/platform/android/assets.rs | 68 ++ src/platform/android/input.rs | 169 +++ src/platform/android/java.rs | 156 +++ src/platform/android/keys.rs | 60 + src/platform/android/log.rs | 51 + src/platform/android/mod.rs | 133 +++ src/platform/android/present.rs | 85 ++ src/platform/android/run.rs | 203 ++++ src/platform/android/state.rs | 52 + src/platform/android/surface.rs | 100 ++ src/platform/mock.rs | 157 +++ src/platform/mod.rs | 119 ++ src/platform/wayland/cursor.rs | 58 + .../wayland}/data_device.rs | 70 +- .../wayland}/handlers.rs | 243 ++-- src/platform/wayland/input.rs | 339 ++++++ src/platform/wayland/mod.rs | 143 +++ .../wayland}/output_probe.rs | 0 .../wayland}/overlays_reconcile.rs | 70 +- src/platform/wayland/present.rs | 226 ++++ .../wayland}/primary_selection.rs | 65 +- src/platform/wayland/run.rs | 499 ++++++++ .../wayland}/session.rs | 10 +- src/platform/wayland/state.rs | 117 ++ .../wayland}/subsurface.rs | 57 +- src/platform/wayland/surface.rs | 325 ++++++ src/platform/wayland/text_input.rs | 74 ++ src/platform/wayland/window.rs | 69 ++ .../wayland}/window_fit.rs | 13 +- src/render/mod.rs | 2 + src/render/primitives.rs | 9 +- src/render/text.rs | 9 +- src/resources.rs | 67 ++ src/system_fonts.rs | 24 +- src/theme/assets.rs | 4 +- src/theme/document.rs | 3 +- src/theme/font_registry.rs | 4 +- src/theme/paint.rs | 8 +- src/theme/prefs.rs | 24 +- src/theme/schema/raw.rs | 28 +- src/theme/search.rs | 8 +- src/theme/shadow.rs | 8 +- src/theme/text_style.rs | 24 +- src/tree.rs | 12 +- src/types.rs | 8 +- src/wallpaper.rs | 2 +- src/widget/date_picker/mod.rs | 4 +- src/widget/separator/mod.rs | 8 + src/widget/slider/mod.rs | 2 +- src/widget/text_edit/tests.rs | 4 +- src/widget/time_picker/mod.rs | 32 +- src/widget/window_button/mod.rs | 2 +- tests/element_map.rs | 9 +- tests/text_edit_dispatch.rs | 6 +- tests/widget_dispatch.rs | 14 +- 131 files changed, 6155 insertions(+), 3492 deletions(-) create mode 100644 build.rs create mode 100644 examples/android_hello.rs create mode 100644 java/net/liberux/ltk/KeepAlive.java create mode 100755 scripts/android-apk.sh create mode 100755 scripts/android-assets.sh delete mode 100644 src/draw/gles.rs create mode 100644 src/draw/paint.rs delete mode 100644 src/draw/software.rs create mode 100644 src/event_loop/a11y.rs delete mode 100644 src/event_loop/run.rs create mode 100644 src/event_loop/setup.rs create mode 100644 src/event_loop/step.rs create mode 100644 src/event_loop/tests.rs create mode 100644 src/input/event.rs create mode 100644 src/platform/android/assets.rs create mode 100644 src/platform/android/input.rs create mode 100644 src/platform/android/java.rs create mode 100644 src/platform/android/keys.rs create mode 100644 src/platform/android/log.rs create mode 100644 src/platform/android/mod.rs create mode 100644 src/platform/android/present.rs create mode 100644 src/platform/android/run.rs create mode 100644 src/platform/android/state.rs create mode 100644 src/platform/android/surface.rs create mode 100644 src/platform/mock.rs create mode 100644 src/platform/mod.rs create mode 100644 src/platform/wayland/cursor.rs rename src/{event_loop => platform/wayland}/data_device.rs (80%) rename src/{event_loop => platform/wayland}/handlers.rs (78%) create mode 100644 src/platform/wayland/input.rs create mode 100644 src/platform/wayland/mod.rs rename src/{event_loop => platform/wayland}/output_probe.rs (100%) rename src/{event_loop => platform/wayland}/overlays_reconcile.rs (86%) create mode 100644 src/platform/wayland/present.rs rename src/{event_loop => platform/wayland}/primary_selection.rs (57%) create mode 100644 src/platform/wayland/run.rs rename src/{event_loop => platform/wayland}/session.rs (97%) create mode 100644 src/platform/wayland/state.rs rename src/{event_loop => platform/wayland}/subsurface.rs (89%) create mode 100644 src/platform/wayland/surface.rs create mode 100644 src/platform/wayland/text_input.rs create mode 100644 src/platform/wayland/window.rs rename src/{event_loop => platform/wayland}/window_fit.rs (89%) create mode 100644 src/resources.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 49aa1b0..1786cf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Added +- **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. +- **`ltk::resource_dir()`** — the resource directory `set_resource_dir` gave, so an app finds its own bundled files (a terminal's monospace face) among the resources unpacked from the APK. `make install` now copies `scripts/` and `java/` into the registry, so apps package with the installed `android-apk.sh` and `android-assets.sh`. +- **Surface insets** — `SurfaceState::insets` holds the logical pixels the system covers along each edge of a surface; the view is laid out inside them while the background still fills the whole surface, and positions stay absolute, so hit testing is unchanged. The built-in keyboard is placed inside them too. On Android 11 and later they come from the window's `getRootWindowInsets()`: the status bar and any cutout on top, and on the other edges only bars that take taps, so gesture navigation leaves no gap at the bottom while a bar of buttons keeps its room; earlier releases fall back to the activity's content rect. On Wayland they stay zero. +- **APKs with Debian's own tools** — `scripts/android-apk.sh OUT.apk LIB.so PACKAGE LABEL [ASSETS_DIR]` packages an app built as a `cdylib` into a NativeActivity APK: it writes the manifest (the library loaded as `libmain.so`, optional permissions, a theme without an action bar, which the content rect would otherwise leave room for), compiles it with `aapt` against `android-framework-res`, adds the assets and the library, aligns with `zipalign` and signs with `apksigner`, creating a debug keystore in `~/.android/` when there is none; no Gradle or Android SDK download is involved. `ICON` gives the launcher icon, an SVG rasterised with `rsvg-convert` for every density, or a PNG. `KEEP_ALIVE=1` adds the only Java code, `java/net/liberux/ltk/KeepAlive.java`, compiled with `javac` and `dx` (art-standalone) against the platform 34 `android.jar` (google-android-platform-34-installer), which aapt then links against too, as Debian's framework-res predates the `specialUse` service type; the service is declared with `KEEP_ALIVE_USE` as its reason, with the permissions it needs. `examples/android_hello.rs`, a counter built as a `cdylib` only with the `android` feature, is the smoke test: `make android-apk` builds and packages it with the in-tree default theme, `make android-install` installs it through `adb`. On Android, `set_android_app` now also sends standard output and error, panics included, to logcat under the process's name. +- **`ltk::set_resource_dir( dir )`** — an app's own copy of ltk's resources, for a package with no system to install them into. Its `themes/` is searched after the user's themes and before `/usr/share/ltk/themes`, and its `keyboards/` before the system's layouts; a font a theme names by an absolute path that does not exist is read from its `fonts/` by file name, so a theme written against Debian's font paths works unchanged from the bundle. +- **Themes and fonts in an Android package** — `set_android_app` unpacks `assets/ltk/` into the app's internal storage and uses it as the resource directory. `assets/ltk/MANIFEST` lists the tree, one path per line, since the asset manager cannot list subdirectories; the tree is unpacked again only when the manifest changed, and its first line is a hash of the contents. `scripts/android-assets.sh` builds the tree from installed themes, without their cursors, and copies the fonts each `theme.json` names and ltk's keyboard layouts (`KEYBOARDS_DIR` overrides where they come from); `make android-assets` runs it on the in-tree default theme. On Android the per-script fallback fonts are read from `/system/fonts`, and the primary face is the embedded Sora. `make android-check` typechecks the Android backend with Debian's own rustc, `rust-src` and `google-android-ndk-r27c-installer`, through `-Zbuild-std`; `NDK`, `ANDROID_API` and `ANDROID_TARGET` override its defaults. +- **Android backend, the `android` feature** — `ltk::run` inside a NativeActivity through android-activity 0.6 and ndk 0.9, optional dependencies that build only for Android targets. The feature excludes `wayland`, and `build.rs` refuses both at once, so an Android build uses `default-features = false, features = [ "android" ]`. The app's `android_main` passes its `AndroidApp` (re-exported as `ltk::AndroidApp`) to the new `ltk::set_android_app` before `ltk::run`; without it `run` fails with the new `RunError::NoAndroidApp`. The backend draws through EGL on the activity's `ANativeWindow`, with `eglSwapBuffers` waiting for the display, or in software into a locked window buffer; sizes the main surface to the window at the display's density in whole multiples of 160 dpi; turns fingers and styluses into touches, a mouse into the pointer and key events into keysyms and text through the device's character map; and types on ltk's own keyboard. calloop's descriptor rides on the activity's `ALooper`, so the app's channel and timers keep working. Not yet: overlays, secondary surfaces and subsurfaces, dead keys from a physical keyboard and the activity's saved state. `EglContext` gains `new_android`, `create_native_surface` and `release_current`, and swaps with the interval its backend needs; `EglSurface::egl_window` and `resize` are Wayland's only. What a frame callback does is now the backend-neutral `AppData::frame_presented`, which both backends call. `session_state.rs` builds only with `wayland`, its only user, and so do the AT-SPI2 adapter's construction and action forwarding. The state only the Wayland backend reads moved out of the runtime's: the clipboard inbox, a drag's position and type, the first-configure latches (`pending_fullscreen`, `pending_size_hint_unpin`, `pending_size_hint_fit`, `fitted_size_hint`) and the overlay canvas pool from `AppData` into `WaylandPlatform`, and `titlebar_base` from `SurfaceState` into `WaylandSurfaceState`. A build with another backend does not warn about runtime code only Wayland drives; the Wayland build still does. +- **`wayland` feature, on by default** — the Wayland backend and its dependencies (smithay-client-toolkit, wayland-protocols, wayland-egl, wayland-scanner, calloop-wayland-source, and accesskit_unix for AT-SPI2 accessibility) are now optional. A new `build.rs` sets `cfg( backend )` when some backend is enabled, which builds the runtime: `run`, `try_run`, `RunError` and everything behind them. With `--no-default-features` only the runtime-free core builds (`UiSurface`, widgets, layout, theming, rendering, `EglOffscreenContext`); `EglContext` and `EglSurface` need `wayland`. The backend in use is named by `platform::NativePlatform` and `platform::NativeSurface`. `make install` now copies `build.rs` into the registry. Builds with default features are unchanged. - **`TextEdit::color( c )` / `TextEdit::on_focus( msg )`** — `color` sets the colour of the value, which always came from the theme; the placeholder keeps the theme's. `on_focus` emits a message when the field takes the keyboard focus (press, Tab, `take_focus_request`), once per change and not for a press on the field that already holds it. `App::on_text_input_focused( bool )` only says that some text field is focused; an app that mirrors focus into another model (rustdroid keeps Android's focused view in step) needs to know which, and a per-widget message carries that without a `WidgetId`, which is a `&'static str` and cannot name a dynamic handle. The message rides the `WidgetHandlers::TextEdit` snapshot and is pushed from `set_focus`. - **External text input: `App::external_text_input()`, `on_external_text_commit`, `on_external_text_delete`** — lets an app hold the input method for a text field ltk does not own: an editable inside an embedded web view, a guest toolkit's field. While `external_text_input` returns `Some( serial )` and no ltk text widget has the focus, the runtime keeps a `zwp_text_input_v3` enabled, so the compositor raises its on-screen keyboard, and routes `commit_string` / `delete_surrounding_text` to the two new hooks instead of to a widget; a change of `serial` renews the `enable`, which is how a host asks for a keyboard the user folded to come back. Polled after every batch of `update`s through `AppData::sync_external_text_input`; an ltk text widget that takes the focus wins and hands the text-input back when it loses it. Keys such as Backspace and Enter keep arriving as key events. Default `None`. - **`App::on_back()` / `BackResponse`** — the app's answer to a back request (the shell's back button or gesture, delivered as `XF86Back`, or a mouse's `BTN_SIDE` / `BTN_BACK`). Back peels one layer per request: transient UI the runtime owns (xdg-popups, context menu, text selection, a dialog's `on_escape`), then text-input focus, then `on_back`, then `on_key_with_modifiers( XF86_Back )` for apps that bound the key themselves, then `back_at_root`. `BackResponse::Message` carries the message that undoes the top-most piece of navigation state, `Consumed` is for hosts whose back stack lives elsewhere (an embedded Android runtime), `Unhandled` means the root. Default `Unhandled`. @@ -46,6 +56,8 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Changed +- **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`. - **A `window_size_hint` larger than the screen is shrunk to fit.** The hint is pinned as `min_size == max_size` before the first commit, when no output is known yet, so an app asking for a phone-shaped 720x1440 window on a 1080p desktop got a toplevel taller than the screen. On the first configure the runtime now fits the hint, keeping its aspect ratio, inside the compositor's `xdg_toplevel.configure_bounds` (`WindowConfigure::suggested_bounds`) or, when it sends none, inside 90 % of the first output's logical size; `min_size` (and `max_size` for a non-resizable window) is re-pinned to the fitted size, which also replaces the hint as the fallback when later configures carry no size. A hint that fits is untouched. `event_loop/window_fit.rs`, with unit tests for the fitting rule. - **A press on a text field renews the text-input `enable`.** Moving the focus between two text fields, or pressing the one already focused, used to commit only the refreshed content type on the existing `zwp_text_input_v3`. It now sends `enable` first, as GTK does when a focused entry is clicked: a compositor that folded its on-screen keyboard while the field kept the focus (the back button does) has no other way to learn that the user wants it again, since the text-input never stopped being active. ltk sends neither surrounding text nor a cursor rectangle, so the state `enable` resets is only the content type set right after it. diff --git a/Cargo.toml b/Cargo.toml index bc45c33..6d5853b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,27 @@ exclude = [ ] [features] +default = [ "wayland" ] +# The Wayland backend: `ltk::run` on a Wayland compositor, with AT-SPI2 +# accessibility. Without any backend only the runtime-free core builds +# (`UiSurface`, widgets, theming, rendering). +wayland = [ + "dep:smithay-client-toolkit", + "dep:calloop-wayland-source", + "dep:wayland-protocols", + "dep:wayland-egl", + "dep:wayland-scanner", + "dep:accesskit_unix", +] +# The Android backend: `ltk::run` in a NativeActivity, after `android_main` +# hands its `AndroidApp` to `ltk::set_android_app`. Builds only for Android +# targets and excludes `wayland`, so an Android build disables the default +# features. +android = [ + "dep:android-activity", + "dep:ndk", + "dep:jni", +] # 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 @@ -33,9 +54,11 @@ test-support = [] chrono = { version = "0.4", features = ["clock"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -smithay-client-toolkit = { version = "0.20", features = ["calloop", "calloop-wayland-source", "xkbcommon"] } +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 = "0.4" +calloop-wayland-source = { version = "0.4", optional = true } tiny-skia = "0.12" unicode-bidi = "0.3" rustybuzz = "0.14" @@ -46,9 +69,9 @@ rust-i18n = "3" # newer than Debian's 1.85, and its manifest does not declare that MSRV. ignore = "=0.4.23" libc = "0.2" -wayland-protocols = { version = "0.32", features = ["client", "unstable", "staging"] } -wayland-egl = "0.32" -wayland-scanner = "0.31" +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" @@ -62,11 +85,19 @@ raw-window-handle = "0.6" # — so this crate only has to construct an `accesskit::TreeUpdate` # from `widget_rects` once per frame. accesskit = "0.17" -accesskit_unix = "0.13" +accesskit_unix = { version = "0.13", optional = true } +android-activity = { version = "0.6", features = ["native-activity"], optional = true } +ndk = { version = "0.9", default-features = false, optional = true } +jni = { version = "0.21", optional = true } [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } +[[example]] +name = "android_hello" +crate-type = [ "cdylib" ] +required-features = [ "android" ] + [[bench]] name = "lookup" harness = false diff --git a/Makefile b/Makefile index 4bbc6ae..e4c4b1e 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,46 @@ REGISTRY ?= /usr/share/cargo/registry/ltk DOCDIR ?= /usr/share/doc/libltk-doc/html +# The Android build uses Debian's own packages: rustc and rust-src of the +# same version, whose std `-Zbuild-std` compiles for the target, and the NDK +# that google-android-ndk-r27c-installer (non-free) unpacks. +NDK ?= /usr/lib/android-sdk/ndk/27.2.12479018 +ANDROID_API ?= 26 +ANDROID_TARGET ?= aarch64-linux-android +NDK_BIN = $(NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin +NDK_CC = $(NDK_BIN)/$(ANDROID_TARGET)$(ANDROID_API)-clang +ANDROID_ENV = RUSTC_BOOTSTRAP=1 \ + CC_$(subst -,_,$(ANDROID_TARGET))=$(NDK_CC) \ + CXX_$(subst -,_,$(ANDROID_TARGET))=$(NDK_CC)++ \ + AR_$(subst -,_,$(ANDROID_TARGET))=$(NDK_BIN)/llvm-ar \ + CARGO_TARGET_$(shell echo $(ANDROID_TARGET) | tr a-z- A-Z_)_LINKER=$(NDK_CC) +ANDROID_CARGO = -Zbuild-std=std,panic_abort --target $(ANDROID_TARGET) --no-default-features --features android +# Where `android-assets` writes the `ltk/` tree an APK carries under `assets/`. +ANDROID_ASSETS ?= target/android/assets +# The example APK, packaged with aapt, zipalign, apksigner and +# android-framework-res, and installed with adb (android-sdk-platform-tools). +ANDROID_ABI ?= arm64-v8a +ANDROID_APK ?= target/android/ltk-hello.apk + # 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 test doctest-md stylecheck clippy audit doc install examples clean distclean +.PHONY: all deps android-deps test doctest-md stylecheck clippy audit doc install examples clean distclean android-check android-assets android-apk android-install all: cargo build --release +# What building, testing and linting need, from Debian. +deps: + sudo apt-get install -y cargo rustc rust-clippy pkg-config libwayland-dev libegl-dev libxkbcommon-dev + +# What the android-* targets need, from Debian: std's sources for -Zbuild-std, +# the NDK installer (non-free), the APK tools, rsvg-convert for SVG icons, javac, dx and +# android.jar (non-free) for the keep-alive service, and adb. +android-deps: + sudo apt-get install -y rust-src google-android-ndk-r27c-installer aapt zipalign apksigner android-framework-res librsvg2-bin default-jdk-headless art-standalone google-android-platform-34-installer android-sdk-platform-tools + test: cargo test --features test-support @@ -30,6 +61,25 @@ stylecheck: clippy: cargo clippy --workspace --all-targets --features test-support -- -D warnings +# Typecheck the Android backend. Needs rust-src and the NDK (see NDK above). +android-check: + @test -x $(NDK_CC) || { echo "No NDK compiler at $(NDK_CC); install google-android-ndk-r27c-installer or set NDK="; exit 1; } + $(ANDROID_ENV) cargo check $(ANDROID_CARGO) + +# The in-tree default theme and the fonts it names, as an APK carries them. +android-assets: + ./scripts/android-assets.sh $(ANDROID_ASSETS) themes default + +# examples/android_hello.rs as an APK, with the in-tree default theme. +android-apk: android-assets + @test -x $(NDK_CC) || { echo "No NDK compiler at $(NDK_CC); install google-android-ndk-r27c-installer or set NDK="; exit 1; } + $(ANDROID_ENV) cargo build --release --example android_hello $(ANDROID_CARGO) + ANDROID_ABI=$(ANDROID_ABI) MIN_SDK=$(ANDROID_API) ./scripts/android-apk.sh $(ANDROID_APK) \ + target/$(ANDROID_TARGET)/release/examples/libandroid_hello.so net.liberux.ltk.Hello "ltk hello" $(ANDROID_ASSETS) + +android-install: android-apk + adb install -r $(ANDROID_APK) + audit: @command -v cargo-audit >/dev/null 2>&1 || cargo install cargo-audit --locked cargo audit @@ -39,7 +89,7 @@ doc: install: doc install -d $(REGISTRY) - cp -r src benches locales protocols keyboards Cargo.toml liberux.toml $(REGISTRY)/ + cp -r src benches examples locales protocols keyboards scripts java 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 0063906..6b51a6d 100644 --- a/README.md +++ b/README.md @@ -154,10 +154,16 @@ to keep. as `rust-version` in `Cargo.toml`). - A running **Wayland** session — there is no X11 backend. - System headers for `libwayland`, `libegl` and `libxkbcommon` at compile - time. On Debian / Ubuntu: + time, and `rust-clippy` for `make clippy`. On Debian, `make deps` + installs them: ```bash - sudo apt-get install libwayland-dev libegl-dev libxkbcommon-dev pkg-config + sudo apt-get install cargo rustc rust-clippy pkg-config libwayland-dev libegl-dev libxkbcommon-dev ``` + `make android-deps` installs what the `android-*` targets need: `rust-src`, + `google-android-ndk-r27c-installer` (non-free), `aapt`, `zipalign`, + `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`. - A usable system font (`fonts-sora`, `fonts-liberation`, `fonts-dejavu`, …). If none is installed `ltk` falls back to an embedded Sora Regular build with a stderr warning. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..29659f4 --- /dev/null +++ b/build.rs @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +// `cfg( backend )` marks the runtime behind `ltk::run`, which builds when some platform backend does. Each backend defines `ltk::run` and the native platform types, so only one may be enabled. +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 + { + panic!( "ltk: the `wayland` and `android` features are exclusive; build for Android with `default-features = false, features = [ \"android\" ]`" ); + } + if wayland || android + { + println!( "cargo::rustc-cfg=backend" ); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 1e8e8ad..8b612f6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,11 +31,12 @@ Where things live under `src/`, one line each: - `app.rs` — the `App` trait, `OverlaySpec`, `SubsurfaceSpec`, `run` / `try_run`. - `chassis.rs` — scaffolding for full-screen ambient surfaces (greeter, lock screen, kiosk): theme bring-up, branding/wallpaper loading, the wallpaper-backed view stack. - `core.rs` — `UiSurface`, runtime-free embedding. -- `draw/` — the per-frame drawing pipeline shared by both backends. +- `draw/` — the per-frame drawing pipeline: painting a surface's view on its canvas (`paint.rs`), the same for the software and GPU renderers and on every platform, damage tracking, the layout walk and the chrome the runtime paints itself. - `egl_context.rs` — EGL bootstrap for the GPU path. -- `event_loop/` — the Wayland run loop: frame scheduling, invalidation, clipboard / data device, text editing and IME, tooltips, focus, subsurfaces, session management, perf guardrails. +- `event_loop/` — the runtime state (`AppData`) and the steps of a run-loop iteration every backend shares: building the state and its event sources (`setup.rs`), updating and drawing (`step.rs`), frame scheduling, invalidation, the clipboard buffer, text editing, tooltips, focus, the accessibility tree and its actions, perf guardrails. - `gles_render/` — GPU backend (EGL + GLES2 / GLES3). -- `input/` — pointer, keyboard and touch handling, the gesture machine, dispatch. +- `input/` — platform-neutral input events (`event.rs`), pointer, keyboard and touch handling, the gesture machine, dispatch. Nothing in it names a windowing system. See [Input events and platform backends](#input-events-and-platform-backends). +- `platform/` — the `Platform` trait the runtime asks the windowing system through, and the backends. `platform/wayland/` implements it, holds the protocol state (`WaylandPlatform`, which `AppData::platform` owns) and each surface's Wayland half (`WaylandSurfaceState`, on `SurfaceState::platform`) and the handlers smithay-client-toolkit dispatches to, turns Wayland's input into `input/event.rs`, shares the clipboard and the primary selection with other clients, and carries out what the runtime asks of the system: presenting painted frames, window move, resize, minimise and fullscreen, a bare frame request, the cursor's shape, the input method, publishing the clipboard and the primary selection. It also owns `ltk::run` (`run.rs`), the overlay and subsurface reconciliation, session management, and output probing and window fitting. - `layout/` — composable arrangers for `Element` trees. - `osk/` — the on-screen keyboard: layouts from JSON files, a host-driven `Panel`, and its drawing. See [On-screen keyboard](#on-screen-keyboard). - `protocol/` — in-tree `wayland-scanner` bindings for protocols `wayland-protocols` does not generate yet (`xdg-session-management-v1`). @@ -166,11 +167,68 @@ Separate from overlays, `subsurfaces()` returns `SubsurfaceSpec` entries: input- If your application does not need overlays or layer-shell, you can ignore this entire section and stay in the `ltk::window` subset. +## Input events and platform backends + +Input reaches the runtime as platform-neutral events, defined in `input/event.rs`. A platform backend translates what its input system reports into them and calls the runtime's entry points; everything past those entry points is shared by every platform. This is the first step towards backends other than Wayland: an Android backend will feed the same events from its `MotionEvent` and `KeyEvent`, and apps will not see the difference. + +| Event | What it carries | +| --- | --- | +| `KeyInput` | The keysym, the physical key's code in the platform's numbering, and the text the key types once dead keys and compose sequences are applied. `KeyInput::raw` builds the `RawKey` that `App::on_raw_key_event` receives. | +| `PointerInput` | The surface, the position, and a `PointerKind`: enter, leave, motion, press or release of a `PointerButton` (left, right, middle, back, other), or an axis scroll with its distance, its wheel detents and its `AxisSource`. | +| `TouchInput` | The finger's id, the time, and a `TouchPhase`: down on a surface at a position, motion to a position, or up. | + +| Source | Entry points on the runtime | +| --- | --- | +| Mouse, touchpad | `pointer_input( &PointerInput )` | +| Fingers | `touch_input( &TouchInput )`; `reset_touch_state()` when the platform cancels the sequence | +| Keyboard | `keyboard_enter( focus )`, `keyboard_leave()`, `key_press( KeyInput )`, `key_release( &KeyInput )`, `key_repeated( KeyInput )` for a repeat the platform generates itself, `modifiers_changed( ctrl, shift, alt )`, `set_repeat_info( rate, delay )` | + +Behind them sit hover and focus, the gesture machine (tap, long-press, drag, scroll, swipe, fling), text editing, the context menu, the built-in on-screen keyboard, back handling, the runtime's own key repeat and every `App` input callback. None of it knows which platform the event came from. + +**Units.** Positions are in the logical coordinates of the surface, as a compositor reports them; the handlers scale them to physical pixels, so a platform that reports physical pixels divides by the surface's scale first. Time is in milliseconds on the platform's monotonic input clock; only differences are read, for fling velocity. Scroll distances are in logical pixels, positive downwards and rightwards; the runtime multiplies a wheel's by ten before it moves a viewport, since a wheel reports a few pixels per detent. + +**What stays with the backend.** The events carry only what the shared logic reads. What a protocol needs for its own later requests is recorded by the backend before it forwards the event: on Wayland, the serials that a window move or resize, a selection, a popup grab and the cursor shape quote back to the compositor. The backend also resolves its surfaces to a `SurfaceFocus`; a platform with one surface always passes `SurfaceFocus::Main`. Later events of a finger carry no surface, since they go to the one the finger landed on. + +**What the runtime asks of the backend** is the `Platform` trait (`platform/mod.rs`), which `AppData::platform` implements. Input handling needs a few things only the windowing system can do, and asks for them through it without touching a protocol object. Requests about the window take the main surface's backend half, `&self.main.platform`: + +| `Platform` method | Wayland (`platform/wayland/`) | +| --- | --- | +| `is_toplevel( main )` | The main surface is an xdg-toplevel, not a layer-shell or lock surface | +| `begin_window_move( main )`, `begin_window_resize( main, edge )` | `xdg_toplevel.move` / `resize` with the last left-press serial; `ResizeEdge` is ltk's own | +| `minimize_window( main )`, `set_fullscreen( main, on )` | `xdg_toplevel.set_minimized`, `set_fullscreen` / `unset_fullscreen`; `false` when the main surface is no toplevel | +| `request_frame( surface, focus )` | A frame callback and a commit with no new buffer, for an animation that moves only subsurfaces | +| `set_cursor_shape( shape )` | `wp_cursor_shape_device_v1.set_shape` with the last enter serial; `false` without the protocol | +| `activate_text_input( secure, purpose )`, `deactivate_text_input()`, `reenable_text_input( secure, purpose )`, `text_input_active()` | `zwp_text_input_v3` enable, content type and disable | +| `prepare_canvas( surface, canvas, pw, ph, scale )` | `eglMakeCurrent` and a GLES canvas when the surface has an EGL window surface, else a software canvas over the SHM pool; created on the first frame, resized to the buffer | +| `present( surface, canvas, presentation )` | GPU: blit the FBO and `eglSwapBuffers` with the damage turned to EGL's bottom-left origin. Software: copy the pixmap into a slot of the pool, attach and damage it. Both set the opaque and input regions, ask for the frame callback and commit | +| `precise_damage( surface )` | `true` on the software path, where a full repaint reports the widgets that changed; the GPU path damages the whole buffer | +| `publish_clipboard( text )`, `publish_primary_selection( text )` | A `wl_data_device` copy-paste source, a `zwp_primary_selection_v1` source; other clients' pastes are served from `AppData::clipboard` and `AppData::primary_selection` | + +`AppData` keeps thin wrappers where several call sites share one: `main_is_toplevel()`, `sync_fullscreen()`, which remembers what was applied, the text-input trio, which remembers `secure` and `purpose` for a later renewal, and `publish_clipboard()` and `publish_primary_selection()`, which keep the text. + +**Drawing a frame** is the runtime's, presenting it the backend's. For each surface due a frame, `event_loop/frame.rs` picks a partial repaint, under the rects whose focus, hover or press changed when they cover less than half of the surface, or a full one; asks the backend to `prepare_canvas`; paints with `draw::paint_partial` or `draw::paint_full`, which return the damage; and hands the canvas to `present` with a `Presentation`: the damage, background, opaque and input regions, logical size and scale, and the `SurfaceFocus` the frame callback is reported for, `None` for a surface not paced by frames. The canvas belongs to the surface and keeps its pixels, so a partial repaint touches only its rects, and the only copy is the one a software canvas always needs into the system's buffer. When presenting fails the next frame repaints in full. + +**The Wayland backend** is `platform/wayland/`. Its `input.rs` holds the `KeyboardHandler`, `PointerHandler` and `TouchHandler` implementations, which record serials, map evdev buttons (`BTN_LEFT` … `BTN_BACK`), take high-resolution wheel detents (`value120`) over the older whole ones, drop pointer and touch events on the secondary per-output surfaces and forward the rest; `window.rs`, `cursor.rs` and `text_input.rs` carry out the `Platform` requests. It carries no input logic and is the template for a new backend. + +**The protocol state is the backend's.** Globals, seat devices, the serials, the text-input, cursor, activation, session-management, data-device, primary-selection and foreign-toplevel objects, the EGL context and the queue handle live in `WaylandPlatform`, which `AppData` holds as `platform`. + +**So is each surface's Wayland half.** `SurfaceState::platform` is a `WaylandSurfaceState` (`Platform::Surface`, named `NativeSurface` in `platform/mod.rs`): the role object (`SurfaceKind`: layer surface, xdg window or popup, lock surface, or one still waiting for an output), the SHM pool or the EGL window surface, and what the compositor was last told (requested size and exclusive zone, popup anchor and reposition token, opaque region). Everything else on `SurfaceState`, from the canvas and the laid-out widgets to focus, gestures and frame pacing, belongs to the runtime. Configuring a surface, which picks the GPU or SHM path, is the backend's too (`platform/wayland/surface.rs`). + +**The runtime is generic over its platform.** `AppData>` and `SurfaceState` take the backend as a parameter; the runtime's `impl` blocks are written for any `P`, the backend's for its own (`impl AppData`, which the default makes the native one). Everything is resolved at compile time, as before. What a backend does with a configure is split the same way: it resizes its buffers and calls `SurfaceState::set_size`, and for the main surface `AppData::on_main_resized`, which tells the app and the accessibility tree. A backend builds the state with `AppData::new` and installs the sources every backend has with `AppData::install_sources`: the channel the app sends messages from other threads on, the desktop's text scale and the `App::poll_interval` timer. The backend-neutral part of a loop iteration is `event_loop/step.rs`. `next_wakeup` bounds the sleep by the runtime's own deadlines (a still press turning long, a tooltip, the built-in keyboard). `before_draw` fires the deadlines that passed, takes `App::poll_external` and a clipboard set by another application, runs `process_messages`, which hands the queued messages to `App::update` and applies their invalidation, and then what the update asks of the window: exit, fullscreen, external text input, a drag that just began. `draw_due` rebuilds the cached view and overlays when stale and draws every surface due a frame. `after_draw` publishes the accessibility tree and applies its actions, then the app's requests to blur, copy, paste and move the focus. The Wayland loop dispatches Wayland events and does its own work around these calls (the primary selection and drops from other clients, activation tokens, unlocking a session lock, overlays and subsurfaces), and a new backend's loop will do the same with its system. `AppData::secondaries`, the surfaces covering other outputs, pair each with the backend's `Platform::Output`. + +**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`. + +**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. + ## 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`. -**Layouts are data.** A layout is one JSON file per language, `keyboards/es.json`, searched in `LTK_KEYBOARDS_DIR` (when set), `$XDG_DATA_HOME/ltk/keyboards` (or `~/.local/share/ltk/keyboards`) and `/usr/share/ltk/keyboards`, highest priority first; `ltk::osk::layout::search_paths()` returns the list, `available()` the names found, `load( name )` one layout. The English layout is compiled in as the last resort. A file has `name`, an optional `label` for the layout-switch key, `layers` and `variants`. A layer is a list of rows; a row is a list of keys; a key is a bare string for the text it types, or an object with `action` (`shift`, `backspace`, `enter`, `space`, `layer`, `layout`, `hide`), `layer` for the target of `layer`, `label`, and `width` in key units, `1` by default, which share the row's width. The `base` layer is mandatory; a missing `shift` layer is derived from `base` by uppercasing. `variants` maps a key's text to what a long press offers; the shifted form of a key uses its base's variants uppercased. +**Layouts are data.** A layout is one JSON file per language, `keyboards/es.json`, searched in `LTK_KEYBOARDS_DIR` (when set), `$XDG_DATA_HOME/ltk/keyboards` (or `~/.local/share/ltk/keyboards`), the resource directory's `keyboards/` and `/usr/share/ltk/keyboards`, highest priority first; `ltk::osk::layout::search_paths()` returns the list, `available()` the names found, `load( name )` one layout. The English layout is compiled in as the last resort. A file has `name`, an optional `label` for the layout-switch key, `layers` and `variants`. A layer is a list of rows; a row is a list of keys; a key is a bare string for the text it types, or an object with `action` (`shift`, `backspace`, `enter`, `space`, `layer`, `layout`, `hide`), `layer` for the target of `layer`, `label`, and `width` in key units, `1` by default, which share the row's width. The `base` layer is mandatory; a missing `shift` layer is derived from `base` by uppercasing. `variants` maps a key's text to what a long press offers; the shifted form of a key uses its base's variants uppercased. A key may also be a named key, `{ "key": "Escape" }`, `Tab`, the arrows, `Home`, `End`, `Page_Up`, `Page_Down`, `Delete`, `Insert` and `F1` to `F12`, or a sticky modifier, `{ "modifier": "ctrl" }` or `alt`. Those are what `keyboards/bar-terminal.json` is made of: the row a terminal needs, independent of the language, which the panel lays above the language's layout when its `Purpose` is `Terminal`. A `bar-*.json` file is a row, not a layout, and `available()` leaves it out of the layouts the layout-switch key cycles through. @@ -343,14 +401,14 @@ A short, honest list of what ltk does not currently provide. None of these are a ltk delegates the AT-SPI2 D-Bus protocol to `accesskit_unix`. After every layout pass, the runtime hands the platform adapter a fresh `accesskit::TreeUpdate` built from `widget_rects`: a `Window` root whose children are the main surface's widgets, with each overlay grouped under its own `Dialog` node and rich-text runs nested as `TextRun` children. Buttons / toggles / checkboxes / radios / list items map to `Role::Button` / `Role::Switch` / `Role::CheckBox` / `Role::RadioButton` / `Role::ListItem`; sliders to `Role::Slider`; pattern grids to `Role::GenericContainer` (a drawn gesture has no assistive equivalent, so the app must offer another unlock method); single- and multi-line text edits to `Role::TextInput` / `Role::MultilineTextInput`; non-interactive labels, images, separators and progress bars surface as `Label` / `Image` / `Splitter` / `ProgressIndicator` nodes. Inbound action requests are translated into the matching widget message on the next iteration of the run loop: `Click` and `Focus` on every interactive node, `SetValue` on sliders and text edits, `Increment` / `Decrement` on sliders, and the scroll actions on scroll viewports. Live regions are wired too — `Container::live_region( true )` marks a subtree `Live::Polite` so status messages and OSDs announce themselves on appearance. -The adapter is constructed unconditionally — `accesskit_unix::Adapter::new` is infallible and simply stays inactive when no AT client is attached — and the tree is only built when AT-SPI2 is actually observing the application, so the pipeline costs nothing on headless runners. The current cut covers the common cases — buttons, lists, form fields, dialogs — and intentionally leaves room for follow-up: +The adapter comes with the `wayland` feature; a build without it has no accessibility state and skips both directions. Where present it is constructed unconditionally — `accesskit_unix::Adapter::new` is infallible and simply stays inactive when no AT client is attached — and the tree is only built when AT-SPI2 is actually observing the application, so the pipeline costs nothing on headless runners. The current cut covers the common cases — buttons, lists, form fields, dialogs — and intentionally leaves room for follow-up: - **Generic container nesting**: dialogs, text runs and the root do nest, but `Column` / `Row` / `Container` parents inside a surface are not represented — a surface's widgets are siblings. Adding that requires either recording the nesting on `LaidOutWidget` or walking `Element` again from the a11y side. - **Per-widget accessible label / description / `LabelledBy` relations**: the internal `accessible_label` plumbing exists (labels are derived from the widget's own content, with the tooltip as last fallback), but the public builders to override them (`Button::accessible_name(...)`, etc.) are not exposed yet. Adding them is mechanical but touches every widget module. Downstream consumers shipping into regulated environments (EN 301 549, WCAG 2.1 AA, EAA) should still treat the integration as a starting point that needs a real audit with assistive technology users — the foundation is in place but the per-widget metadata work is what determines whether Orca actually reads a useful announcement. -**Cross-application drag-and-drop — deferred.** The clipboard now bridges to the Wayland selection via `wl_data_device_manager` (see `event_loop/data_device.rs`), so Ctrl+C / Ctrl+V crosses application boundaries when the compositor advertises the global. Middle-click primary selection (`zwp_primary_selection_v1`) and inter-app drag-and-drop targets (drop-zone widgets that accept text / URI lists from outside the process) are still pending — they share most of the offer / source plumbing but need widget-level drop-target wiring on top. +**Cross-application drag-and-drop — deferred.** The clipboard now bridges to the Wayland selection via `wl_data_device_manager` (see `platform/wayland/data_device.rs`), so Ctrl+C / Ctrl+V crosses application boundaries when the compositor advertises the global. Middle-click primary selection (`zwp_primary_selection_v1`) and inter-app drag-and-drop targets (drop-zone widgets that accept text / URI lists from outside the process) are still pending — they share most of the offer / source plumbing but need widget-level drop-target wiring on top. **Multi-touch — primary slot plus raw auxiliary fingers.** The first finger to land on a surface becomes its primary slot and drives the built-in gesture machine (swipe, scroll, long-press, drag). Additional fingers bypass the gesture machine and surface directly through `App::on_touch_down` / `on_touch_move` / `on_touch_up`, so apps can implement pinch-zoom or two-finger pan without losing the built-in gestures. One exception keeps fast two-thumb typing whole: a finger landing while the primary is still a plain press (no drag, scroll, swipe or slider in flight) commits that press as a tap at its last position and takes the primary slot itself, so alternating taps whose touches overlap all register; the earlier finger's release then reports through `on_touch_up`. A press on a widget that fires on tap also stays a tap for as long as the finger is inside that widget, however far it rolls; the swipe machinery only engages, from the original press point, once the finger leaves it. Presses inside a scroll viewport keep the 8 logical px slop, since a list of full-width rows is scrolled by dragging on them. Apps that want the whole stream raw — an embedded web view, a drawing canvas — return `true` from `App::claims_raw_touch`: every finger, primary included, then reports through `on_touch_*` and widget presses, taps and swipes never fire. What ltk itself does not provide is *recognition* of multi-finger gestures — pinch and rotate detection is the app's job on top of the raw stream. @@ -360,6 +418,8 @@ Downstream consumers shipping into regulated environments (EN 301 549, WCAG 2.1 **xdg-session-management-v1 — wired in (client side).** Before the first commit of a `ShellMode::Window` toplevel the runtime binds `xdg_session_manager_v1`, calls `get_session( reason, stored_id )` and `restore_toplevel( toplevel, "main" )`, so the compositor restores geometry on every launch. The session id from `created` is persisted to `$XDG_STATE_HOME//session.json` next to `state.bin`, the app's own `App::save_state` bytes (saved every 30 s when changed, on close and on `SIGTERM`/`SIGINT`, which the runtime now turns into a clean exit). App state is handed back through `App::restore_state` only for reason `session_restore` (`LTK_SESSION_RESTORE=1` in the environment, set by the shell) or `recover` (previous run left `clean_exit: false`) — a plain launch starts fresh, Android-style. Layer-shell and session-lock surfaces have no toplevel and skip all of it. Compositors without the global lose only the geometry half; the file-based state and reason detection still work. Multi-toplevel sessions and the compositor implementation in forge are tracked separately. +**Platforms other than Wayland — in progress.** Input already reaches the runtime as platform-neutral events (see [Input events and platform backends](#input-events-and-platform-backends)), input handling, drawing and the clipboard reach the windowing system only through the `Platform` trait, and the protocol state, each surface's Wayland half, presentation and the handlers live in `platform/wayland/`, the Wayland dependencies are the optional `wayland` feature, and the run loop, overlay reconciliation and subsurfaces are the Wayland backend's, around backend-neutral steps any loop can call. The Android backend is the second (see [Input events and platform backends](#input-events-and-platform-backends)); what it still lacks is listed there. + **Fractional scale — deferred.** `wp_fractional_scale_v1` (so 125 % / 150 % outputs render natively instead of via compositor downscale) remains tracked as upcoming protocol work. **Software/GLES parity gaps — see [`docs/backends.md`](./backends.md).** The software backend renders gradients as a flat fill from the first stop, skips outer and inset shadows and backdrop blur, and hard-cuts the bottom-edge fade; `oklab` gradient interpolation falls back to linear-light on both backends. The capability matrix is the canonical per-feature table and must be updated in the same patch that closes any of these gaps. diff --git a/examples/android_hello.rs b/examples/android_hello.rs new file mode 100644 index 0000000..73bd42a --- /dev/null +++ b/examples/android_hello.rs @@ -0,0 +1,56 @@ +//! The smallest ltk app on Android: a counter in a NativeActivity, on the default theme the APK carries. `make android-apk` builds and packages it, `make android-install` puts it on the device `adb` sees. +//! +//! An Android app is a library the activity loads, so this example builds as a `cdylib` whose entry point is `android_main`. + +#![ cfg( target_os = "android" ) ] + +use ltk::{ App, ButtonVariant, Element, button, column, spacer, text }; + +#[ derive( Clone ) ] +enum Msg +{ + Press, +} + +#[ derive( Default ) ] +struct Hello +{ + presses: u32, +} + +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 on Android" ).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::Primary ).on_press( Msg::Press ) ) + .push( spacer() ) + .into() + } + + fn update( &mut self, msg: Msg ) + { + match msg + { + Msg::Press => self.presses += 1, + } + } +} + +#[ no_mangle ] +fn android_main( app: ltk::AndroidApp ) +{ + ltk::set_android_app( app ); + ltk::run( Hello::default() ); +} diff --git a/examples/carousel.rs b/examples/carousel.rs index 8f8a626..914604f 100644 --- a/examples/carousel.rs +++ b/examples/carousel.rs @@ -95,9 +95,8 @@ impl App for CarouselApp .gap( GAP ) .offset( self.offset ); - for i in 0..TILE_COUNT + for ( i, &( r, g, b ) ) in COLORS.iter().enumerate() { - let ( r, g, b ) = COLORS[i]; let tile = container::( column::() .padding( 24.0 ) diff --git a/examples/clip_path.rs b/examples/clip_path.rs index 831cf21..993e827 100644 --- a/examples/clip_path.rs +++ b/examples/clip_path.rs @@ -53,7 +53,7 @@ fn circle_path( r: Rect ) -> Vec let cx = r.x + r.width / 2.0; let cy = r.y + r.height / 2.0; let rad = ( r.width.min( r.height ) ) / 2.0 - MARGIN; - let k = rad * 0.552_284_75; // 4/3 * tan(pi/8): cubic circle approximation + let k = rad * 0.552_284_8; // 4/3 * tan(pi/8): cubic circle approximation vec![ PathCmd::MoveTo( cx + rad, cy ), PathCmd::CubicTo( cx + rad, cy + k, cx + k, cy + rad, cx, cy + rad ), diff --git a/examples/dialog.rs b/examples/dialog.rs index d4da875..a1c5b27 100644 --- a/examples/dialog.rs +++ b/examples/dialog.rs @@ -220,7 +220,7 @@ fn custom_body_dialog( brightness: f32 ) -> Element .color( secondary ) ) .push( slider::( brightness ) - .on_change( |v| Msg::BrightnessChanged( v ) ) + .on_change( Msg::BrightnessChanged ) .accent_thumb( true ), ); diff --git a/examples/sliders.rs b/examples/sliders.rs index 90438d6..bc1adc6 100644 --- a/examples/sliders.rs +++ b/examples/sliders.rs @@ -24,9 +24,9 @@ use ltk:: #[ derive( Clone ) ] enum Msg { - SetHue( f32 ), - SetVolume( f32 ), - SetBrightness( f32 ), + Hue( f32 ), + Volume( f32 ), + Brightness( f32 ), } struct SlidersApp @@ -68,11 +68,11 @@ impl App for SlidersApp let secondary = palette.text_secondary; // Horizontal slider — claims its row's full width. - let hue_slider = slider( self.hue ).on_change( Msg::SetHue ); + let hue_slider = slider( self.hue ).on_change( Msg::Hue ); // Two vertical pills. - let brgt = vslider( self.brightness ).size( PILL_W, PILL_H ).on_change( Msg::SetBrightness ); - let vol = vslider( self.volume ).size( PILL_W, PILL_H ).on_change( Msg::SetVolume ); + let brgt = vslider( self.brightness ).size( PILL_W, PILL_H ).on_change( Msg::Brightness ); + let vol = vslider( self.volume ).size( PILL_W, PILL_H ).on_change( Msg::Volume ); let pills = row::() .spacing( 40.0 ) @@ -128,9 +128,9 @@ impl App for SlidersApp { match msg { - Msg::SetHue( v ) => self.hue = v, - Msg::SetVolume( v ) => self.volume = v, - Msg::SetBrightness( v ) => self.brightness = v, + Msg::Hue( v ) => self.hue = v, + Msg::Volume( v ) => self.volume = v, + Msg::Brightness( v ) => self.brightness = v, } } diff --git a/java/net/liberux/ltk/KeepAlive.java b/java/net/liberux/ltk/KeepAlive.java new file mode 100644 index 0000000..a60e2ac --- /dev/null +++ b/java/net/liberux/ltk/KeepAlive.java @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +package net.liberux.ltk; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.ServiceInfo; +import android.os.Build; +import android.os.IBinder; + +/** A foreground service with an ongoing notification, so Android keeps the process running in the background. */ +public final class KeepAlive extends Service +{ + private static final String CHANNEL = "ltk-keep-alive"; + + @Override + public int onStartCommand( Intent intent, int flags, int startId ) + { + ApplicationInfo info = getApplicationInfo(); + CharSequence label = info.loadLabel( getPackageManager() ); + String text = intent != null ? intent.getStringExtra( "text" ) : null; + + NotificationManager manager = getSystemService( NotificationManager.class ); + manager.createNotificationChannel( new NotificationChannel( CHANNEL, label, NotificationManager.IMPORTANCE_LOW ) ); + + Intent open = getPackageManager().getLaunchIntentForPackage( getPackageName() ); + PendingIntent tap = PendingIntent.getActivity( this, 0, open, PendingIntent.FLAG_IMMUTABLE ); + Notification note = new Notification.Builder( this, CHANNEL ) + .setSmallIcon( info.icon != 0 ? info.icon : android.R.drawable.ic_dialog_info ) + .setContentTitle( label ) + .setContentText( text ) + .setContentIntent( tap ) + .setOngoing( true ) + .build(); + + if ( Build.VERSION.SDK_INT >= 34 ) + { + startForeground( 1, note, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE ); + } else { + startForeground( 1, note ); + } + return START_NOT_STICKY; + } + + @Override + public IBinder onBind( Intent intent ) + { + return null; + } +} diff --git a/scripts/android-apk.sh b/scripts/android-apk.sh new file mode 100755 index 0000000..15374b8 --- /dev/null +++ b/scripts/android-apk.sh @@ -0,0 +1,160 @@ +#!/bin/sh +# Package an ltk app built as a cdylib into an APK, with Debian's own tools: +# aapt, zipalign, apksigner and android-framework-res. The app is a +# NativeActivity with no Java code, whose library android-activity loads as +# `libmain.so`. ASSETS_DIR, when given, becomes the APK's `assets/`; the +# `ltk/` tree from android-assets.sh goes there. +# +# Usage: scripts/android-apk.sh OUT.apk LIB.so PACKAGE LABEL [ASSETS_DIR] +# +# Environment, with its defaults: +# ANDROID_ABI arm64-v8a +# MIN_SDK 26 +# TARGET_SDK 34 +# VERSION_CODE 1 +# VERSION_NAME 0.1 +# PERMISSIONS (none) space-separated, e.g. "android.permission.INTERNET" +# ICON (none) the launcher icon, SVG (through rsvg-convert) or PNG +# KEEP_ALIVE (none) 1 adds java/'s KeepAlive service, which ltk::keep_running starts +# KEEP_ALIVE_USE what keeps the app running, which Android asks of the service +# ANDROID_JAR /usr/lib/android-sdk/platforms/android-34/android.jar, to compile it against, +# and, unless FRAMEWORK_RES is set, the resources aapt links against +# KEYSTORE ~/.android/debug.keystore, created when missing +# KS_PASS android +# BUILD_TOOLS /usr/lib/android-sdk/build-tools/debian +# FRAMEWORK_RES /usr/share/android-framework-res/framework-res.apk +set -eu + +if [ $# -lt 4 ] +then + echo "usage: $0 OUT.apk LIB.so PACKAGE LABEL [ASSETS_DIR]" >&2 + exit 2 +fi +out=$1 +lib=$2 +package=$3 +label=$( printf '%s' "$4" | sed 's/&/\&/g; s/&2 + exit 1 + fi +done + +work=$( mktemp -d ) +trap 'rm -rf "$work"' EXIT + +icon_attr= +res_args= +if [ -n "${ICON:-}" ] +then + for density in mdpi:48 hdpi:72 xhdpi:96 xxhdpi:144 xxxhdpi:192 + do + dir=$work/res/mipmap-${density%%:*} + mkdir -p "$dir" + case $ICON in + *.svg) + command -v rsvg-convert > /dev/null || { echo "android-apk: rsvg-convert missing; install librsvg2-bin" >&2; exit 1; } + rsvg-convert -w "${density##*:}" -h "${density##*:}" -o "$dir/ic_launcher.png" "$ICON" ;; + *) + cp "$ICON" "$dir/ic_launcher.png" ;; + esac + done + icon_attr=' android:icon="@mipmap/ic_launcher"' + res_args="-S $work/res" +fi + +has_code=false +if [ "${KEEP_ALIVE:-}" = 1 ] +then + android_jar=${ANDROID_JAR:-/usr/lib/android-sdk/platforms/android-34/android.jar} + java_dir=$( cd "$( dirname "$0" )/.." && pwd )/java + for tool in javac dx "$android_jar" + do + if ! command -v "$tool" > /dev/null && [ ! -e "$tool" ] + then + echo "android-apk: $tool missing; install default-jdk-headless, art-standalone and google-android-platform-34-installer" >&2 + exit 1 + fi + done + mkdir -p "$work/classes" + javac -nowarn -source 8 -target 8 -bootclasspath "$android_jar" -d "$work/classes" $( find "$java_dir" -name '*.java' ) + dx --dex --min-sdk-version=26 --output="$work/classes.dex" "$work/classes" + has_code=true + # Debian's framework-res lacks the service type `specialUse`, which Android 14 added. + framework=${FRAMEWORK_RES:-$android_jar} + PERMISSIONS="${PERMISSIONS:-} android.permission.FOREGROUND_SERVICE android.permission.FOREGROUND_SERVICE_SPECIAL_USE android.permission.POST_NOTIFICATIONS" +fi + +{ + echo '' + echo '" + echo " " + echo ' ' + for permission in ${PERMISSIONS:-} + do + echo " " + done + echo " ' + echo '' +} > "$work/AndroidManifest.xml" + +mkdir -p "$work/lib/$abi" +cp "$lib" "$work/lib/$abi/libmain.so" + +if [ -n "$assets" ] +then + "$tools/aapt" package -f -M "$work/AndroidManifest.xml" -I "$framework" $res_args -A "$assets" -F "$work/unaligned.apk" +else + "$tools/aapt" package -f -M "$work/AndroidManifest.xml" -I "$framework" $res_args -F "$work/unaligned.apk" +fi +( cd "$work" && "$tools/aapt" add unaligned.apk "lib/$abi/libmain.so" > /dev/null ) +if [ "$has_code" = true ] +then + ( cd "$work" && "$tools/aapt" add unaligned.apk classes.dex > /dev/null ) +fi +zipalign -f -p 4 "$work/unaligned.apk" "$work/aligned.apk" + +if [ ! -f "$keystore" ] +then + mkdir -p "$( dirname "$keystore" )" + keytool -genkeypair -keystore "$keystore" -storepass "$pass" -keypass "$pass" \ + -alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=Android Debug,O=Android,C=US" +fi +mkdir -p "$( dirname "$out" )" +"$tools/apksigner" sign --ks "$keystore" --ks-pass "pass:$pass" --out "$out" "$work/aligned.apk" +echo "android-apk: $out" diff --git a/scripts/android-assets.sh b/scripts/android-assets.sh new file mode 100755 index 0000000..bf8a4cb --- /dev/null +++ b/scripts/android-assets.sh @@ -0,0 +1,64 @@ +#!/bin/sh +# Build the `ltk/` tree an Android package carries under `assets/`: the +# 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. +# +# Usage: scripts/android-assets.sh OUT_DIR [THEMES_DIR [THEME_ID...]] +# THEMES_DIR defaults to /usr/share/ltk/themes, THEME_ID to `default`. +# EXTRA_FONTS, space-separated paths, adds fonts the app itself reads from +# `ltk::resource_dir()/fonts`, such as a terminal's monospace face. +# KEYBOARDS_DIR defaults to the `keyboards/` next to this script's directory. +set -eu + +keyboards=${KEYBOARDS_DIR:-$( cd "$( dirname "$0" )/.." && pwd )/keyboards} + +if [ $# -lt 1 ] +then + echo "usage: $0 OUT_DIR [THEMES_DIR [THEME_ID...]]" >&2 + exit 2 +fi +out=$1 +shift +themes=/usr/share/ltk/themes +if [ $# -gt 0 ] +then + themes=$1 + shift +fi +[ $# -gt 0 ] || set -- default + +root=$out/ltk +rm -rf "$root" +mkdir -p "$root/themes" "$root/fonts" "$root/keyboards" +cp "$keyboards"/*.json "$root/keyboards/" + +for id in "$@" +do + cp -r "$themes/$id" "$root/themes/$id" + # ltk draws no cursor of its own on Android. + rm -rf "$root/themes/$id/cursors" "$root/themes/$id/cursor.theme" + grep -o '"path"[[:space:]]*:[[:space:]]*"/[^"]*"' "$themes/$id/theme.json" \ + | sed 's/.*"\(\/[^"]*\)"$/\1/' \ + | while read -r font + do + if [ -f "$font" ] + then + cp "$font" "$root/fonts/" + else + echo "android-assets: $font not found, skipped" >&2 + fi + done +done + +for font in ${EXTRA_FONTS:-} +do + cp "$font" "$root/fonts/" +done + +cd "$root" +hash=$( find . -type f ! -name MANIFEST -print0 | LC_ALL=C sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1 ) +{ + printf '# %s\n' "$hash" + find . -type f ! -name MANIFEST | sed 's|^\./||' | LC_ALL=C sort +} > MANIFEST diff --git a/src/a11y/mod.rs b/src/a11y/mod.rs index 54afd2d..91ba07c 100644 --- a/src/a11y/mod.rs +++ b/src/a11y/mod.rs @@ -26,7 +26,9 @@ //! translates the request into a synthetic press / focus on the //! target widget. //! -//! The adapter is constructed unconditionally — `accesskit_unix` +//! The adapter comes with the `wayland` feature, which brings +//! `accesskit_unix`; without it there is no [`A11yState`]. It is +//! constructed unconditionally when present — `accesskit_unix` //! never refuses creation, it just stays inactive when no AT client //! attaches to the session bus. Nothing else in the pipeline reads //! the field except the per-frame tree update and the per-iteration @@ -38,6 +40,20 @@ pub( crate ) mod tree; use std::sync::mpsc; use accesskit::{ ActionRequest, Rect as A11yRect, TreeUpdate }; +#[ cfg( feature = "wayland" ) ] +use accesskit_unix::Adapter; + +/// A build with no AT-SPI2 adapter creates no [`A11yState`], so these never run. +#[ cfg( not( feature = "wayland" ) ) ] +struct Adapter; + +#[ cfg( not( feature = "wayland" ) ) ] +impl Adapter +{ + fn update_if_active( &mut self, _factory: impl FnOnce() -> TreeUpdate ) {} + fn update_window_focus_state( &mut self, _focused: bool ) {} + fn set_root_window_bounds( &mut self, _outer: A11yRect, _inner: A11yRect ) {} +} /// Channel carrying inbound action requests from the AccessKit /// adapter thread to the run loop. @@ -48,27 +64,20 @@ pub( crate ) type ActionInbox = mpsc::Receiver; /// channel. pub( crate ) struct A11yState { - adapter: accesskit_unix::Adapter, + adapter: Adapter, pub( crate ) action_rx: ActionInbox, } impl A11yState { - /// Bring up the AccessKit adapter. Returns `None` only when we - /// reserve the right to refuse creation in the future (currently - /// always `Some` — `accesskit_unix::Adapter::new` is infallible - /// and stays inactive when no AT client is attached). Keeping - /// the `Option` lets us toggle accessibility off at runtime via - /// an env var or feature flag without re-plumbing every call - /// site. + /// Bring up the AccessKit adapter. Always `Some` for now: + /// `accesskit_unix::Adapter::new` is infallible and stays inactive + /// when no AT client is attached. + #[ cfg( feature = "wayland" ) ] pub( crate ) fn try_new( _app_name: &str, _app_id: &str ) -> Option { let ( action_tx, action_rx ) = mpsc::channel(); - let adapter = accesskit_unix::Adapter::new( - ActivationStub, - ActionForwarder { tx: action_tx }, - DeactivationStub, - ); + let adapter = Adapter::new( ActivationStub, ActionForwarder { tx: action_tx }, DeactivationStub ); Some( Self { adapter, action_rx } ) } @@ -118,8 +127,10 @@ impl A11yState /// empty root — the first proper update comes from the run loop on /// the very next frame, which is the natural moment to materialise a /// real tree (the widget_rects are populated by then). +#[ cfg( feature = "wayland" ) ] struct ActivationStub; +#[ cfg( feature = "wayland" ) ] impl accesskit::ActivationHandler for ActivationStub { fn request_initial_tree( &mut self ) -> Option @@ -132,11 +143,13 @@ impl accesskit::ActivationHandler for ActivationStub /// main event loop. The translation from `ActionRequest` to a /// synthetic press / focus happens on the main thread so it can /// touch the rest of `AppData` without locking. +#[ cfg( feature = "wayland" ) ] struct ActionForwarder { tx: mpsc::Sender, } +#[ cfg( feature = "wayland" ) ] impl accesskit::ActionHandler for ActionForwarder { fn do_action( &mut self, request: ActionRequest ) @@ -147,8 +160,10 @@ impl accesskit::ActionHandler for ActionForwarder /// Deactivation handler. AccessKit invokes this when every AT client /// detaches; we have no per-client cleanup so it's a no-op. +#[ cfg( feature = "wayland" ) ] struct DeactivationStub; +#[ cfg( feature = "wayland" ) ] impl accesskit::DeactivationHandler for DeactivationStub { fn deactivate_accessibility( &mut self ) {} diff --git a/src/a11y/tree.rs b/src/a11y/tree.rs index 08eb00b..20d78f3 100644 --- a/src/a11y/tree.rs +++ b/src/a11y/tree.rs @@ -87,6 +87,7 @@ fn overlay_root_id( focus: u8 ) -> NodeId make_id( focus, KIND_OVERLAY, 0 ) } +#[ cfg( feature = "wayland" ) ] pub( crate ) fn empty_root() -> TreeUpdate { let mut root = Node::new( Role::Window ); diff --git a/src/app.rs b/src/app.rs index 8701a84..d8c4c7b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -pub use smithay_client_toolkit::seat::keyboard::Keysym; +pub use xkeysym::Keysym; pub use calloop::channel::Sender as ChannelSender; use crate::widget::Element; @@ -65,6 +65,7 @@ pub enum Layer impl Layer { /// Convert to smithay's wlr-layer Layer type. + #[ cfg( feature = "wayland" ) ] pub( crate ) fn to_wlr_layer( self ) -> smithay_client_toolkit::shell::wlr_layer::Layer { use smithay_client_toolkit::shell::wlr_layer::Layer as WlrLayer; @@ -109,6 +110,7 @@ impl Anchor pub const RIGHT: Self = Self { top: true, bottom: true, left: false, right: true }; /// Convert to smithay's Anchor bitflags. + #[ cfg( feature = "wayland" ) ] pub( crate ) fn to_wlr_anchor( self ) -> smithay_client_toolkit::shell::wlr_layer::Anchor { use smithay_client_toolkit::shell::wlr_layer::Anchor as WlrAnchor; @@ -1382,9 +1384,10 @@ pub trait App: 'static /// Embedders that need to recover gracefully — e.g. fall back to a /// software TTY UI when no compositor is reachable — should call /// [`try_run`] instead and match on the returned [`RunError`]. +#[ cfg( backend ) ] pub fn run( app: A ) { - crate::event_loop::run( app ); + crate::platform::run( app ); } /// Run the application, returning a typed error on init failure. @@ -1443,9 +1446,11 @@ pub fn run( app: A ) /// } /// } /// ``` +#[ cfg( backend ) ] pub fn try_run( app: A ) -> Result<(), RunError> { - crate::event_loop::try_run( app ) + crate::platform::try_run( app ) } +#[ cfg( backend ) ] pub use crate::event_loop::RunError; diff --git a/src/draw/chrome.rs b/src/draw/chrome.rs index 57e565b..24d1263 100644 --- a/src/draw/chrome.rs +++ b/src/draw/chrome.rs @@ -1,58 +1,11 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -//! Surface chrome: the client-side title bar and the Wayland input -//! region. Both are backend-agnostic — the software and GPU draw -//! paths call them with a `&mut Canvas` / a `&WlSurface` and get back -//! the geometry they need for hit testing + commit. - -use smithay_client_toolkit::compositor::{ CompositorState, Region }; -use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface; +//! Surface chrome the runtime paints itself: the client-side title bar and the missing-theme banner. use crate::render::Canvas; use crate::types::{ Color, Rect }; -/// Declare the opaque part of the surface: the whole of it when the -/// background is opaque, else the app's `region` (physical rects), so the -/// compositor can skip blending it and repainting what lies underneath. -/// `applied` remembers the rects last sent. -pub( crate ) fn sync_opaque_region( - wl_surface: &WlSurface, - compositor: &CompositorState, - applied: &mut Option>, - bg: Color, - region: Option<&[Rect]>, - width: u32, - height: u32, - scale: u32, -) -{ - let s = scale.max( 1 ) as f32; - let rects: Vec = if bg.a >= 1.0 - { - vec![ Rect { x: 0.0, y: 0.0, width: width as f32 * s, height: height as f32 * s } ] - } else { - region.map( |r| r.to_vec() ).unwrap_or_default() - }; - if applied.as_ref() == Some( &rects ) { return; } - if rects.is_empty() - { - wl_surface.set_opaque_region( None ); - } else if let Ok( wl_region ) = Region::new( compositor ) { - for r in &rects - { - wl_region.add( - ( r.x / s ).round() as i32, - ( r.y / s ).round() as i32, - ( r.width / s ).round() as i32, - ( r.height / s ).round() as i32, - ); - } - wl_surface.set_opaque_region( Some( wl_region.wl_region() ) ); - } - *applied = Some( rects ); -} - /// Paint the client-side title bar (close button, divider, title text). /// /// Returns the close button rect in physical pixels so the caller can @@ -117,35 +70,3 @@ pub( crate ) fn draw_fallback_banner( let y = ( banner_h - size * sf ) / 2.0; canvas.draw_text( msg, x, y, size, Color::WHITE ); } - -/// Apply or clear the surface's input region (set by -/// `App::input_region`). `None` restores the default of "receive -/// input everywhere"; `Some(&[])` makes the surface pointer-through. -pub( crate ) fn apply_input_region( - wl_surface: &WlSurface, - compositor: &CompositorState, - input_region: Option<&[Rect]>, - scale: u32, -) -{ - if let Some( regions ) = input_region - { - if let Ok( region ) = Region::new( compositor ) - { - // `Rect`s are physical (layout space); `wl_region` is logical. - let s = scale.max( 1 ) as f32; - for r in regions - { - region.add( - ( r.x / s ).round() as i32, - ( r.y / s ).round() as i32, - ( r.width / s ).round() as i32, - ( r.height / s ).round() as i32, - ); - } - wl_surface.set_input_region( Some( region.wl_region() ) ); - } - } else { - wl_surface.set_input_region( None ); - } -} diff --git a/src/draw/gles.rs b/src/draw/gles.rs deleted file mode 100644 index fe66351..0000000 --- a/src/draw/gles.rs +++ /dev/null @@ -1,218 +0,0 @@ -// SPDX-License-Identifier: LGPL-2.1-only -// Copyright (C) 2026 Liberux Labs, S. L. - -//! GLES (EGL + FBO) draw paths. -//! -//! The GPU counterparts to [`super::software::draw_surface_full`] and -//! [`super::software::draw_surface_partial`]. Same DrawCtx flow; the -//! differences are: -//! -//! * Canvas is `Canvas::new_gles` backed by a persistent FBO rather -//! than a CPU pixmap → FBO pixels survive across frames naturally, -//! so the partial path only needs a scissor. -//! * Presentation goes through `egl_ctx.swap_buffers_with_damage` -//! which attaches + damages + commits atomically. The partial path -//! translates the top-left dirty rects into EGL's bottom-left -//! convention before passing them in. -//! * No `wl_shm` pool; no `pick_shm_format`; no byte-order swap. -//! -//! The Y-flip for the swap-with-damage call lives here (only the GPU -//! path needs it — SHM buffers are already top-down). - -use std::sync::Arc; - -use smithay_client_toolkit::compositor::CompositorState; -use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface; - -use crate::egl_context::EglContext; -use crate::event_loop::perf::RasterTrace; -use crate::event_loop::SurfaceState; -use crate::render::Canvas; -use crate::types::{ Color, Rect }; -use crate::widget::Element; - -use super::{ build_draw_ctx, commit_draw_ctx, layout_and_draw }; -use super::chrome::{ apply_input_region, draw_fallback_banner, draw_titlebar, sync_opaque_region }; - -/// GPU full-redraw path. Mirrors [`super::software::draw_surface_full`] -/// but writes into the EGL surface's persistent FBO instead of an SHM -/// buffer: -/// -/// 1. `eglMakeCurrent` so subsequent GL calls hit this surface. -/// 2. (Lazily) build the [`Canvas::Gles`], or resize its FBO if the -/// surface dimensions changed. -/// 3. Clear, run layout+draw, blit the FBO to the default framebuffer. -/// 4. Set buffer_scale + input_region (state attached to the implicit -/// commit done by `eglSwapBuffers`). -/// 5. `eglSwapBuffers` — performs the wl attach/damage/commit atomically. -pub( crate ) fn draw_surface_full_gpu( - ss: &mut SurfaceState, - compositor: &CompositorState, - egl_ctx: &Arc, - view: &Element, - bg: Color, - input_region: Option<&[Rect]>, - opaque_region: Option<&[Rect]>, - debug_layout: bool, - pw: u32, - ph: u32, - scale: u32, - request_frame: &dyn Fn( &WlSurface ), -) -{ - let Some( es ) = ss.egl_surface.as_ref() else { return }; - if egl_ctx.make_current( es ).is_err() { return; } - let trace = RasterTrace::begin(); - - let canvas = ss.canvas.get_or_insert_with( || - { - let mut c = Canvas::new_gles( - Arc::clone( egl_ctx.gl() ), egl_ctx.version, pw, ph, - ); - c.set_dpi_scale( scale as f32 ); - if let Some( reg ) = crate::theme::build_font_registry() - { - c.set_font_registry( Arc::new( reg ) ); - } - c - } ); - if canvas.size() != ( pw, ph ) - { - canvas.resize( pw, ph ); - canvas.set_dpi_scale( scale as f32 ); - } - canvas.clear_clip(); - - let sf = scale as f32; - let tb_h = ss.titlebar_height * sf; - let osk_h = crate::event_loop::osk::place_osk( &mut ss.osk, pw, ph, sf ); - let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: ( ph as f32 - tb_h - osk_h ).max( 0.0 ) }; - - if bg.a > 0.0 { canvas.fill( bg ); } else { canvas.clear(); } - - ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf ); - - let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, debug_layout ); - layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); - - if ctx.debug_layout - { - for w in &ctx.widget_rects - { - canvas.stroke_rect( w.rect, Color::rgb( 1.0, 0.0, 0.0 ), 1.5, 0.0 ); - } - } - - if let Some( panel ) = ss.osk.as_mut() { panel.draw( canvas ); } - if let Some( ref menu ) = ss.context_menu - { - super::draw_context_menu( canvas, menu ); - } - - // Fallback-theme warning. Painted last so it sits on top of every - // widget; no-op when the real theme is loaded. - draw_fallback_banner( canvas, pw, sf ); - - canvas.present(); - if let Some( t ) = trace { t.end( egl_ctx.gl(), "full", pw, ph ); } - - commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx ); - ss.content_dirty = false; - - let wl_surface = ss.surface.wl_surface(); - sync_opaque_region( wl_surface, compositor, &mut ss.opaque_full, bg, opaque_region, ss.width, ss.height, scale ); - apply_input_region( wl_surface, compositor, input_region, scale ); - // Frame callback request must precede the implicit commit done by - // `swap_buffers_with_damage`, so the compositor can attach it to this - // frame and not the previous one. - request_frame( wl_surface ); - // Full-surface damage. (0,0,w,h) is identical in EGL bottom-left and - // top-left coords, so no Y-flip needed. - let _ = egl_ctx.swap_buffers_with_damage( es, &[ ( 0, 0, pw as i32, ph as i32 ) ] ); - ss.frame_pending = true; -} - -/// GPU partial-redraw path. Same flow as -/// [`super::software::draw_surface_partial`] (clip mask → repaint -/// background + widgets → present), but the clip is implemented with -/// `glScissor` (bbox union) inside the canvas, and presentation goes -/// through `eglSwapBuffers`. The FBO is preserved between frames so -/// the unclipped pixels naturally carry over. -pub( crate ) fn draw_surface_partial_gpu( - ss: &mut SurfaceState, - compositor: &CompositorState, - egl_ctx: &Arc, - view: &Element, - bg: Color, - input_region: Option<&[Rect]>, - opaque_region: Option<&[Rect]>, - dirty_rects: Vec, - pw: u32, - ph: u32, - scale: u32, - request_frame: &dyn Fn( &WlSurface ), -) -{ - let Some( es ) = ss.egl_surface.as_ref() else { return }; - if egl_ctx.make_current( es ).is_err() { return; } - let trace = RasterTrace::begin(); - - let canvas = ss.canvas.as_mut().expect( "partial path requires existing canvas" ); - if canvas.size() != ( pw, ph ) - { - canvas.resize( pw, ph ); - canvas.set_dpi_scale( scale as f32 ); - } - canvas.set_clip_rects( &dirty_rects ); - - let sf = scale as f32; - let tb_h = ss.titlebar_height * sf; - let osk_h = crate::event_loop::osk::place_osk( &mut ss.osk, pw, ph, sf ); - let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: ( ph as f32 - tb_h - osk_h ).max( 0.0 ) }; - - if bg.a > 0.0 { canvas.fill( bg ); } - else - { - canvas.clear_rects_transparent( &dirty_rects ); - } - - ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf ); - - let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, false ); - layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); - - if let Some( panel ) = ss.osk.as_mut() { panel.draw( canvas ); } - if let Some( ref menu ) = ss.context_menu - { - super::draw_context_menu( canvas, menu ); - } - - // Fallback-theme warning. Painted last so it sits on top of every - // widget; no-op when the real theme is loaded. - draw_fallback_banner( canvas, pw, sf ); - - canvas.clear_clip(); - canvas.present(); - if let Some( t ) = trace { t.end( egl_ctx.gl(), "partial", pw, ph ); } - - commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx ); - ss.content_dirty = false; - - let wl_surface = ss.surface.wl_surface(); - sync_opaque_region( wl_surface, compositor, &mut ss.opaque_full, bg, opaque_region, ss.width, ss.height, scale ); - apply_input_region( wl_surface, compositor, input_region, scale ); - request_frame( wl_surface ); - // Convert top-left dirty rects to EGL bottom-left coords for the - // swap-with-damage call. - let damage: Vec<( i32, i32, i32, i32 )> = dirty_rects.iter().map( |r| - { - let x = r.x.floor() as i32; - let w = r.width.ceil() as i32; - let h = r.height.ceil() as i32; - let y_top = r.y.floor() as i32; - let y_bottom = ph as i32 - y_top - h; - ( x, y_bottom.max( 0 ), w.max( 0 ), h.max( 0 ) ) - } ).collect(); - let _ = egl_ctx.swap_buffers_with_damage( es, &damage ); - ss.frame_pending = true; -} diff --git a/src/draw/mod.rs b/src/draw/mod.rs index 83c2bc6..2b0a9c9 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -10,23 +10,19 @@ //! previously committed buffer is still correct. //! * **Partial** — only focus / hover / pressed changed. Install a clip //! mask covering the paint rects of the affected widgets, repaint only -//! under the clip, damage Wayland with exactly those rects. +//! under the clip, damage exactly those rects. //! * **Full** — something substantive changed (app message, animation //! tick, configure, text edit, scroll, slider drag). Clear + redraw //! the entire view, let damage tracking tighten the commit. //! -//! Each of those paths has a software variant (CPU + SHM pool) and a -//! GLES variant (FBO + EGL swap). The four resulting functions live in -//! [`software`] and [`gles`]; routing and surface-level orchestration -//! live in [`crate::event_loop::frame`]. +//! Painting is the same for the software canvas (a CPU pixmap) and the GLES one (an FBO). The backend prepares the canvas before and presents it after, through [`crate::platform::Platform`]; routing lives in [`crate::event_loop::frame`]. //! //! # Submodule layout //! -//! * [`software`] — `draw_surface_full` / `draw_surface_partial` -//! * [`gles`] — `draw_surface_full_gpu` / `draw_surface_partial_gpu` +//! * [`paint`] — `fit_canvas`, `paint_full` / `paint_partial` //! * [`damage`] — `compute_interaction_dirty_rects`, `compute_damage`, //! `clamp_rect_to` -//! * [`chrome`] — `draw_titlebar`, `apply_input_region` +//! * [`chrome`] — `draw_titlebar`, `draw_fallback_banner` //! * [`layout`] — `layout_and_draw` (the recursive element walker) use std::collections::HashMap; @@ -35,14 +31,16 @@ use crate::render::Canvas; use crate::types::{ Rect, WidgetId }; use crate::widget::LaidOutWidget; -pub( crate ) mod software; -pub( crate ) mod gles; +#[ cfg( backend ) ] +pub( crate ) mod paint; pub( crate ) mod damage; pub( crate ) mod chrome; pub( crate ) mod layout; pub( crate ) use damage::{ compute_damage, compute_interaction_dirty_rects }; pub( crate ) use layout::layout_and_draw; +#[ cfg( backend ) ] +pub( crate ) use paint::{ fit_canvas, paint_full, paint_partial }; /// Per-frame draw state threaded through [`layout_and_draw`]. Captures /// the interaction snapshot (focus / hover / pressed), scratch space @@ -107,8 +105,9 @@ pub( crate ) struct DrawCtx /// Build the per-frame [`DrawCtx`] from the surface's [`FrameState`], moving the /// carried maps (cursor / selection / scroll) out of `frame` into the context. /// The interaction indices are passed by value (they live on `SurfaceState`, not -/// `frame`). `debug_layout` is `false` on the partial paths. Shared by all four -/// frame paths; [`commit_draw_ctx`] writes the produced state back. +/// `frame`). `debug_layout` is `false` on the partial paint. Shared by the full +/// and partial paints; [`commit_draw_ctx`] writes the produced state back. +#[ cfg( backend ) ] pub( crate ) fn build_draw_ctx( frame: &mut crate::event_loop::FrameState, focused_idx: Option, @@ -144,9 +143,10 @@ pub( crate ) fn build_draw_ctx( /// Write the finished frame's [`DrawCtx`] back into the [`FrameState`]: snapshot /// the interaction indices into `prev_*` and move the produced rect lists and -/// carried maps back. Shared by all four frame paths; the caller must have +/// carried maps back. Shared by the full and partial paints; the caller must have /// already consumed anything it needs from `ctx` (e.g. `compute_damage`) before /// calling this, and resets `content_dirty` itself (it lives on `SurfaceState`). +#[ cfg( backend ) ] pub( crate ) fn commit_draw_ctx( frame: &mut crate::event_loop::FrameState, focused_idx: Option, @@ -178,10 +178,11 @@ pub( crate ) fn commit_draw_ctx( } /// Paint the built-in Copy / Cut / Paste context menu on top of the -/// finished surface content. Called from the software and GLES draw -/// paths right before `present()` so the menu sits above everything +/// finished surface content. Called from [`paint`] after the widget +/// tree so the menu sits above everything /// the widget tree painted, matching the convention every other /// toolkit follows for runtime-internal popups. +#[ cfg( backend ) ] pub( crate ) fn draw_context_menu( canvas: &mut crate::render::Canvas, menu: &crate::event_loop::context_menu::ContextMenu, diff --git a/src/draw/paint.rs b/src/draw/paint.rs new file mode 100644 index 0000000..633cba8 --- /dev/null +++ b/src/draw/paint.rs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Painting a surface's view on its canvas, the same for every backend and renderer. The backend prepares the canvas before and presents it after; see [`crate::platform::Platform`]. + +use std::sync::Arc; + +use crate::event_loop::SurfaceState; +use crate::render::Canvas; +use crate::types::{ Color, Rect }; +use crate::widget::Element; + +use super::{ build_draw_ctx, commit_draw_ctx, compute_damage, layout_and_draw, DrawCtx }; +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 +{ + let canvas = slot.get_or_insert_with( || + { + let mut c = create(); + c.set_dpi_scale( scale as f32 ); + if let Some( reg ) = crate::theme::build_font_registry() + { + c.set_font_registry( Arc::new( reg ) ); + } + c + } ); + if canvas.size() != ( pw, ph ) + { + canvas.resize( pw, ph ); + canvas.set_dpi_scale( scale as f32 ); + } + canvas +} + +/// Repaint the whole surface. Returns the rects that changed when `precise_damage` asks for them and the content did not change wholesale, else none, meaning the whole surface. +pub( crate ) fn paint_full( + ss: &mut SurfaceState, + view: &Element, + bg: Color, + debug_layout: bool, + precise_damage: bool, + pw: u32, + ph: u32, + scale: u32, +) -> Vec +{ + let canvas = ss.canvas.as_mut().expect( "canvas prepared" ); + canvas.clear_clip(); + if bg.a > 0.0 { canvas.fill( bg ); } else { canvas.clear(); } + + let ctx = paint_view( ss, view, debug_layout, pw, ph, scale ); + + let damage = if ss.content_dirty || !precise_damage + { + Vec::new() + } else { + compute_damage( + &ss.frame.widget_rects, + &ctx.widget_rects, + ss.frame.prev_focused, + ss.frame.prev_hovered, + ss.frame.prev_pressed, + ss.focused_idx, + ss.hovered_idx, + ss.gesture.pressed_idx, + pw, ph, + ) + }; + + commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx ); + ss.content_dirty = false; + damage +} + +/// Repaint only under `dirty_rects`, keeping the rest of the previous frame's pixels, and return them as the damage. +pub( crate ) fn paint_partial( + ss: &mut SurfaceState, + view: &Element, + bg: Color, + dirty_rects: Vec, + pw: u32, + ph: u32, + scale: u32, +) -> Vec +{ + let canvas = ss.canvas.as_mut().expect( "canvas prepared" ); + canvas.set_clip_rects( &dirty_rects ); + // Start the dirty rects from the background rather than the previous widget's pixels. + if bg.a > 0.0 { canvas.fill( bg ); } else { canvas.clear_rects_transparent( &dirty_rects ); } + + let ctx = paint_view( ss, view, false, pw, ph, scale ); + + if let Some( canvas ) = ss.canvas.as_mut() { canvas.clear_clip(); } + commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx ); + ss.content_dirty = false; + dirty_rects +} + +/// The title bar, the widget tree and what the runtime draws over it, on a canvas already cleared where needed. +fn paint_view( + ss: &mut SurfaceState, + view: &Element, + debug_layout: bool, + pw: u32, + ph: u32, + scale: u32, +) -> DrawCtx +{ + let canvas = ss.canvas.as_mut().expect( "canvas prepared" ); + let sf = scale as f32; + 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 ); + let top = tb_h + inset.top * sf; + let screen_rect = Rect + { + x: inset.left * sf, + y: top, + width: ( pw as f32 - ( inset.left + inset.right ) * sf ).max( 0.0 ), + height: ( ph as f32 - top - osk_h - inset.bottom * sf ).max( 0.0 ), + }; + + ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf ); + + let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, debug_layout ); + layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); + + if ctx.debug_layout + { + for w in &ctx.widget_rects + { + canvas.stroke_rect( w.rect, Color::rgb( 1.0, 0.0, 0.0 ), 1.5, 0.0 ); + } + } + + if let Some( panel ) = ss.osk.as_mut() { panel.draw( canvas ); } + if let Some( ref menu ) = ss.context_menu + { + super::draw_context_menu( canvas, menu ); + } + + // Last, so it sits over every widget. Outside a partial repaint's clip the previous frame's banner stays. + draw_fallback_banner( canvas, pw, sf ); + ctx +} diff --git a/src/draw/software.rs b/src/draw/software.rs deleted file mode 100644 index deca34e..0000000 --- a/src/draw/software.rs +++ /dev/null @@ -1,243 +0,0 @@ -// SPDX-License-Identifier: LGPL-2.1-only -// Copyright (C) 2026 Liberux Labs, S. L. - -//! Software (CPU + `wl_shm` pool) draw paths. -//! -//! Two functions, both symmetric with their GPU counterparts in -//! [`super::gles`]: -//! -//! * [`draw_surface_full`] — full redraw. Allocate a buffer, clear the -//! canvas, run layout+draw, write pixels to the SHM buffer, damage -//! the whole surface (or fine-grained rects from [`compute_damage`]), -//! commit. -//! * [`draw_surface_partial`] — clip-masked repaint. The canvas -//! pixmap from the previous frame is still valid; install a clip -//! covering only the dirty rects, repaint the background + title bar -//! + widget tree under the clip, and damage Wayland with exactly -//! those rects. -//! -//! Both paths share the DrawCtx / `layout_and_draw` / `draw_titlebar` -//! / `apply_input_region` helpers with the GPU path. - -use std::sync::Arc; - -use smithay_client_toolkit::compositor::CompositorState; -use smithay_client_toolkit::reexports::client::protocol::{ wl_shm, wl_surface::WlSurface }; - -use crate::event_loop::SurfaceState; -use crate::render::Canvas; -use crate::types::{ Color, Rect }; -use crate::widget::Element; - -use super::{ build_draw_ctx, commit_draw_ctx, compute_damage, layout_and_draw }; -use super::chrome::{ apply_input_region, draw_fallback_banner, draw_titlebar, sync_opaque_region }; - -/// Full redraw path: clear the canvas, run layout+draw for every widget, then -/// emit either tight per-widget damage or a single full-surface damage rect -/// depending on what [`compute_damage`] derives. -pub( crate ) fn draw_surface_full( - ss: &mut SurfaceState, - compositor: &CompositorState, - view: &Element, - bg: Color, - input_region: Option<&[Rect]>, - opaque_region: Option<&[Rect]>, - debug_layout: bool, - shm_format: wl_shm::Format, - swap_rb: bool, - pw: u32, - ph: u32, - scale: u32, - request_frame: &dyn Fn( &WlSurface ), -) -{ - let Some( pool ) = ss.pool.as_mut() else { return }; - let stride = pw * 4; - - let ( buffer, canvas_buf ) = match pool.create_buffer( - pw as i32, ph as i32, stride as i32, shm_format, - ) - { - Ok( r ) => r, - Err( _ ) => return, - }; - - let canvas = ss.canvas.get_or_insert_with( || { - let mut c = Canvas::new( pw, ph ); - c.set_dpi_scale( scale as f32 ); - if let Some( reg ) = crate::theme::build_font_registry() - { - c.set_font_registry( Arc::new( reg ) ); - } - c - } ); - if canvas.size() != ( pw, ph ) - { - canvas.resize( pw, ph ); - canvas.set_dpi_scale( scale as f32 ); - } - canvas.clear_clip(); - - let sf = scale as f32; - let tb_h = ss.titlebar_height * sf; - let osk_h = crate::event_loop::osk::place_osk( &mut ss.osk, pw, ph, sf ); - let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: ( ph as f32 - tb_h - osk_h ).max( 0.0 ) }; - - if bg.a > 0.0 { canvas.fill( bg ); } else { canvas.clear(); } - - ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf ); - - let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, debug_layout ); - layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); - - if ctx.debug_layout - { - for w in &ctx.widget_rects - { - canvas.stroke_rect( w.rect, Color::rgb( 1.0, 0.0, 0.0 ), 1.5, 0.0 ); - } - } - - if let Some( panel ) = ss.osk.as_mut() { panel.draw( canvas ); } - if let Some( ref menu ) = ss.context_menu - { - super::draw_context_menu( canvas, menu ); - } - - // Fallback-theme warning. Painted last so it sits on top of every - // widget; no-op when the real theme is loaded. - draw_fallback_banner( canvas, pw, sf ); - - let damage_rects = if ss.content_dirty - { - Vec::new() - } else { - compute_damage( - &ss.frame.widget_rects, - &ctx.widget_rects, - ss.frame.prev_focused, - ss.frame.prev_hovered, - ss.frame.prev_pressed, - ss.focused_idx, - ss.hovered_idx, - ss.gesture.pressed_idx, - pw, ph, - ) - }; - - commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx ); - ss.content_dirty = false; - - canvas.write_to_wayland_buf( canvas_buf, swap_rb ); - - let wl_surface = ss.surface.wl_surface(); - sync_opaque_region( wl_surface, compositor, &mut ss.opaque_full, bg, opaque_region, ss.width, ss.height, scale ); - buffer.attach_to( wl_surface ).expect( "attach" ); - - if damage_rects.is_empty() - { - wl_surface.damage_buffer( 0, 0, pw as i32, ph as i32 ); - } else { - for r in &damage_rects - { - wl_surface.damage_buffer( r.x as i32, r.y as i32, r.width as i32, r.height as i32 ); - } - } - - apply_input_region( wl_surface, compositor, input_region, scale ); - // Request a frame callback BEFORE commit so the compositor schedules it - // against this exact frame; sets `frame_pending` so the run loop won't - // try to draw this surface again until the callback fires. - request_frame( wl_surface ); - wl_surface.commit(); - ss.frame_pending = true; -} - -/// Partial redraw: install a clip mask covering only `dirty_rects`, repaint -/// the background + title bar + widget tree under that mask (so unchanged -/// pixels from the previous frame stay), and damage Wayland with exactly -/// those rects. -pub( crate ) fn draw_surface_partial( - ss: &mut SurfaceState, - compositor: &CompositorState, - view: &Element, - bg: Color, - input_region: Option<&[Rect]>, - opaque_region: Option<&[Rect]>, - shm_format: wl_shm::Format, - swap_rb: bool, - dirty_rects: Vec, - pw: u32, - ph: u32, - scale: u32, - request_frame: &dyn Fn( &WlSurface ), -) -{ - let Some( pool ) = ss.pool.as_mut() else { return }; - let stride = pw * 4; - - let ( buffer, canvas_buf ) = match pool.create_buffer( - pw as i32, ph as i32, stride as i32, shm_format, - ) - { - Ok( r ) => r, - Err( _ ) => return, - }; - - // Canvas pixels from the previous frame are still valid for the unclipped - // region and serve as our background — do not call clear()/fill() here. - let canvas = ss.canvas.as_mut().expect( "partial path requires existing canvas" ); - canvas.set_clip_rects( &dirty_rects ); - - let sf = scale as f32; - let tb_h = ss.titlebar_height * sf; - let osk_h = crate::event_loop::osk::place_osk( &mut ss.osk, pw, ph, sf ); - let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: ( ph as f32 - tb_h - osk_h ).max( 0.0 ) }; - - // Repaint surface background under the clip so the dirty rects start from - // a known state instead of leaking the previous widget's pixels. - if bg.a > 0.0 { canvas.fill( bg ); } - else - { - canvas.clear_rects_transparent( &dirty_rects ); - } - - ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf ); - - let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, false ); - layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); - - if let Some( panel ) = ss.osk.as_mut() { panel.draw( canvas ); } - if let Some( ref menu ) = ss.context_menu - { - super::draw_context_menu( canvas, menu ); - } - - // Fallback-theme warning. Painted last so it sits on top of every - // widget; no-op when the real theme is loaded. If the banner's row - // happens to fall outside the partial-redraw clip, the previous - // frame's banner is still on the canvas (pixmap preserved), which - // is the correct behaviour. - draw_fallback_banner( canvas, pw, sf ); - - canvas.clear_clip(); - - commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx ); - ss.content_dirty = false; - - canvas.write_to_wayland_buf( canvas_buf, swap_rb ); - - let wl_surface = ss.surface.wl_surface(); - sync_opaque_region( wl_surface, compositor, &mut ss.opaque_full, bg, opaque_region, ss.width, ss.height, scale ); - buffer.attach_to( wl_surface ).expect( "attach" ); - - for r in &dirty_rects - { - wl_surface.damage_buffer( r.x as i32, r.y as i32, r.width as i32, r.height as i32 ); - } - - apply_input_region( wl_surface, compositor, input_region, scale ); - request_frame( wl_surface ); - wl_surface.commit(); - ss.frame_pending = true; -} diff --git a/src/egl_context.rs b/src/egl_context.rs index e419a96..2cecfd1 100644 --- a/src/egl_context.rs +++ b/src/egl_context.rs @@ -3,28 +3,35 @@ //! EGL bootstrap for the GPU rendering path. //! -//! Initialises an `EGLDisplay` from the Wayland connection, picks an -//! `EGLConfig`, and creates an `EGLContext`. Tries GLES 3 first and falls -//! back to GLES 2 if the driver does not advertise it. On success returns an -//! [`EglContext`] holding a `glow::Context` already pointed at the resolved -//! GL functions; on failure returns `Err( reason )` so the caller can fall -//! back to the software `wl_shm` path. +//! Initialises an `EGLDisplay` from the Wayland connection, or the default +//! display on Android, picks an `EGLConfig`, and creates an `EGLContext`. +//! Tries GLES 3 first and falls back to GLES 2 if the driver does not +//! advertise it. On success returns an [`EglContext`] holding a +//! `glow::Context` already pointed at the resolved GL functions; on failure +//! returns `Err( reason )` so the caller can fall back to the software path. //! -//! Per-surface, [`EglSurface`] wraps a `wl_egl_window` plus an `EGLSurface` -//! pinned to the wayland surface. Resizing the wayland surface must call -//! [`EglSurface::resize`] so the underlying buffer follows. +//! Per-surface, [`EglSurface`] wraps an `EGLSurface` on the platform's +//! window: on Wayland a `wl_egl_window` it owns, which [`EglSurface::resize`] +//! must follow the surface's size; on Android an `ANativeWindow` the backend +//! holds, whose buffers follow `ANativeWindow_setBuffersGeometry`. +//! +//! Both need a backend feature, `wayland` or `android`; [`EglOffscreenContext`], the runtime-free target of `UiSurface`, does not. //! //! The bootstrap honours `LTK_FORCE_SOFTWARE=1` by failing fast with a //! descriptive error. Backend selection is announced exactly once per //! process via `eprintln!( "[ltk] render backend: {GLES3|GLES2|SOFTWARE (...)}" )` -//! — the GPU branch logs from [`EglContext::new`], the SOFTWARE branch from -//! [`log_software_fallback`] (called by the integration site in `draw.rs`). +//! — the GPU branch logs from the constructors of [`EglContext`] and +//! [`EglOffscreenContext`], the SOFTWARE branch from +//! [`log_software_fallback`] (called by each backend's `run`). use khronos_egl as egl; +#[ cfg( feature = "wayland" ) ] use std::ffi::c_void; use std::sync::{ Arc, Once, OnceLock }; use crate::gles_render::GlesVersion; +use crate::types::Rect; +#[ cfg( feature = "wayland" ) ] use smithay_client_toolkit::reexports::client:: { Connection, @@ -50,6 +57,7 @@ type EglInstance = egl::DynamicInstance; /// Using `eglSwapBuffersWithDamage` makes Mesa emit /// `wl_surface.damage_buffer(x, y, w, h)` with the actual rect we pass, /// which the compositor then uploads correctly. +#[ cfg( any( feature = "wayland", feature = "android" ) ) ] type SwapBuffersWithDamageFn = unsafe extern "system" fn( display: egl::EGLDisplay, surface: egl::EGLSurface, @@ -60,6 +68,7 @@ type SwapBuffersWithDamageFn = unsafe extern "system" fn( /// Process-wide EGL display + GLES context. Cheap to clone because the heavy /// state (`Arc`, `Arc`) is reference-counted; the /// raw EGL handles are POD. +#[ cfg( any( feature = "wayland", feature = "android" ) ) ] pub struct EglContext { /// Refcounted handle to the loaded `libEGL` instance. Shared with every @@ -83,16 +92,21 @@ pub struct EglContext /// place of `eglSwapBuffers` to avoid Mesa's `INT32_MAX` damage sentinel /// (see [`SwapBuffersWithDamageFn`] docs). swap_with_damage: Option, + /// 0 where the backend paces frames itself (Wayland's frame callbacks), 1 where `eglSwapBuffers` waits for the display (Android). + swap_interval: i32, } -/// Per-Wayland-surface EGL window. `egl_window` owns the `wl_egl_window`; it -/// must outlive `surface` because EGL keeps a raw pointer into it. `egl` and +/// Per-surface EGL window. On Wayland `egl_window` owns the `wl_egl_window`; +/// it must outlive `surface` because EGL keeps a raw pointer into it. On +/// Android the backend holds the `ANativeWindow` for as long. `egl` and /// `display` are kept so that `Drop` can call `eglDestroySurface` without /// requiring a `&EglContext` at the call site. +#[ cfg( any( feature = "wayland", feature = "android" ) ) ] pub struct EglSurface { /// The `wl_egl_window` backing this surface. EGL holds a raw pointer into /// it, so it must not be dropped while `surface` is alive. + #[ cfg( feature = "wayland" ) ] pub egl_window: wayland_egl::WlEglSurface, /// The `EGLSurface` bound to the Wayland surface, current target for GL /// rendering once [`EglContext::make_current`] selects it. @@ -120,6 +134,7 @@ pub struct EglOffscreenContext gl: Arc, } +#[ cfg( any( feature = "wayland", feature = "android" ) ) ] impl Drop for EglSurface { fn drop( &mut self ) @@ -140,13 +155,34 @@ impl Drop for EglOffscreenContext } } +#[ cfg( any( feature = "wayland", feature = "android" ) ) ] impl EglContext { /// Initialise EGL on `conn`. Returns `Err( reason )` if EGL cannot be /// used (forced software, library missing, no compatible config, ES2/3 /// context creation failed). The caller falls back to SHM and is /// expected to log via [`log_software_fallback`]. + #[ cfg( feature = "wayland" ) ] pub fn new( conn: &Connection ) -> Result + { + let wl_display_ptr = conn.backend().display_ptr() as *mut c_void; + // SAFETY: `wl_display_ptr` comes from `Connection::backend().display_ptr()`, + // which guarantees a valid `wl_display *` for as long as `conn` lives. + // `conn` is a `&Connection` argument so the pointer is valid for the + // duration of this call. EGL retains the display reference internally + // and we keep `egl` alive for the process lifetime. + Self::init( |egl| unsafe { egl.get_display( wl_display_ptr ) } + .ok_or_else( || "eglGetDisplay returned NULL".to_string() ), 0 ) + } + + /// Initialise EGL on Android's default display, with `eglSwapBuffers` waiting for the display so drawing follows its refresh. Fails as [`Self::new`] does. + #[ cfg( feature = "android" ) ] + pub fn new_android() -> Result + { + Self::init( default_display, 1 ) + } + + fn init( get_display: impl FnOnce( &EglInstance ) -> Result, swap_interval: i32 ) -> Result { if std::env::var( "LTK_FORCE_SOFTWARE" ).map( |v| v != "0" ).unwrap_or( false ) { @@ -163,14 +199,7 @@ impl EglContext .map_err( |e| format!( "load libEGL: {e:?}" ) )?, ); - let wl_display_ptr = conn.backend().display_ptr() as *mut c_void; - // SAFETY: `wl_display_ptr` comes from `Connection::backend().display_ptr()`, - // which guarantees a valid `wl_display *` for as long as `conn` lives. - // `conn` is a `&Connection` argument so the pointer is valid for the - // duration of this call. EGL retains the display reference internally - // and we keep `egl` alive for the process lifetime. - let display = unsafe { egl.get_display( wl_display_ptr ) } - .ok_or_else( || "eglGetDisplay returned NULL".to_string() )?; + let display = get_display( &egl )?; egl.initialize( display ) .map_err( |e| format!( "eglInitialize: {e:?}" ) )?; @@ -237,7 +266,7 @@ impl EglContext .and_then( |n| egl.get_proc_address( n ) ) .map( |p| unsafe { std::mem::transmute::<_, SwapBuffersWithDamageFn>( p ) } ); - Ok( Self { egl, display, config, context, version, gl: OnceLock::new(), swap_with_damage } ) + Ok( Self { egl, display, config, context, version, gl: OnceLock::new(), swap_with_damage, swap_interval } ) } /// Access the lazily-constructed `glow::Context`. Must be called only after @@ -248,6 +277,7 @@ impl EglContext } /// Create an `EGLSurface` pinned to `wl_surface` at the given pixel size. + #[ cfg( feature = "wayland" ) ] pub fn create_surface( &self, wl_surface: &WlSurface, width: i32, height: i32, ) -> Result @@ -279,6 +309,24 @@ impl EglContext } ) } + /// Create an `EGLSurface` on an `ANativeWindow`, sized by its buffers. + /// + /// # Safety + /// `window` must be a live `ANativeWindow *` that the caller keeps acquired until the returned surface is dropped. + #[ cfg( feature = "android" ) ] + pub unsafe fn create_native_surface( &self, window: *mut std::ffi::c_void ) -> Result + { + let surface = unsafe { self.egl.create_window_surface( self.display, self.config, window as egl::NativeWindowType, None ) } + .map_err( |e| format!( "eglCreateWindowSurface: {e:?}" ) )?; + Ok( EglSurface { surface, egl: Arc::clone( &self.egl ), display: self.display } ) + } + + /// Leave no surface current, so one about to be destroyed with its window is released at once. + pub fn release_current( &self ) + { + let _ = self.egl.make_current( self.display, None, None, None ); + } + /// Make `surface` current for subsequent GL calls. The first successful /// call lazily constructs the shared `glow::Context` (glow needs a live /// current context to read `GL_VERSION` during initialisation). @@ -290,9 +338,10 @@ impl EglContext Some( surface.surface ), Some( self.context ), ).map_err( |e| format!( "eglMakeCurrent: {e:?}" ) )?; - // ltk paces itself on wl_surface.frame; Mesa's own throttle would - // block eglSwapBuffers until the previous frame's callback. - let _ = self.egl.swap_interval( self.display, 0 ); + // On Wayland ltk paces itself on wl_surface.frame, and Mesa's own + // throttle would block eglSwapBuffers until the previous frame's + // callback; on Android that wait is the only pacing there is. + let _ = self.egl.swap_interval( self.display, self.swap_interval ); if self.gl.get().is_none() { @@ -377,6 +426,7 @@ impl EglContext } } +#[ cfg( feature = "wayland" ) ] impl EglSurface { /// Resize the backing `wl_egl_window` to `width` x `height` physical @@ -407,7 +457,7 @@ impl EglOffscreenContext .map_err( |e| format!( "load libEGL: {e:?}" ) )?, ); - let display = offscreen_display( &egl )?; + let display = default_display( &egl )?; egl.initialize( display ) .map_err( |e| format!( "eglInitialize: {e:?}" ) )?; egl.bind_api( egl::OPENGL_ES_API ) @@ -515,7 +565,7 @@ fn try_create_context( egl.create_context( display, config, None, &attribs ) } -fn offscreen_display( egl: &EglInstance ) -> Result +fn default_display( egl: &EglInstance ) -> Result { // SAFETY: `DEFAULT_DISPLAY` is the EGL sentinel for "the default display // for the current platform" and is always a valid argument to @@ -527,12 +577,27 @@ fn offscreen_display( egl: &EglInstance ) -> Result } /// Log the SOFTWARE fallback once, with the reason. The GPU branch logs -/// from [`EglContext::new`]. +/// from the context's constructor. pub fn log_software_fallback( reason: &str ) { log_backend_once( &format!( "SOFTWARE ({reason})" ) ); } +/// `damage` as `eglSwapBuffersWithDamage` takes it, bottom-left origin; the whole `pw × ph` buffer when empty. +pub( crate ) fn egl_damage( damage: &[Rect], pw: u32, ph: u32 ) -> Vec<( i32, i32, i32, i32 )> +{ + if damage.is_empty() { return vec![ ( 0, 0, pw as i32, ph as i32 ) ]; } + damage.iter().map( |r| + { + let x = r.x.floor() as i32; + let w = r.width.ceil() as i32; + let h = r.height.ceil() as i32; + let y_top = r.y.floor() as i32; + let y_bottom = ph as i32 - y_top - h; + ( x, y_bottom.max( 0 ), w.max( 0 ), h.max( 0 ) ) + } ).collect() +} + fn log_backend_once( label: &str ) { static ONCE: Once = Once::new(); @@ -541,3 +606,39 @@ fn log_backend_once( label: &str ) eprintln!( "[ltk] render backend: {label}" ); } ); } + +#[ cfg( test ) ] +mod tests +{ + use super::egl_damage; + use crate::types::Rect; + + fn rect( x: f32, y: f32, width: f32, height: f32 ) -> Rect + { + Rect { x, y, width, height } + } + + #[ test ] + fn egl_damage_without_rects_is_the_whole_buffer() + { + assert_eq!( egl_damage( &[], 800, 600 ), vec![ ( 0, 0, 800, 600 ) ] ); + } + + #[ test ] + fn egl_damage_counts_from_the_bottom() + { + assert_eq!( egl_damage( &[ rect( 10.0, 20.0, 30.0, 40.0 ) ], 800, 600 ), vec![ ( 10, 540, 30, 40 ) ] ); + } + + #[ test ] + fn egl_damage_grows_fractional_rects_outwards() + { + assert_eq!( egl_damage( &[ rect( 10.5, 20.5, 29.2, 39.2 ) ], 800, 600 ), vec![ ( 10, 540, 30, 40 ) ] ); + } + + #[ test ] + fn egl_damage_clamps_a_rect_past_the_bottom() + { + assert_eq!( egl_damage( &[ rect( 0.0, 590.0, 10.0, 20.0 ) ], 800, 600 ), vec![ ( 0, 0, 10, 20 ) ] ); + } +} diff --git a/src/event_loop/a11y.rs b/src/event_loop/a11y.rs new file mode 100644 index 0000000..4467a5f --- /dev/null +++ b/src/event_loop/a11y.rs @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The runtime's side of accessibility: the tree handed to the adapter after a draw, and the actions an assistive technology sends back. + +use accesskit::{ Action, ActionData, ActionRequest }; + +use crate::a11y::tree::{ build_tree, parse_id, SurfaceView }; +use crate::app::{ App, OverlayId }; +use crate::platform::Platform; +use crate::types::Point; +use crate::widget::WidgetHandlers; + +use super::{ AppData, SurfaceFocus }; + +impl AppData +{ + /// Hand the adapter the tree of the main surface and of every overlay over it. The tree is only built while an assistive technology listens. + pub( crate ) fn publish_a11y_tree( &mut self ) + { + let Some( mut a11y ) = self.a11y.take() else { return }; + let main_w = self.main.width as f32; + let main_h = self.main.height as f32; + let mut overlay_meta: Vec<( u8, OverlayId, f32, f32 )> = Vec::new(); + let mut next_id: u8 = 1; + for ( id, ss ) in self.overlays.iter() + { + if next_id == 0 { break; } + let ( ox, oy ) = self.surface_offset_for( SurfaceFocus::Overlay( *id ) ); + let ( w, h ) = ( ss.width as f32, ss.height as f32 ); + if w <= 0.0 || h <= 0.0 || ( ox + w <= 0.0 ) || ( oy + h <= 0.0 ) || ox >= main_w || oy >= main_h { continue; } + overlay_meta.push( ( next_id, *id, ox, oy ) ); + next_id = next_id.saturating_add( 1 ); + } + let kb_focus_id = match self.keyboard_focus + { + SurfaceFocus::Main => 0u8, + SurfaceFocus::Overlay( id ) => + overlay_meta.iter().find( |( _, oid, _, _ )| *oid == id ).map( |( i, _, _, _ )| *i ).unwrap_or( 0 ), + }; + let mut surfaces: Vec> = Vec::new(); + surfaces.push( SurfaceView + { + focus_id: 0, + is_main: true, + label: None, + widget_rects: &self.main.frame.widget_rects, + extras: &self.main.frame.accessible_extras, + focused_idx: self.main.focused_idx, + pressed_idx: self.main.gesture.pressed_idx, + pending_text_values: &self.main.pending_text_values, + cursor_state: &self.main.frame.cursor_state, + width: main_w, + height: main_h, + offset_x: 0.0, + offset_y: 0.0, + } ); + for ( fid, id, ox, oy ) in &overlay_meta + { + let Some( ss ) = self.overlays.get( id ) else { continue }; + surfaces.push( SurfaceView + { + focus_id: *fid, + is_main: false, + label: None, + widget_rects: &ss.frame.widget_rects, + extras: &ss.frame.accessible_extras, + focused_idx: ss.focused_idx, + pressed_idx: ss.gesture.pressed_idx, + pending_text_values: &ss.pending_text_values, + cursor_state: &ss.frame.cursor_state, + width: ss.width as f32, + height: ss.height as f32, + offset_x: *ox, + offset_y: *oy, + } ); + } + let app_name = self.app.app_id(); + a11y.update( || build_tree( &surfaces, kb_focus_id, app_name ) ); + self.a11y = Some( a11y ); + } + + /// Turn each pending action of an assistive technology (a screen reader pressing a button, a switch device moving the focus) into a press, a focus, a value or a scroll of its widget. Actions on widgets that went away since are dropped. + pub( crate ) fn apply_a11y_actions( &mut self ) + { + let requests: Vec = match self.a11y + { + Some( ref a ) => a.action_rx.try_iter().collect(), + None => return, + }; + if requests.is_empty() { return; } + let overlay_keys: Vec = self.overlays.keys().copied().collect(); + for req in requests + { + let Some( r ) = parse_id( req.target ) else { continue }; + if r.kind != 0 { continue; } + let idx = r.idx as usize; + let focus_target = if r.focus == 0 + { + SurfaceFocus::Main + } else { + let oi = ( r.focus as usize ).saturating_sub( 1 ); + let Some( id ) = overlay_keys.get( oi ).copied() else { continue }; + SurfaceFocus::Overlay( id ) + }; + let widget = self.try_surface( focus_target ) + .and_then( |ss| ss.frame.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned() ); + match req.action + { + Action::Click => + { + if let Some( msg ) = widget.as_ref().and_then( |w| w.handlers.press_msg() ) + { + self.pending_msgs.push( msg ); + } + } + Action::Focus => self.set_focus( focus_target, Some( idx ) ), + Action::SetValue => + { + let Some( w ) = widget else { continue }; + let msg = match ( &w.handlers, req.data ) + { + ( WidgetHandlers::Slider { .. }, Some( ActionData::NumericValue( v ) ) ) => w.handlers.slider_change_msg( v.clamp( 0.0, 1.0 ) as f32 ), + ( WidgetHandlers::TextEdit { .. }, Some( ActionData::Value( s ) ) ) => w.handlers.text_change_msg( &s ), + _ => None, + }; + self.pending_msgs.extend( msg ); + } + Action::Increment | Action::Decrement => + { + let Some( w ) = widget else { continue }; + let WidgetHandlers::Slider { value, .. } = &w.handlers else { continue }; + let step = if matches!( req.action, Action::Increment ) { 0.05 } else { -0.05 }; + if let Some( msg ) = w.handlers.slider_change_msg( ( *value + step ).clamp( 0.0, 1.0 ) ) + { + self.pending_msgs.push( msg ); + } + } + Action::ScrollUp | Action::ScrollDown | Action::ScrollLeft | Action::ScrollRight => + { + const STEP: f32 = 80.0; + let ( dx, dy ) = match req.action + { + Action::ScrollLeft => ( -STEP, 0.0 ), + Action::ScrollRight => ( STEP, 0.0 ), + Action::ScrollUp => ( 0.0, -STEP ), + _ => ( 0.0, STEP ), + }; + let Some( ss ) = self.try_surface_mut( focus_target ) else { continue }; + let Some( &( _, scroll_idx, _ ) ) = ss.frame.scroll_rects.last() else { continue }; + let entry = ss.frame.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) ); + entry.0 = ( entry.0 + dx ).max( 0.0 ); + entry.1 = ( entry.1 + dy ).max( 0.0 ); + ss.request_redraw(); + } + Action::ScrollIntoView => + { + let Some( w ) = widget else { continue }; + let Some( ss ) = self.try_surface_mut( focus_target ) else { continue }; + let target = w.rect; + let probe = Point { x: target.x + 1.0, y: target.y + 1.0 }; + let Some( ( r, idx, _ ) ) = ss.frame.scroll_rects.iter().rev() + .find( |( r, _, _ )| r.contains( probe ) ) + .copied() + else { continue }; + let entry = ss.frame.scroll_offsets.entry( idx ).or_insert( ( 0.0, 0.0 ) ); + if target.y < r.y { entry.1 -= r.y - target.y; } + if target.y + target.height > r.y + r.height + { + entry.1 += ( target.y + target.height ) - ( r.y + r.height ); + } + if target.x < r.x { entry.0 -= r.x - target.x; } + if target.x + target.width > r.x + r.width + { + entry.0 += ( target.x + target.width ) - ( r.x + r.width ); + } + entry.0 = entry.0.max( 0.0 ); + entry.1 = entry.1.max( 0.0 ); + ss.request_redraw(); + } + _ => {} + } + } + } +} diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index e2c7a4d..977a783 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -1,83 +1,22 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use smithay_client_toolkit:: -{ - compositor::CompositorState, - output::OutputState, - registry::RegistryState, - seat::SeatState, - shell::{ wlr_layer::LayerShell, xdg::XdgShell }, - shm::Shm, - session_lock::SessionLock, - subcompositor::SubcompositorState, -}; -use smithay_client_toolkit::reexports::client:: -{ - protocol:: - { - wl_keyboard::WlKeyboard, - wl_output::WlOutput, - wl_pointer::WlPointer, - wl_surface::WlSurface, - wl_touch::WlTouch, - }, - QueueHandle, -}; -use wayland_protocols::wp::text_input::zv3::client:: -{ - zwp_text_input_manager_v3::ZwpTextInputManagerV3, - zwp_text_input_v3::ZwpTextInputV3, -}; use std::collections::HashMap; -use std::sync::Arc; -use crate::app::{ App, OverlayId, SubsurfaceId }; -use crate::egl_context::EglContext; +use crate::app::{ App, OverlayId }; +use crate::platform::{ NativePlatform, Platform }; use crate::types::Point; use super::repeat::{ ButtonRepeatState, KeyRepeatState }; -use super::subsurface::SubsurfaceSlot; use super::surface::{ SurfaceFocus, SurfaceState }; use super::tooltip::{ TooltipPending, TooltipVisible }; -pub struct AppData +/// The runtime's state for one app, on the platform `P`: the build's backend unless a test supplies another. +pub struct AppData> { pub app: A, - pub registry_state: RegistryState, - pub seat_state: SeatState, - pub output_state: OutputState, - pub compositor_state: CompositorState, - /// `wl_subcompositor` binding for [`crate::app::App::subsurfaces`]. - /// `None` when the compositor does not advertise the protocol. - pub subcompositor: Option, - pub shm: Shm, - pub session_lock: Option, - /// Process-wide EGL context (display + GLES context). `None` when EGL - /// failed to initialise or `LTK_FORCE_SOFTWARE=1` — every surface then - /// falls back to the SHM path. - pub egl_context: Option>, - #[ allow( dead_code ) ] - pub xdg_shell: Option, - /// Shared layer-shell binding used for the main surface and every - /// overlay. `None` when the compositor does not advertise the protocol. - pub layer_shell: Option, - pub keyboard: Option, - pub pointer: Option, - pub touch: Option, + pub platform: P, pub pointer_pos: Point, - /// Process-wide cursor-shape manager (`wp_cursor_shape_v1`). - /// `None` when the compositor does not advertise the protocol — - /// the runtime then leaves cursor shape to the compositor's - /// defaults. - pub cursor_shape_manager: Option, - /// Per-pointer cursor-shape device, populated when the compositor - /// supports cursor-shape AND the seat has a pointer capability. - pub cursor_shape_device: Option, - /// Last `Enter` serial seen on a pointer event. Required by - /// `wp_cursor_shape_device_v1::set_shape` per spec — the - /// compositor tags every cursor change with the entry serial. - pub last_pointer_enter_serial: u32, /// The cursor shape currently active on the pointer. `None` /// means "we have not pushed any shape since the last /// `wl_pointer.enter`" — the compositor is showing whatever the @@ -86,8 +25,6 @@ pub struct AppData /// surface. `Some(s)` lets the dispatch short-circuit when the /// target equals what we already sent. pub current_cursor_shape: Option, - pub text_input_manager: Option, - pub text_input: Option, /// No input method to ask for: the runtime draws its own keyboard under a focused text field. pub builtin_osk: bool, /// Whether the currently focused text field is `secure` (password). @@ -96,53 +33,11 @@ pub struct AppData pub text_input_purpose: crate::osk::Purpose, /// Serial of the [`App::external_text_input`] request the text-input is held for. pub external_text_input: Option, - /// `xdg-activation-v1`. Present when the compositor advertises the - /// global. Used on first configure to honour an incoming - /// `XDG_ACTIVATION_TOKEN` (a launcher that spawned us wants the - /// main surface raised to focus) and exposed via - /// [`App::request_activation_token`] for outbound requests. - pub activation_state: - Option, - /// Honour the activation token exactly once — after the first - /// successful configure of the main surface. Subsequent configures - /// (resizes, scale changes) must not re-activate. - pub activation_token_pending: Option, - /// `xdg-session-management-v1` proxies plus the on-disk store; the - /// store is `None` when persistence is off (layer / lock surfaces, - /// unusable app_id, concurrent instance, or after `replaced`). - pub session: super::session::SessionRuntime, - - /// `wl_data_device_manager` binding. `None` when the compositor - /// does not advertise the global, in which case copy / paste stays - /// process-local and inbound selections from other clients are - /// invisible. - pub data_device_manager: - Option, - /// Per-seat `wl_data_device` handle, created the first time a seat - /// with a keyboard or pointer capability appears. Required for - /// `set_selection` and to receive inbound `selection` events. - pub data_device: - Option, - /// Currently-published outbound selection source. Held alive - /// across [`DataSourceHandler::send_request`] so the cached - /// clipboard text can be re-served on each paste from the peer. - pub clipboard_source: - Option, - /// Sender half of the cross-thread channel used to ferry inbound - /// selection bytes (a worker thread drains the read pipe). - pub clipboard_inbox_tx: std::sync::mpsc::Sender, - /// Receiver half — drained once per run loop iteration. - pub clipboard_inbox_rx: std::sync::mpsc::Receiver, - pub primary: super::primary_selection::PrimarySelection, - - /// Cross-application drag-and-drop: best mime negotiated on - /// `enter` and the last pointer position (in logical surface - /// coords). Cleared on `leave` and after `drop_performed`. - pub drop_position: Option<( f64, f64 )>, - pub drop_mime: Option, - pub drop_inbox_tx: std::sync::mpsc::Sender, - pub drop_inbox_rx: std::sync::mpsc::Receiver, + /// The selection the middle button pastes, ours or another application's. + pub primary_selection: String, + /// The selection of the focused text field when it was last looked at. + pub primary_field: Option, /// AccessKit / AT-SPI2 adapter. `None` when the platform adapter /// could not be created (no AT-SPI2 daemon on the session bus, @@ -152,16 +47,6 @@ pub struct AppData /// the per-frame tree update and the per-iteration action /// inbox drain. pub a11y: Option, - /// Client-side handle to `ext-foreign-toplevel-list-v1`. Holds the - /// global proxy plus the live list of open toplevels; SCTK fans - /// events through this object into our `ForeignToplevelListHandler` - /// impl in `handlers.rs`, which then routes them to - /// `App::on_toplevel_event`. Present on every session, even when - /// the compositor does not advertise the global — internally the - /// list holds a `GlobalProxy` that simply yields no toplevels in - /// that case. - pub foreign_toplevel_list: - smithay_client_toolkit::foreign_toplevel_list::ForeignToplevelList, pub shift_pressed: bool, pub ctrl_pressed: bool, pub alt_pressed: bool, @@ -187,12 +72,9 @@ pub struct AppData /// long-press promotion. pub button_repeat: Option, /// Clipboard buffer. Copy / Cut store the selected text here; - /// Paste retrieves from here. Synchronised with the Wayland - /// selection through `wl_data_device_manager` when the compositor - /// advertises the global: outbound copies publish a - /// `CopyPasteSource`, and inbound selections from other clients - /// land here through a worker thread (see - /// [`super::data_device`]). + /// Paste retrieves from here. Outbound copies are offered to other + /// applications through [`Platform::publish_clipboard`]; the backend + /// writes their selections here. pub clipboard: String, /// Timestamp of the previous press (pointer or touch). Combined /// with [`Self::last_press_pos`] it lets the press handler @@ -211,32 +93,16 @@ pub struct AppData pub pending_drag_inits: Vec, pub tooltip_pending: Option, pub tooltip_visible: Option, - pub qh: QueueHandle, - /// Last pointer serial (needed for interactive move). - pub last_pointer_serial: u32, - /// Last serial from any input event. Required by `xdg_popup.grab`, - /// which only honours serials from recent input events on the same - /// seat. - pub last_input_serial: u32, /// Set to true when the app has accepted a close request. pub exit_requested: bool, - /// First-configure latch for `App::start_fullscreen`. - pub pending_fullscreen: bool, /// Last value of `App::fullscreen` sent to the compositor. pub applied_fullscreen: Option, - /// First-configure latch: clears `max_size` after the compositor - /// has honoured the pinned initial size. - pub pending_size_hint_unpin: bool, - /// First-configure latch: check the size hint against the screen. - pub pending_size_hint_fit: bool, - /// The size hint once shrunk to fit the screen; replaces the app's. - pub fitted_size_hint: Option<( u32, u32 )>, /// Main application surface. - pub main: SurfaceState, + pub main: SurfaceState, /// Auxiliary layer-shell surfaces keyed by their stable [`OverlayId`]. /// Populated and reconciled by the run loop from [`App::overlays`]. Always /// empty until overlay diffing is wired up. - pub overlays: HashMap>, + pub overlays: HashMap>, /// Surfaces covering every output beyond the first, paired with the /// output each one belongs to: session-lock surfaces while locked (the /// compositor blanks any output without one), background layer surfaces @@ -244,20 +110,7 @@ pub struct AppData /// extended outputs). These render [`App::secondary_view`] and take no /// input (the input handlers find no focus for them, and keyboard falls /// through to `Main`). - pub secondaries: Vec<( WlOutput, SurfaceState )>, - /// Output the main surface was created on, so a later `new_output` for - /// the same output does not add a duplicate secondary surface. - pub main_output: Option, - /// Input-transparent child surfaces keyed by their stable - /// [`SubsurfaceId`]. Reconciled each frame from [`App::subsurfaces`]. - pub subsurfaces: HashMap, - /// Shared GLES canvas reused across every subsurface raster. Held here - /// (not per-slot) so a subsurface that is dropped and re-created — e.g. a - /// lazily-emitted sliding panel — does not recompile the ~14 shader - /// programs (hundreds of ms on a mobile GPU) every time it reappears. - pub subsurface_gles_canvas: Option, - /// GLES canvases of destroyed overlays, reused by the next overlay. - pub canvas_pool: Vec, + pub secondaries: Vec<( P::Output, SurfaceState )>, /// Surface currently receiving pointer events (updated on each pointer /// event via its `surface` field). pub pointer_focus: SurfaceFocus, @@ -296,61 +149,31 @@ pub struct AppData pub perf: super::perf::PerfState, } -impl AppData +impl AppData { - /// Forward a change of [`App::fullscreen`] to the main toplevel. + /// Whether the main surface is an application window the user can move, resize and minimise. + pub( crate ) fn main_is_toplevel( &self ) -> bool + { + self.platform.is_toplevel( &self.main.platform ) + } + + /// Forward a change of [`App::fullscreen`] to the main window. pub( crate ) fn sync_fullscreen( &mut self ) { let Some( wanted ) = self.app.fullscreen() else { return }; if self.applied_fullscreen == Some( wanted ) { return; } - let super::SurfaceKind::Window( ref window ) = self.main.surface else { return }; - if wanted + if self.platform.set_fullscreen( &self.main.platform, wanted ) { - window.set_fullscreen( None ); - } else { - window.unset_fullscreen(); + self.applied_fullscreen = Some( wanted ); } - self.applied_fullscreen = Some( wanted ); - } - - /// Find which [`SurfaceFocus`] owns the given `WlSurface`, or `None` if it - /// does not correspond to any tracked surface. Safe even when the main - /// surface is still `Pending` (returns `None`). - pub( crate ) fn focus_for_surface( - &self, - wl: &WlSurface, - ) -> Option - { - if self.main.surface.try_wl_surface() == Some( wl ) - { - return Some( SurfaceFocus::Main ); - } - for ( id, ss ) in self.overlays.iter() - { - if ss.surface.try_wl_surface() == Some( wl ) - { - return Some( SurfaceFocus::Overlay( *id ) ); - } - } - None - } - - /// Whether `wl` is one of the secondary per-output surfaces. Pointer and - /// touch events landing on them must be dropped rather than falling back - /// to `Main` — their coordinates would hit-test the main surface's - /// widgets. Keyboard keeps the `Main` fallback so typing works wherever - /// the compositor puts the focus. - pub( crate ) fn is_secondary_surface( &self, wl: &WlSurface ) -> bool - { - self.secondaries.iter().any( |( _, ss )| ss.surface.try_wl_surface() == Some( wl ) ) } /// Borrow the [`SurfaceState`] identified by `focus`. Panics if `focus` /// refers to an overlay that is not currently registered — callers must - /// only pass focus values obtained from [`focus_for_surface`] or from the + /// only pass focus values obtained from the backend's surface lookup or from the /// per-device focus fields, which the run loop keeps in sync. #[ allow( dead_code ) ] - pub( crate ) fn surface( &self, focus: SurfaceFocus ) -> &SurfaceState + pub( crate ) fn surface( &self, focus: SurfaceFocus ) -> &SurfaceState { match focus { @@ -363,7 +186,7 @@ impl AppData /// Non-panicking variant of [`surface`]. Returns `None` when `focus` /// refers to an overlay that has already been removed — callers on /// async dispatch paths (IME Done, tooltip arm) must use this. - pub( crate ) fn try_surface( &self, focus: SurfaceFocus ) -> Option<&SurfaceState> + pub( crate ) fn try_surface( &self, focus: SurfaceFocus ) -> Option<&SurfaceState> { match focus { @@ -374,7 +197,7 @@ impl AppData /// Mutable counterpart of [`surface`]. #[ allow( dead_code ) ] - pub( crate ) fn surface_mut( &mut self, focus: SurfaceFocus ) -> &mut SurfaceState + pub( crate ) fn surface_mut( &mut self, focus: SurfaceFocus ) -> &mut SurfaceState { match focus { @@ -385,7 +208,7 @@ impl AppData } /// Non-panicking variant of [`surface_mut`]. - pub( crate ) fn try_surface_mut( &mut self, focus: SurfaceFocus ) -> Option<&mut SurfaceState> + pub( crate ) fn try_surface_mut( &mut self, focus: SurfaceFocus ) -> Option<&mut SurfaceState> { match focus { @@ -447,37 +270,20 @@ impl AppData } } - // Configure the main surface size, (re)allocate its rendering target, and - // request a redraw. Routes to the GPU or SHM path inside `SurfaceState` - // according to whether `self.egl_context` is available. - pub( crate ) fn on_configure( &mut self, w: u32, h: u32 ) + /// Tell the app and the accessibility tree the main surface is now `w × h` logical pixels at its scale, once the backend has resized it. + pub( crate ) fn on_main_resized( &mut self, w: u32, h: u32 ) { - self.main.on_configure( &self.shm, self.egl_context.as_ref(), w, h ); if let Some( ref mut a ) = self.a11y { let sf = self.main.scale_factor.max( 1 ) as f64; a.set_window_bounds( ( w as f64 ) * sf, ( h as f64 ) * sf ); a.set_window_focus( true ); } - // First-configure latch: honour an incoming - // `XDG_ACTIVATION_TOKEN` exactly once, after the surface has - // been mapped (`xdg_activation_v1.activate` is only meaningful - // against a configured surface). Compositors that don't - // advertise the global leave `activation_state` as `None` and - // the call drops the token silently. - if let Some( token ) = self.activation_token_pending.take() - { - if let ( Some( ref activation ), Some( wl ) ) = - ( self.activation_state.as_ref(), self.main.surface.try_wl_surface() ) - { - activation.activate::( &wl, token ); - } - } // `on_resize` is documented to deliver **physical** pixels, matching the // coordinate space that the layout passes and the pointer/touch - // callbacks (`on_drag_move`, `on_drop`) work in. Wayland's - // configure.new_size is surface-local (logical), so multiply by the - // current buffer scale before handing the dimensions to the app. + // 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 ); diff --git a/src/event_loop/clipboard.rs b/src/event_loop/clipboard.rs index 09b6e64..5068736 100644 --- a/src/event_loop/clipboard.rs +++ b/src/event_loop/clipboard.rs @@ -4,18 +4,45 @@ use super::app_data::AppData; use super::surface::SurfaceFocus; use crate::app::App; +use crate::platform::Platform; use crate::tree::find_handlers; -impl AppData +impl AppData { - /// Copy the currently-selected text into the process-local - /// clipboard. No-op when there is no selection. + /// Make `text` the clipboard, for other applications as well when the system allows. + pub( crate ) fn publish_clipboard( &mut self, text: String ) + { + self.platform.publish_clipboard( &text ); + self.clipboard = text; + } + + /// Make `text` the selection the middle button pastes, for other applications as well when the system allows. + pub( crate ) fn publish_primary_selection( &mut self, text: String ) + { + self.platform.publish_primary_selection( &text ); + self.primary_selection = text; + } + + /// Publish a new selection in the focused text field once the press that makes it ends; a password field's never leaves it. + pub( crate ) fn sync_text_field( &mut self ) + { + let focus = self.keyboard_focus; + if self.try_surface( focus ).is_none_or( |ss| ss.gesture.pressed_idx.is_some() ) { return; } + let secure = self.surface( focus ).focused_idx + .and_then( |idx| self.text_input_geometry( focus, idx ) ) + .is_some_and( |( _, _, _, secure, _, _ )| secure ); + let selected = if secure { None } else { self.focused_selection_text( focus ) }; + if selected == self.primary_field { return; } + self.primary_field = selected.clone(); + if let Some( text ) = selected { self.publish_primary_selection( text ); } + } + + /// Copy the selected text to the clipboard. No-op when there is no selection. pub( crate ) fn handle_copy( &mut self, focus: SurfaceFocus ) { if let Some( text ) = self.focused_selection_text( focus ) { - self.clipboard = text; - self.publish_clipboard_selection(); + self.publish_clipboard( text ); } } @@ -26,8 +53,7 @@ impl AppData let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return }; if let Some( text ) = self.focused_selection_text( focus ) { - self.clipboard = text; - self.publish_clipboard_selection(); + self.publish_clipboard( text ); } if let Some( new_value ) = self.delete_selection( focus ) { diff --git a/src/event_loop/context_menu.rs b/src/event_loop/context_menu.rs index 068eb2d..040d615 100644 --- a/src/event_loop/context_menu.rs +++ b/src/event_loop/context_menu.rs @@ -6,6 +6,7 @@ use super::surface::SurfaceFocus; use crate::app::App; use crate::tree::find_handlers; use crate::types::{ Point, Rect }; +use crate::platform::Platform; /// Built-in context menu for text editing — Copy / Cut / Paste rows /// drawn on top of the surface content. Shown on a right-click (or @@ -66,7 +67,7 @@ impl ContextMenu } } -impl AppData +impl AppData { /// Open the built-in Copy / Cut / Paste context menu near `pos`, /// targeting the TextEdit at `widget_idx`. Clamps the menu rect diff --git a/src/event_loop/cursor_shape.rs b/src/event_loop/cursor_shape.rs index d0d32bc..d1958c5 100644 --- a/src/event_loop/cursor_shape.rs +++ b/src/event_loop/cursor_shape.rs @@ -1,13 +1,26 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge; - use super::app_data::AppData; -use super::surface::{ SurfaceFocus, SurfaceKind }; +use super::surface::SurfaceFocus; use crate::app::App; +use crate::platform::Platform; use crate::types::{ CursorShape, Point }; +/// The edge or corner of the window an interactive resize drags. +#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] +pub( crate ) enum ResizeEdge +{ + Top, + Bottom, + Left, + Right, + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + /// Classify a pointer position against a surface of size `w × h` (in /// physical pixels). Returns the resize edge if the pointer is inside /// the `border`-thick grab band along any edge or corner. @@ -46,58 +59,11 @@ pub( crate ) fn resize_edge_to_cursor( edge: ResizeEdge ) -> CursorShape ResizeEdge::TopRight => NeResize, ResizeEdge::BottomLeft => SwResize, ResizeEdge::BottomRight => SeResize, - _ => Default, } } -impl AppData +impl AppData { - /// Convert a public [`CursorShape`](crate::types::CursorShape) into - /// the wire-protocol [`Shape`] enum the compositor expects. - fn cursor_shape_to_wp( shape: CursorShape ) - -> smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape - { - use CursorShape as C; - use smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape as S; - match shape - { - C::Default => S::Default, - C::ContextMenu => S::ContextMenu, - C::Help => S::Help, - C::Pointer => S::Pointer, - C::Progress => S::Progress, - C::Wait => S::Wait, - C::Cell => S::Cell, - C::Crosshair => S::Crosshair, - C::Text => S::Text, - C::VerticalText => S::VerticalText, - C::Alias => S::Alias, - C::Copy => S::Copy, - C::Move => S::Move, - C::NoDrop => S::NoDrop, - C::NotAllowed => S::NotAllowed, - C::Grab => S::Grab, - C::Grabbing => S::Grabbing, - C::EResize => S::EResize, - C::NResize => S::NResize, - C::NeResize => S::NeResize, - C::NwResize => S::NwResize, - C::SResize => S::SResize, - C::SeResize => S::SeResize, - C::SwResize => S::SwResize, - C::WResize => S::WResize, - C::EwResize => S::EwResize, - C::NsResize => S::NsResize, - C::NeswResize => S::NeswResize, - C::NwseResize => S::NwseResize, - C::ColResize => S::ColResize, - C::RowResize => S::RowResize, - C::AllScroll => S::AllScroll, - C::ZoomIn => S::ZoomIn, - C::ZoomOut => S::ZoomOut, - } - } - /// Compute the cursor shape that should be active right now and /// push it to the compositor if it changed since the last /// dispatch. Precedence: @@ -148,12 +114,7 @@ impl AppData // unconditionally in that case so the user does not see the // stranger cursor lingering until they hit a widget. if self.current_cursor_shape == Some( target ) { return; } - - if let Some( device ) = self.cursor_shape_device.as_ref() - { - device.set_shape( self.last_pointer_enter_serial, Self::cursor_shape_to_wp( target ) ); - self.current_cursor_shape = Some( target ); - } + if self.platform.set_cursor_shape( target ) { self.current_cursor_shape = Some( target ); } } /// Width (logical pixels) of the resize-grab band along each @@ -168,7 +129,7 @@ impl AppData pub( crate ) fn resize_edge_under_pointer( &self, focus: SurfaceFocus ) -> Option { if !matches!( focus, SurfaceFocus::Main ) { return None; } - if !matches!( self.main.surface, SurfaceKind::Window( _ ) ) { return None; } + if !self.main_is_toplevel() { return None; } let sf = self.surface( focus ).scale_factor.max( 1 ) as f32; let border = Self::RESIZE_BORDER_LOGICAL * sf; let w = self.surface( focus ).physical_width() as f32; diff --git a/src/event_loop/drag.rs b/src/event_loop/drag.rs index b93d5f8..da3a879 100644 --- a/src/event_loop/drag.rs +++ b/src/event_loop/drag.rs @@ -5,8 +5,9 @@ use super::app_data::AppData; use super::surface::{ SurfaceFocus, SurfaceState }; use crate::app::App; use crate::types::Point; +use crate::platform::Platform; -impl AppData +impl AppData { /// Fire long-press messages for any surface whose deadline has /// elapsed. Idempotent — if a press is in-flight but not yet due this @@ -30,7 +31,7 @@ impl AppData // Cut / Paste menu instead. // Any combination can be present: an icon with both menu and // drag fires both messages in order (menu first, then drag). - let fire = |ss: &mut SurfaceState| + let fire = |ss: &mut SurfaceState| -> Option<( Option, Option, Point, Option )> { let start = ss.gesture.long_press_start?; @@ -100,7 +101,7 @@ impl AppData pub( crate ) fn clear_long_press_drag( &mut self ) { - let clear = |ss: &mut SurfaceState| + let clear = |ss: &mut SurfaceState| { ss.gesture.long_press_start = None; ss.gesture.long_press_origin = None; diff --git a/src/event_loop/error.rs b/src/event_loop/error.rs index 0059963..cd14f7c 100644 --- a/src/event_loop/error.rs +++ b/src/event_loop/error.rs @@ -5,9 +5,9 @@ /// to bring up the event loop. /// /// Every variant maps to a fatal failure during init: the Wayland -/// connection, the calloop event loop, or one of the protocol bindings +/// connection, the calloop event loop, one of the protocol bindings /// the runtime cannot operate without (`wl_compositor`, `wl_shm`, -/// `xdg_wm_base`). Once init succeeds, the compositor disconnecting +/// `xdg_wm_base`), or on Android the missing `AndroidApp`. Once init succeeds, the compositor disconnecting /// (`BrokenPipe` / `ConnectionReset`) is treated as a clean exit; any /// other runtime error during the dispatch loop still panics, since the /// surface is already on screen and the state machine cannot be unwound @@ -38,6 +38,9 @@ pub enum RunError name: &'static str, detail: String, }, + /// On Android, `ltk::run` was called before `android_main` handed + /// its `AndroidApp` to `ltk::set_android_app`. + NoAndroidApp, } impl std::fmt::Display for RunError @@ -54,6 +57,8 @@ impl std::fmt::Display for RunError write!( f, "event-loop setup failed: {d}" ), Self::MissingProtocol { name, detail } => write!( f, "Wayland protocol `{name}` unavailable: {detail}" ), + Self::NoAndroidApp => + write!( f, "no AndroidApp: call ltk::set_android_app from android_main before ltk::run" ), } } } diff --git a/src/event_loop/focus.rs b/src/event_loop/focus.rs index 7d7f16a..67bed31 100644 --- a/src/event_loop/focus.rs +++ b/src/event_loop/focus.rs @@ -1,8 +1,6 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use smithay_client_toolkit::reexports::client::QueueHandle; - use super::app_data::AppData; use super::surface::SurfaceFocus; use crate::app::{ App, OverlayId }; @@ -10,8 +8,9 @@ use crate::tree::find_handlers; use crate::types::{ Point, WidgetId }; use crate::widget::WidgetHandlers; use crate::widget::scroll::offset_into_view; +use crate::platform::Platform; -impl AppData +impl AppData { /// Push `on_dismiss` for every active xdg-popup overlay whose /// anchor widget was not hit by a press at `pos` (logical pixels @@ -98,7 +97,7 @@ impl AppData self.dirty_caches(); } - pub( crate ) fn set_focus( &mut self, focus: SurfaceFocus, idx: Option, qh: &QueueHandle ) + pub( crate ) fn set_focus( &mut self, focus: SurfaceFocus, idx: Option ) { let was_text_input; let is_text_input; @@ -192,7 +191,7 @@ impl AppData } else if is_text_input && !was_text_input { - self.activate_text_input( qh, secure, purpose ); + self.activate_text_input( secure, purpose ); self.show_osk( focus ); self.app.on_text_input_focused( true ); self.dirty_caches(); @@ -201,7 +200,7 @@ impl AppData { // Focus moved between text fields: refresh the content type so a // password field is flagged even without re-activating. - self.activate_text_input( qh, secure, purpose ); + self.activate_text_input( secure, purpose ); self.show_osk( focus ); } } @@ -231,7 +230,7 @@ impl AppData } /// Whether the surface's focused widget is a text input in its current frame. -pub( crate ) fn surface_focused_text_input( ss: &super::surface::SurfaceState ) -> bool +pub( crate ) fn surface_focused_text_input( ss: &super::surface::SurfaceState ) -> bool { let by_id = ss.focused_id.and_then( |id| { diff --git a/src/event_loop/frame.rs b/src/event_loop/frame.rs index 7964df7..c48dc14 100644 --- a/src/event_loop/frame.rs +++ b/src/event_loop/frame.rs @@ -1,41 +1,21 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use std::sync::Arc; -use smithay_client_toolkit::compositor::CompositorState; -use smithay_client_toolkit::reexports::client::protocol::{ wl_shm, wl_surface::WlSurface }; -use smithay_client_toolkit::shm::Shm; - use crate::app::{ App, SurfaceTarget }; -use crate::egl_context::EglContext; +use crate::platform::{ Platform, Presentation }; use crate::types::{ Color, Rect }; use crate::widget::Element; use super::AppData; use super::focus::surface_focused_text_input; use super::surface::{ SurfaceFocus, SurfaceState }; -use crate::draw::gles::{ draw_surface_full_gpu, draw_surface_partial_gpu }; -use crate::draw::software::{ draw_surface_full, draw_surface_partial }; +use crate::draw::{ paint_full, paint_partial }; use crate::draw::damage::compute_interaction_dirty_rects; -/// Pick the best wl_shm format for our RGBA-premultiplied pixmap. -/// Abgr8888 matches tiny-skia's byteorder on little-endian systems (plain -/// memcpy). Falls back to mandatory Argb8888 when not advertised. -/// Returns `(format, swap_rb)`. -pub( crate ) fn pick_shm_format( shm: &Shm ) -> ( wl_shm::Format, bool ) -{ - if shm.formats().contains( &wl_shm::Format::Abgr8888 ) - { - ( wl_shm::Format::Abgr8888, false ) - } else { - ( wl_shm::Format::Argb8888, true ) - } -} - /// Returns `true` when this call performed the very first commit on the main /// surface — the run loop uses that to fire `App::on_first_frame_committed` /// exactly once, after the borrows held by draw_frame have been released. -pub( crate ) fn draw_frame( data: &mut AppData ) -> bool +pub( crate ) fn draw_frame( data: &mut AppData ) -> bool { let main_view = data.cached_view.as_ref().expect( "view cache populated" ); let overlays = data.cached_overlays.as_ref().expect( "overlays cache populated" ); @@ -43,9 +23,7 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> bool let main_bg = data.app.background_color(); let main_region = data.app.input_region(); let debug_layout = data.debug_layout; - let ( format, swap_rb ) = pick_shm_format( &data.shm ); - let egl_ctx = data.egl_context.as_ref(); - let qh = &data.qh; + let platform = &data.platform; let mut first_commit_now = false; let mut text_focus_rebuilt: Vec = Vec::new(); @@ -55,19 +33,15 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> bool { text_focus_rebuilt.push( SurfaceFocus::Main ); } - let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, SurfaceFocus::Main ); }; - draw_surface::( + draw_surface( + platform, &mut data.main, - &data.compositor_state, - egl_ctx, main_view, main_bg, main_region.as_deref(), None, debug_layout, - format, - swap_rb, - &req_frame, + Some( SurfaceFocus::Main ), ); data.main.needs_redraw = false; data.main.last_draw = std::time::Instant::now(); @@ -92,20 +66,7 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> bool // No frame callback: these surfaces are static between invalidations, // so there is no per-frame pacing to keep — and a callback that never // gets requested again would leave `frame_pending` stuck. - let req_frame = | _: &WlSurface | {}; - draw_surface::( - ss, - &data.compositor_state, - egl_ctx, - &view, - main_bg, - None, - None, - debug_layout, - format, - swap_rb, - &req_frame, - ); + 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(); @@ -121,21 +82,17 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> bool { text_focus_rebuilt.push( focus ); } - let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, focus ); }; let bg = Color::rgba( 0.0, 0.0, 0.0, 0.0 ); let opaque = data.app.overlay_opaque_region( spec.id ); - draw_surface::( + draw_surface( + platform, ss, - &data.compositor_state, - egl_ctx, &spec.view, bg, spec.input_region.as_deref(), opaque.as_deref(), debug_layout, - format, - swap_rb, - &req_frame, + Some( focus ), ); ss.needs_redraw = false; ss.last_draw = std::time::Instant::now(); @@ -155,18 +112,16 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> bool first_commit_now } -fn draw_surface( - ss: &mut SurfaceState, - compositor: &CompositorState, - egl_ctx: Option<&Arc>, +/// Paint `ss` and present it, under the rects whose interaction state changed when they cover less than half of it, else all of it. `frame` is the surface the next frame callback is reported for, `None` for one not paced by frames. +fn draw_surface( + platform: &P, + ss: &mut SurfaceState, view: &Element, bg: Color, input_region: Option<&[Rect]>, opaque_region: Option<&[Rect]>, debug_layout: bool, - shm_format: wl_shm::Format, - swap_rb: bool, - request_frame: &dyn Fn( &WlSurface ), + frame: Option, ) { let scale = ss.scale_factor.max( 1 ) as u32; @@ -183,6 +138,7 @@ fn draw_surface( && canvas_ready && !ss.frame.widget_rects.is_empty(); + let mut partial = None; if partial_eligible { let dirty_rects = compute_interaction_dirty_rects( @@ -193,34 +149,27 @@ fn draw_surface( ); if dirty_rects.is_empty() { return; } let total: f32 = dirty_rects.iter().map( |r| r.width * r.height ).sum(); - if total < pw as f32 * ph as f32 * 0.5 - { - if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() ) - { - draw_surface_partial_gpu( - ss, compositor, ctx, view, bg, input_region, opaque_region, - dirty_rects, pw, ph, scale, request_frame, - ); - } else { - draw_surface_partial( - ss, compositor, view, bg, input_region, opaque_region, - shm_format, swap_rb, dirty_rects, pw, ph, scale, request_frame, - ); - } - return; - } + if total < pw as f32 * ph as f32 * 0.5 { partial = Some( dirty_rects ); } } - if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() ) + if !platform.prepare_canvas( &mut ss.platform, &mut ss.canvas, pw, ph, scale ) { return; } + let damage = match partial { - draw_surface_full_gpu( - ss, compositor, ctx, view, bg, input_region, opaque_region, debug_layout, - pw, ph, scale, request_frame, - ); + Some( dirty_rects ) => paint_partial( ss, view, bg, dirty_rects, pw, ph, scale ), + None => + { + let precise = platform.precise_damage( &ss.platform ); + paint_full( ss, view, bg, debug_layout, precise, pw, ph, scale ) + } + }; + + let presentation = Presentation { damage: &damage, bg, opaque_region, input_region, width: w, height: h, scale, frame }; + let Some( canvas ) = ss.canvas.as_mut() else { return }; + if platform.present( &mut ss.platform, canvas, &presentation ) + { + ss.frame_pending = true; } else { - draw_surface_full( - ss, compositor, view, bg, input_region, opaque_region, debug_layout, - shm_format, swap_rb, pw, ph, scale, request_frame, - ); + // What was painted never reached the screen, so the next frame cannot build on it. + ss.content_dirty = true; } } diff --git a/src/event_loop/invalidation.rs b/src/event_loop/invalidation.rs index 7320fef..faf414b 100644 --- a/src/event_loop/invalidation.rs +++ b/src/event_loop/invalidation.rs @@ -5,6 +5,7 @@ use std::collections::HashSet; use crate::app::{ App, InvalidationScope, SurfaceTarget }; +use crate::platform::Platform; use super::AppData; /// Apply a folded [`InvalidationScope`] from one message-batch iteration: @@ -14,7 +15,7 @@ use super::AppData; /// /// `InvalidationScope::All` (the default returned by `App::invalidate_after`) /// matches the pre-hook behaviour of broadcasting to every surface. -pub( super ) fn apply_invalidation( data: &mut AppData, scope: InvalidationScope ) +pub( super ) fn apply_invalidation( data: &mut AppData, scope: InvalidationScope ) { match scope { diff --git a/src/event_loop/mod.rs b/src/event_loop/mod.rs index 9bac02a..7a59bfb 100644 --- a/src/event_loop/mod.rs +++ b/src/event_loop/mod.rs @@ -1,36 +1,32 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. +pub( crate ) mod a11y; pub( crate ) mod app_data; pub( crate ) mod clipboard; -pub( crate ) mod data_device; pub( crate ) mod context_menu; pub( crate ) mod cursor_shape; pub( crate ) mod drag; pub( crate ) mod focus; -mod handlers; pub( crate ) mod osk; -pub( crate ) mod output_probe; pub( crate ) mod perf; -pub( crate ) mod primary_selection; pub( crate ) mod repeat; -pub( crate ) mod session; -pub( crate ) mod subsurface; +pub( crate ) mod setup; pub( crate ) mod surface; pub( crate ) mod text_editing; pub( crate ) mod text_scale; pub( crate ) mod tooltip; -pub( crate ) mod window_fit; pub( crate ) mod error; pub( crate ) mod frame; -pub( crate ) mod run; +pub( crate ) mod step; pub( crate ) mod invalidation; -pub( crate ) mod overlays_reconcile; + +#[ cfg( test ) ] +mod tests; pub( crate ) use app_data::AppData; -pub( crate ) use surface::{ FrameState, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState }; +pub( crate ) use surface::{ FrameState, SurfaceFocus, SurfaceState }; pub use error::RunError; -pub( crate ) use run::{ run, try_run }; #[ cfg( feature = "test-support" ) ] pub use invalidation::diff_overlay_ids; diff --git a/src/event_loop/osk.rs b/src/event_loop/osk.rs index e24540a..c41d5cc 100644 --- a/src/event_loop/osk.rs +++ b/src/event_loop/osk.rs @@ -5,40 +5,50 @@ use std::time::{ Duration, Instant }; -use smithay_client_toolkit::seat::keyboard::KeyEvent; - use super::app_data::AppData; -use super::surface::SurfaceFocus; +use super::surface::{ Insets, SurfaceFocus }; use crate::app::App; +use crate::input::event::KeyInput; use crate::osk::{ Event, Modifiers, Panel }; +use crate::platform::Platform; use crate::Keysym; use crate::tree::find_handlers; use crate::types::{ Point, Rect }; -/// Fit a surface's panel to the bottom of its `pw` by `ph` pixels; returns the height it takes. -pub( crate ) fn place_osk( osk: &mut Option, pw: u32, ph: u32, sf: f32 ) -> f32 +/// Fit a surface's panel to the bottom of its `pw` by `ph` pixels, inside the `insets` the system covers; returns the height it takes. +pub( crate ) fn place_osk( osk: &mut Option, pw: u32, ph: u32, sf: f32, insets: Insets ) -> f32 { let Some( panel ) = osk.as_mut() else { return 0.0 }; - let h = panel.height_within( pw as f32, sf, ph as f32 ); - panel.set_rect( Rect { x: 0.0, y: ph as f32 - h, width: pw as f32, height: h }, sf ); + let width = ( pw as f32 - ( insets.left + insets.right ) * sf ).max( 0.0 ); + let bottom = ph as f32 - insets.bottom * sf; + let h = panel.height_within( width, sf, ph as f32 ); + panel.set_rect( Rect { x: insets.left * sf, y: bottom - h, width, height: h }, sf ); h } -impl AppData +impl AppData { fn builtin_keyboard( &self ) -> bool { self.builtin_osk || self.app.builtin_keyboard() } + /// The panel for the focused text field, laid out for its purpose. pub( crate ) fn show_osk( &mut self, focus: SurfaceFocus ) { - if !self.builtin_keyboard() { return; } - let ss = self.surface_mut( focus ); + let ss = self.surface( focus ); let purpose = ss.focused_idx .and_then( |i| find_handlers( &ss.frame.widget_rects, i ) ) .map( |h| h.input_purpose() ) .unwrap_or_default(); + self.show_osk_for( focus, purpose ); + } + + /// The panel on `focus` for text of `purpose`, also for an app's own text input ([`App::external_text_input`]). + pub( crate ) fn show_osk_for( &mut self, focus: SurfaceFocus, purpose: crate::osk::Purpose ) + { + if !self.builtin_keyboard() { return; } + let ss = self.surface_mut( focus ); if ss.osk.is_none() { let lang = std::env::var( "LANG" ).unwrap_or_default(); @@ -47,7 +57,7 @@ impl AppData 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 ); - place_osk( &mut ss.osk, pw, ph, sf ); + place_osk( &mut ss.osk, pw, ph, sf, ss.insets ); ss.request_redraw(); } @@ -124,6 +134,11 @@ impl AppData fn apply_osk_event( &mut self, focus: SurfaceFocus, event: Event ) { + if self.external_text_input.is_some() && !self.focused_text_input( focus ) + { + self.apply_osk_event_to_app( focus, event ); + return; + } match event { Event::Text( text ) => self.handle_text_insert( focus, &text ), @@ -136,11 +151,42 @@ impl AppData self.surface_mut( focus ).request_redraw(); } + /// For an app's own text input every key reaches the app as a physical one would, through [`App::on_raw_key_event`]. + fn apply_osk_event_to_app( &mut self, focus: SurfaceFocus, event: Event ) + { + match event + { + Event::Text( text ) => + { + let keysym = text.chars().next().map_or( Keysym::NoSymbol, Keysym::from_char ); + self.osk_app_key( keysym, Some( text ), Modifiers::default() ); + } + Event::Backspace => self.osk_app_key( Keysym::BackSpace, None, Modifiers::default() ), + Event::Enter => self.osk_app_key( Keysym::Return, None, Modifiers::default() ), + Event::Key( keysym, mods ) => self.osk_app_key( keysym, None, mods ), + Event::Hide => self.hide_osk( focus ), + Event::Changed => {} + } + self.surface_mut( focus ).request_redraw(); + } + + /// Press and release `keysym` with the panel's modifiers held for it. Without modifiers a key that has a character types it, as on a physical keyboard. + fn osk_app_key( &mut self, keysym: Keysym, text: Option, mods: Modifiers ) + { + let text = text.or_else( || ( !mods.any() ).then( || keysym.key_char() ).flatten().map( String::from ) ); + let held = ( self.ctrl_pressed, self.shift_pressed, self.alt_pressed ); + self.modifiers_changed( mods.ctrl, held.1, mods.alt ); + let key = KeyInput::synthetic( keysym, text ); + self.key_press( key.clone() ); + self.key_release( &key ); + self.modifiers_changed( held.0, held.1, held.2 ); + } + /// A key the panel cannot type as text goes through the same dispatch as a physical one. fn osk_key( &mut self, focus: SurfaceFocus, keysym: Keysym, mods: Modifiers ) { let utf8 = ( !mods.any() ).then( || keysym.key_char() ).flatten().map( String::from ); - let event = KeyEvent { time: 0, raw_code: 0, keysym, utf8 }; + let event = KeyInput::synthetic( keysym, utf8 ); let ctrl = std::mem::replace( &mut self.ctrl_pressed, mods.ctrl ); self.dispatch_key( focus, event ); self.ctrl_pressed = ctrl; diff --git a/src/event_loop/repeat.rs b/src/event_loop/repeat.rs index d69e60c..4afc4ff 100644 --- a/src/event_loop/repeat.rs +++ b/src/event_loop/repeat.rs @@ -2,20 +2,21 @@ // Copyright (C) 2026 Liberux Labs, S. L. use calloop::RegistrationToken; -use smithay_client_toolkit::seat::keyboard::KeyEvent; + +use crate::input::event::KeyInput; /// Snapshot of the key whose repeat timer is currently armed. /// /// Stored on [`super::app_data::AppData::key_repeat`] between press and /// release of a held-down key. The timer's calloop callback reads this -/// back to re-dispatch the same `KeyEvent` against the same focus, so a +/// back to re-dispatch the same `KeyInput` against the same focus, so a /// held arrow key keeps moving the cursor in the originally-focused text /// input even if the focus migrates mid-repeat (which it should not — /// release cancels the timer, and focus changes only happen via Tab / /// pointer events that arrive after `release_key` is processed). pub( crate ) struct KeyRepeatState { - pub event: KeyEvent, + pub event: KeyInput, pub token: RegistrationToken, } diff --git a/src/event_loop/run.rs b/src/event_loop/run.rs deleted file mode 100644 index 7b54e69..0000000 --- a/src/event_loop/run.rs +++ /dev/null @@ -1,1017 +0,0 @@ -// SPDX-License-Identifier: LGPL-2.1-only -// Copyright (C) 2026 Liberux Labs, S. L. - -use smithay_client_toolkit:: -{ - compositor::CompositorState, - output::OutputState, - registry::RegistryState, - seat::SeatState, - shell:: - { - WaylandSurface, - wlr_layer::LayerShell, - xdg:: - { - XdgShell, - window::WindowDecorations, - }, - }, - shm::Shm, - subcompositor::SubcompositorState, -}; -use smithay_client_toolkit::reexports::client::Connection; -use smithay_client_toolkit::reexports::calloop::EventLoop; -use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource; -use calloop::timer::{ Timer, TimeoutAction }; -use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3; - -use crate::app::{ App, InvalidationScope }; -use crate::types::Point; - -use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState }; -use super::error::RunError; -use super::frame::draw_frame; -use super::invalidation::apply_invalidation; -use super::overlays_reconcile::reconcile_overlays; - -/// Drawn frames a focus request waits for its target to appear: one for -/// the rebuilt view, one per scroll viewport moved to reveal the widget. -const FOCUS_RETRY_FRAMES: u8 = 4; - -/// Run the application, panicking on init failure. Thin wrapper over -/// [`try_run`] kept for backwards-compatibility — embedders that need -/// to recover from a missing compositor or a stripped-down driver -/// should call [`try_run`] instead. -pub( crate ) fn run( app: A ) -{ - if let Err( e ) = try_run( app ) - { - panic!( "ltk::run failed during init: {e}" ); - } -} - -/// Run the application, returning a typed error on init failure. -/// The dispatch loop's runtime errors still panic — they are non- -/// recoverable once the surface is on screen, and the surface state -/// machine cannot be unwound cleanly from this entry point. -pub( crate ) fn try_run( mut app: A ) -> Result<(), RunError> -{ - let conn = Connection::connect_to_env() - .map_err( |e| RunError::NoWaylandConnection( format!( "{e}" ) ) )?; - let ( globals, queue ) = - smithay_client_toolkit::reexports::client::globals::registry_queue_init( &conn ) - .map_err( |e| RunError::RegistryInit( format!( "{e:?}" ) ) )?; - let qh = queue.handle(); - - let mut event_loop: EventLoop> = EventLoop::try_new() - .map_err( |e| RunError::EventLoop( format!( "EventLoop::try_new: {e}" ) ) )?; - WaylandSource::new( conn.clone(), queue ) - .insert( event_loop.handle() ) - .map_err( |e| RunError::EventLoop( format!( "WaylandSource::insert: {e:?}" ) ) )?; - - super::session::install_signal_source( &event_loop.handle() )?; - - let compositor = CompositorState::bind( &globals, &qh ) - .map_err( |e| RunError::MissingProtocol { name: "wl_compositor", detail: format!( "{e:?}" ) } )?; - let shm = Shm::bind( &globals, &qh ) - .map_err( |e| RunError::MissingProtocol { name: "wl_shm", detail: format!( "{e:?}" ) } )?; - - // Optional: compositors lacking wl_subcompositor leave this None and - // App::subsurfaces() silently degrades to no subsurfaces. - let subcompositor = SubcompositorState::bind( compositor.wl_compositor().clone(), &globals, &qh ).ok(); - - // Try to bring EGL up. On failure (no libEGL, no compatible config, - // LTK_FORCE_SOFTWARE=1, ES2/ES3 context creation refused…) we log the - // reason and every surface falls back to the SHM path. - let egl_context = match crate::egl_context::EglContext::new( &conn ) - { - Ok( ctx ) => Some( std::sync::Arc::new( ctx ) ), - Err( reason ) => - { - crate::egl_context::log_software_fallback( &reason ); - None - } - }; - crate::render::set_software_render( egl_context.is_none() ); - - // Bind layer-shell up front. Both the main surface (when requested via - // ShellMode::Layer) and every overlay returned by App::overlays() share - // this single binding. - let layer_shell_opt: Option = LayerShell::bind( &globals, &qh ).ok(); - - // Backwards compatibility: window_config() overrides shell_mode() - let force_window = app.window_config() - .map( |( t, id )| ( t.to_string(), id.to_string() ) ); - - let session_enabled = force_window.is_some() || matches!( app.shell_mode(), crate::app::ShellMode::Window ); - let mut session = if session_enabled - { - super::session::SessionRuntime::bootstrap( &mut app ) - } else { - super::session::SessionRuntime::disabled() - }; - if session_enabled - { - session.bind( &globals, &qh ); - } - let app_id = app.app_id().to_string(); - if let Some( ( _, ref cfg_id ) ) = force_window - { - if cfg_id != &app_id - { - eprintln!( "ltk: window_config app_id {cfg_id:?} differs from App::app_id {app_id:?}; App::app_id wins" ); - } - } - - let bind_xdg = |globals: &smithay_client_toolkit::reexports::client::globals::GlobalList, qh: &smithay_client_toolkit::reexports::client::QueueHandle>| - -> Result - { - XdgShell::bind( globals, qh ) - .map_err( |e| RunError::MissingProtocol { name: "xdg_wm_base", detail: format!( "{e:?}" ) } ) - }; - - // Skipped when going fullscreen: Mutter rejects the fullscreen - // request if a min_size constrains it. - // Fitted before the first commit, a hint too big for the screen never reaches a compositor that would rescale the window to honour it. - let prefitted_size_hint = app.window_size_hint() - .filter( |_| !app.start_fullscreen() ) - .and_then( |hint| super::output_probe::output_logical_size( &conn, &globals ).map( |output| super::window_fit::fit_within( hint, super::window_fit::output_share( output ) ) ) ); - let apply_size_hint = |window: &smithay_client_toolkit::shell::xdg::window::Window| - { - if app.start_fullscreen() { return; } - match prefitted_size_hint.or( app.window_size_hint() ) - { - Some( ( w, h ) ) => - { - window.set_min_size( Some( ( w, h ) ) ); - window.set_max_size( Some( ( w, h ) ) ); - } - None => - { - window.set_min_size( Some( ( 360, 480 ) ) ); - } - } - }; - - let apply_fullscreen = |window: &smithay_client_toolkit::shell::xdg::window::Window| - { - if app.start_fullscreen() - { - window.set_fullscreen( None ); - } - }; - - // `restore_toplevel` must precede the first commit, so the session - // attach sits right before `commit()`. - let make_window = |xdg: &XdgShell, title: &str, session: &mut super::session::SessionRuntime, attach: bool| - { - let surface = compositor.create_surface( &qh ); - let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh ); - window.set_title( title ); - window.set_app_id( app_id.as_str() ); - apply_size_hint( &window ); - apply_fullscreen( &window ); - if attach - { - session.attach_toplevel( &window, &qh ); - } - window.commit(); - window - }; - - let ( surface_kind, xdg_shell ) = if let Some( ( ref title, _ ) ) = force_window - { - let xdg = bind_xdg( &globals, &qh )?; - let window = make_window( &xdg, title.as_str(), &mut session, true ); - ( SurfaceKind::Window( window ), Some( xdg ) ) - } else { - // Use shell_mode() to determine surface type - use crate::app::ShellMode; - match app.shell_mode() - { - ShellMode::SessionLock => { - // Lock surface is created in SessionLockHandler::locked once the - // compositor grants the lock; until then a placeholder. - ( SurfaceKind::PendingLock, None ) - } - ShellMode::Window => { - let xdg = bind_xdg( &globals, &qh )?; - let window = make_window( &xdg, "ltk", &mut session, true ); - ( SurfaceKind::Window( window ), Some( xdg ) ) - } - ShellMode::Layer( layer ) => { - if layer_shell_opt.is_some() - { - // Defer surface creation until new_output fires: if we create the layer - // surface before the compositor has any output ready (e.g. sway startup), - // the compositor cannot assign it and logs an error. - let cfg = LayerConfig { - layer: layer.to_wlr_layer(), - exclusive_zone: app.exclusive_zone(), - anchor: app.layer_anchor(), - size: app.layer_size(), - keyboard_exclusive: app.keyboard_exclusive(), - namespace: "ltk-sctk", - }; - ( SurfaceKind::Pending( cfg ), None ) - } else { - eprintln!( "ltk: wlr-layer-shell not available, falling back to xdg window" ); - let xdg = bind_xdg( &globals, &qh )?; - let window = make_window( &xdg, "ltk", &mut session, false ); - ( SurfaceKind::Window( window ), Some( xdg ) ) - } - } - } - }; - // Layer-shell apps still get xdg_wm_base when the compositor has it, so - // a `Layer::Window` overlay can be created later. - let xdg_shell = xdg_shell.or_else( || XdgShell::bind( &globals, &qh ).ok() ); - - // Bind the session-lock manager and request the lock. We don't keep the - // `SessionLockState` (the manager) around: it has no `Drop`, so the - // manager object persists in the connection, and the lock lifecycle runs - // entirely off the returned `SessionLock` + its surfaces. - let session_lock = - if force_window.is_none() && matches!( app.shell_mode(), crate::app::ShellMode::SessionLock ) - { - smithay_client_toolkit::session_lock::SessionLockState::new( &globals, &qh ) - .lock( &qh ) - .ok() - } else { - None - }; - - let text_input_manager: Option = globals.bind( &qh, 1..=1, () ).ok(); - let builtin_osk = text_input_manager.is_none() || std::env::var_os( "LTK_BUILTIN_OSK" ).is_some_and( |v| v == "1" ); - // wl_data_device_manager: optional. Required for cross-application - // copy/paste; absence means the clipboard stays process-local. - let data_device_manager = - smithay_client_toolkit::data_device_manager::DataDeviceManagerState::bind( &globals, &qh ).ok(); - let ( clipboard_inbox_tx, clipboard_inbox_rx ) = super::data_device::clipboard_inbox(); - let primary = super::primary_selection::PrimarySelection::new( - smithay_client_toolkit::primary_selection::PrimarySelectionManagerState::bind( &globals, &qh ).ok() ); - let ( drop_inbox_tx, drop_inbox_rx ) = super::data_device::drop_inbox(); - // AT-SPI2 / accessibility. `try_new` returns `None` when the - // platform adapter cannot be created (no daemon on the bus, - // headless CI, etc.) — the runtime then runs with no - // accessibility tree, which is the previous behaviour. - let a11y = crate::a11y::A11yState::try_new( &app_id, &app_id ); - // xdg-activation-v1: optional. Compositors that don't carry the - // global leave `activation_state` as `None` and the inbound / - // outbound activation paths silently degrade to no-ops. - let activation_state = - smithay_client_toolkit::activation::ActivationState::bind( &globals, &qh ).ok(); - // `$XDG_ACTIVATION_TOKEN` is the cross-desktop convention for an - // incoming activation token (the launcher that spawned us set it - // in the new process's environment). Honoured exactly once, after - // the main surface receives its first configure — that is the - // earliest moment we can call `xdg_activation_v1.activate`. - let activation_token_pending = std::env::var( "XDG_ACTIVATION_TOKEN" ).ok().filter( |t| !t.is_empty() ); - // Avoid the token leaking into any child process the app spawns — - // it is single-use and would be invalid the second time anyway. - if activation_token_pending.is_some() - { - // SAFETY: removing an env var is sound only when no other thread - // is reading the environment concurrently. We are still in the - // init phase before `set_channel_sender`, so the app has had - // no opportunity to spawn worker threads yet. - unsafe { std::env::remove_var( "XDG_ACTIVATION_TOKEN" ); } - } - - // `ext-foreign-toplevel-list-v1`. SCTK's `ForeignToplevelList` handles the - // bind + dispatch routing; absence of the global is fine, the inner - // `GlobalProxy` then yields no toplevels and `App::on_toplevel_event` never - // fires. The list is built unconditionally so apps that override the - // callback always see a consistent stream when the compositor does carry - // the protocol. - let foreign_toplevel_list = smithay_client_toolkit::foreign_toplevel_list::ForeignToplevelList::new( &globals, &qh ); - - let debug_layout = std::env::var( "LTK_DEBUG_LAYOUT" ).is_ok(); - - let titlebar_height = if force_window.is_some() { 36.0 } else { 0.0 }; - let titlebar_title = force_window.map( |( t, _ )| t ).unwrap_or_default(); - - let pending_fullscreen = app.start_fullscreen(); - let pending_size_hint_unpin = !app.start_fullscreen() && app.window_size_hint().is_some() && app.window_resizable(); - let pending_size_hint_fit = !app.start_fullscreen() && app.window_size_hint().is_some() && prefitted_size_hint.is_none(); - - let mut data = AppData - { - app, - registry_state: RegistryState::new( &globals ), - seat_state: SeatState::new( &globals, &qh ), - output_state: OutputState::new( &globals, &qh ), - compositor_state: compositor, - subcompositor, - shm, - session_lock, - egl_context, - xdg_shell, - layer_shell: layer_shell_opt, - keyboard: None, - pointer: None, - touch: None, - pointer_pos: Point::default(), - cursor_shape_manager: - smithay_client_toolkit::seat::pointer::cursor_shape::CursorShapeManager::bind( &globals, &qh ).ok(), - cursor_shape_device: None, - last_pointer_enter_serial: 0, - current_cursor_shape: None, - text_input_manager, - text_input: None, - builtin_osk, - text_input_secure: false, - text_input_purpose: crate::osk::Purpose::Normal, - external_text_input: None, - activation_state, - activation_token_pending, - session, - data_device_manager, - data_device: None, - clipboard_source: None, - clipboard_inbox_tx, - clipboard_inbox_rx, - primary, - drop_position: None, - drop_mime: None, - drop_inbox_tx, - drop_inbox_rx, - a11y, - foreign_toplevel_list, - shift_pressed: false, - ctrl_pressed: false, - alt_pressed: false, - loop_handle: event_loop.handle(), - compositor_repeat_rate: 0, - compositor_repeat_delay: 0, - key_repeat: None, - button_repeat: None, - clipboard: String::new(), - last_press_time: None, - last_press_pos: None, - debug_layout, - pending_msgs: Vec::new(), - pending_drag_inits: Vec::new(), - tooltip_pending: None, - tooltip_visible: None, - qh: qh.clone(), - last_pointer_serial: 0, - last_input_serial: 0, - exit_requested: false, - pending_fullscreen, - applied_fullscreen: Some( pending_fullscreen ), - pending_size_hint_unpin, - pending_size_hint_fit, - fitted_size_hint: prefitted_size_hint, - main: SurfaceState::::new( surface_kind, titlebar_height, titlebar_title ), - overlays: std::collections::HashMap::new(), - secondaries: Vec::new(), - main_output: None, - subsurfaces: std::collections::HashMap::new(), - subsurface_gles_canvas: None, - canvas_pool: Vec::new(), - pointer_focus: SurfaceFocus::Main, - keyboard_focus: SurfaceFocus::Main, - press_on_focused: false, - touch_focus: std::collections::HashMap::new(), - cached_view: None, - cached_overlays: None, - view_dirty: true, - overlays_dirty: true, - first_frame_committed: false, - focus_retry: None, - perf: super::perf::PerfState::new(), - }; - - // Register a calloop channel so the app can send messages from any thread. - // Messages sent through the Sender wake the event loop immediately. - { - let ( sender, channel ) = calloop::channel::channel::(); - event_loop.handle() - .insert_source( - channel, - |event, _, data: &mut AppData| - { - if let calloop::channel::Event::Msg( msg ) = event - { - // Just queue the message — the run loop will run - // `App::invalidate_after` and apply the resulting - // scope, which is what decides which surfaces (if - // any) actually need to redraw. - data.pending_msgs.push( msg ); - } - }, - ) - .map_err( |e| RunError::EventLoop( format!( "channel insert_source: {e:?}" ) ) )?; - data.app.set_channel_sender( sender ); - } - - // Follow the desktop's accessibility text scale: a watcher thread reads - // `org.gnome.desktop.interface text-scaling-factor` and streams changes - // here; applying the factor and repainting is all it takes because font - // resolution multiplies by `text_scale()` at paint time. - { - let ( tx, channel ) = calloop::channel::channel::(); - event_loop.handle() - .insert_source( - channel, - |event, _, data: &mut AppData| - { - if let calloop::channel::Event::Msg( factor ) = event - { - 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 ); - } - - // Register a periodic timer if the app wants one (e.g. clock tick every second). - // The timer fires independently of Wayland events, waking the event loop on schedule. - if let Some( dur ) = data.app.poll_interval() - { - data.perf.warn_poll_interval( dur ); - event_loop.handle() - .insert_source( - Timer::from_duration( dur ), - |_, _, data: &mut AppData| - { - let msgs = data.app.poll_external(); - data.pending_msgs.extend( msgs ); - let next = data.app.poll_interval() - .unwrap_or( std::time::Duration::from_secs( 1 ) ); - TimeoutAction::ToDuration( next ) - }, - ) - .map_err( |e| RunError::EventLoop( format!( "poll timer insert_source: {e:?}" ) ) )?; - } - - if data.session.store.is_some() - { - super::session::install_save_timer( &event_loop.handle() )?; - } - - while !data.exit_requested - { - // Sleep until something interesting fires: - // * Wayland event (input, configure, frame callback, …) - // * calloop timer (poll_external) - // * calloop channel (App::set_channel_sender) - // Pacing is driven by `wl_surface.frame` callbacks, so an idle / - // off-screen / VRR display blocks indefinitely instead of polling - // at a fixed rate. The one exception is a pending long-press: - // cap the wait at its deadline so a perfectly still press still - // fires on time. - let timeout = [ data.next_long_press_wakeup(), data.next_tooltip_wakeup(), data.next_osk_wakeup() ] - .into_iter() - .flatten() - .min(); - match event_loop.dispatch( timeout, &mut data ) - { - Ok( () ) => {}, - Err( calloop::Error::IoError( ref e ) ) - if matches!( e.kind(), std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset ) => - { - // On a fatal `wl_display.error` the compositor closes the - // socket; the backend has the typed error stashed but the - // dispatch result only carries the resulting `BrokenPipe`. - if let Some( pe ) = conn.protocol_error() - { - eprintln!( - "ltk: wayland protocol error — interface={} object_id={} code={}: {}", - pe.object_interface, pe.object_id, pe.code, pe.message - ); - } - else - { - eprintln!( "ltk: wayland connection lost ({e}); exiting" ); - } - data.exit_requested = true; - continue; - } - Err( calloop::Error::OtherError( ref e ) ) => - { - // wayland-client surfaces the closed-socket condition as an - // OtherError wrapping a WaylandError::Io(BrokenPipe) — one - // level deeper than a direct io::Error. Walk the source() - // chain so we catch it regardless of how many wrapper types - // sit between calloop and the raw io::Error. - let mut src: Option<&dyn std::error::Error> = Some( e.as_ref() ); - let mut is_closed = false; - while let Some( err ) = src - { - if let Some( io ) = err.downcast_ref::() - { - if matches!( io.kind(), - std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset ) - { - is_closed = true; - break; - } - } - src = err.source(); - } - if is_closed - { - eprintln!( "ltk: wayland connection lost; exiting" ); - data.exit_requested = true; - continue; - } - panic!( "dispatch: {e:?}" ); - } - Err( e ) => panic!( "dispatch: {e:?}" ), - } - // Any surface whose press has now crossed `long_press_duration` - // emits its stored message and flips into drag mode for the rest - // of the gesture. - data.check_long_press_deadlines(); - data.check_tooltip_deadline(); - data.check_osk_deadlines(); - - // Poll external messages (immediate async results, e.g. PAM auth channel) - let ext: Vec<_> = data.app.poll_external(); - data.pending_msgs.extend( ext ); - - // Issue any xdg-activation tokens the app asked for. The tag rides - // in `RequestData::app_id` and comes back through - // `ActivationHandler::new_token` (handlers.rs) as the token. When the - // compositor never advertised the global, answer with an empty token - // so the caller still proceeds (falling back to its own matching). - for tag in data.app.take_activation_requests() - { - match data.activation_state - { - Some( ref activation ) => - { - let req = smithay_client_toolkit::activation::RequestData { - app_id: Some( tag ), - seat_and_serial: None, - surface: data.main.surface.try_wl_surface().cloned(), - }; - activation.request_token::>( &data.qh, req ); - } - None => - { - if let Some( msg ) = data.app.on_activation_token( tag, String::new() ) - { - data.pending_msgs.push( msg ); - } - } - } - } - - // Drain any inbound clipboard payloads delivered by the - // data-device worker thread. Latest writer wins; the channel - // is unbounded but selection payloads are small (capped at - // 16 MiB inside the worker, see `data_device::DataDeviceHandler::selection`). - while let Ok( text ) = data.clipboard_inbox_rx.try_recv() - { - data.clipboard = text; - } - data.primary.drain(); - while let Ok( p ) = data.drop_inbox_rx.try_recv() - { - data.app.on_drop_received( p.x as f32, p.y as f32, &p.mime, &p.text ); - } - - // Process pending messages, folding their per-message invalidation - // scopes into a single decision before applying it. - let msgs: Vec<_> = data.pending_msgs.drain( .. ).collect(); - let had_msgs = !msgs.is_empty(); - if had_msgs - { - let mut scope = InvalidationScope::Only( Vec::new() ); - for msg in msgs - { - scope = scope.union( data.app.invalidate_after( &msg ) ); - data.app.update( msg ); - } - apply_invalidation( &mut data, scope ); - // `update()` may have flipped the busy / loading flag - // the app reads from inside `cursor_override`. Re-sync - // the pointer cursor so the change propagates without - // waiting for the next motion event. - let pf = data.pointer_focus; - data.dispatch_cursor_shape( pf ); - } - - // App asked to exit (e.g. the lockscreen authenticated). For a - // session-lock surface, unlock first — exiting without unlocking - // leaves the compositor's lock in place by design. - if data.app.requested_exit() - { - if let Some( lock ) = data.session_lock.take() - { - lock.unlock(); - let _ = conn.roundtrip(); - } - data.exit_requested = true; - } - - data.sync_fullscreen(); - data.sync_external_text_input(); - - // Seed `on_drag_move` with the long-press origin for any drag that - // just started. Must run *after* `update()` so the app's drag state - // has already been set up by the paired long-press message — the - // coords would otherwise hit a dragging_app=None shell and be lost. - let drag_inits: Vec<_> = data.pending_drag_inits.drain( .. ).collect(); - if !drag_inits.is_empty() - { - for origin in drag_inits - { - data.app.on_drag_move( origin.x, origin.y ); - } - data.view_dirty = true; - data.overlays_dirty = true; - data.main.request_redraw(); - } - // After update() the app state is the source of truth — discard any - // pending text values so that the next keystroke reads the fresh state - // instead of a stale pre-update buffer (e.g. password cleared on auth failure). - if !data.main.pending_text_values.is_empty() - { - data.main.pending_text_values.clear(); - } - for ss in data.overlays.values_mut() - { - ss.pending_text_values.clear(); - } - - // Reconcile the overlay set against the app's current specs: drop any - // overlays whose id disappeared, create new ones for ids that just - // appeared. Specs are re-queried next frame for drawing. - reconcile_overlays( &mut data ); - - // Subsurfaces must map with the main surface's first buffer, not a frame later. - if !data.first_frame_committed - { - super::subsurface::reconcile_subsurfaces( &mut data ); - } - - // Draw any surface that's configured, dirty, and not already waiting - // on a frame callback. The compositor decides our cadence — when no - // surface qualifies we just loop back to `dispatch(None)` and sleep. - let any_drawable = ( data.main.configured && data.main.needs_redraw && !data.main.frame_pending ) - || data.overlays.values().any( |ss| ss.configured && ss.needs_redraw && !ss.frame_pending ) - || data.secondaries.iter().any( |( _, ss )| ss.configured && ss.needs_redraw && !ss.frame_pending ); - if any_drawable - { - // Rebuild while motion is in progress and on the first frame - // after it ends, so the settle frame paints at full quality - // instead of freezing one step short of the target. - // `wants_low_quality_paint` is the source of truth so - // finger-tracked drags get the same treatment. Slider / - // scroll drags are owned by the runtime gesture machine, - // not by `App::update`, so OR with the gesture state to - // pick up that motion signal too. - let runtime_slider_motion = - data.main.gesture.dragging_slider.is_some() - || data.overlays.values().any( |ss| ss.gesture.dragging_slider.is_some() ); - if runtime_slider_motion - { - data.view_dirty = true; - data.overlays_dirty = true; - } - if data.view_dirty - { - data.cached_view = Some( data.app.view() ); - data.view_dirty = false; - } - if data.overlays_dirty - { - let mut specs = data.app.overlays(); - if let Some( ts ) = data.tooltip_overlay() { specs.push( ts ); } - data.cached_overlays = Some( specs ); - data.overlays_dirty = false; - } - let first_commit_now = draw_frame( &mut data ); - if first_commit_now - { - data.app.on_first_frame_committed(); - } - - // Push the freshly-laid-out widget tree to AccessKit. The - // closure only runs when an AT client is actually - // listening (the adapter no-ops `update_if_active` - // otherwise), so the build cost is paid on demand. Main - // surface only — overlays would require per-surface - // adapters, which the AT-SPI2 model is not built for. - let mut a11y_taken = data.a11y.take(); - if let Some( ref mut a ) = a11y_taken - { - let main_w = data.main.width as f32; - let main_h = data.main.height as f32; - let mut overlay_meta: Vec<( u8, crate::app::OverlayId, f32, f32 )> = Vec::new(); - let mut next_id: u8 = 1; - for ( id, ss ) in data.overlays.iter() - { - if next_id == 0 { break; } - let ( ox, oy ) = data.surface_offset_for( super::SurfaceFocus::Overlay( *id ) ); - let ( w, h ) = ( ss.width as f32, ss.height as f32 ); - if w <= 0.0 || h <= 0.0 || ( ox + w <= 0.0 ) || ( oy + h <= 0.0 ) || ox >= main_w || oy >= main_h { continue; } - overlay_meta.push( ( next_id, *id, ox, oy ) ); - next_id = next_id.saturating_add( 1 ); - } - let kb_focus_id = match data.keyboard_focus - { - super::SurfaceFocus::Main => 0u8, - super::SurfaceFocus::Overlay( id ) => - overlay_meta.iter().find( |( _, oid, _, _ )| *oid == id ).map( |( i, _, _, _ )| *i ).unwrap_or( 0 ), - }; - let mut surfaces: Vec> = Vec::new(); - surfaces.push( crate::a11y::tree::SurfaceView - { - focus_id: 0, - is_main: true, - label: None, - widget_rects: &data.main.frame.widget_rects, - extras: &data.main.frame.accessible_extras, - focused_idx: data.main.focused_idx, - pressed_idx: data.main.gesture.pressed_idx, - pending_text_values: &data.main.pending_text_values, - cursor_state: &data.main.frame.cursor_state, - width: main_w, - height: main_h, - offset_x: 0.0, - offset_y: 0.0, - } ); - for ( fid, id, ox, oy ) in &overlay_meta - { - let Some( ss ) = data.overlays.get( id ) else { continue }; - surfaces.push( crate::a11y::tree::SurfaceView - { - focus_id: *fid, - is_main: false, - label: None, - widget_rects: &ss.frame.widget_rects, - extras: &ss.frame.accessible_extras, - focused_idx: ss.focused_idx, - pressed_idx: ss.gesture.pressed_idx, - pending_text_values: &ss.pending_text_values, - cursor_state: &ss.frame.cursor_state, - width: ss.width as f32, - height: ss.height as f32, - offset_x: *ox, - offset_y: *oy, - } ); - } - let app_name = data.app.app_id(); - a.update( || crate::a11y::tree::build_tree( &surfaces, kb_focus_id, app_name ) ); - } - data.a11y = a11y_taken; - } - - // Reconcile + reposition input-transparent subsurfaces every - // iteration, decoupled from the main redraw cadence: a finger drag - // emits motion events faster than frame callbacks clear - // `frame_pending`, so gating this on a main draw would pin the - // subsurface between frames. A position-only change is just - // set_position + a bare parent commit; no-ops when nothing moved. - super::subsurface::reconcile_subsurfaces( &mut data ); - - // Drain inbound AT-SPI2 actions (Orca pressing a button, - // switch-control focusing a node, etc.) and translate each - // into a synthetic press / focus on the matching widget. - // Actions for widgets that disappeared between dispatch and - // drain are silently dropped — the action loop is best-effort. - let a11y_requests: Vec = - if let Some( ref mut a ) = data.a11y { a.action_rx.try_iter().collect() } else { Vec::new() }; - if !a11y_requests.is_empty() - { - let qh = data.qh.clone(); - let overlay_keys: Vec = data.overlays.keys().copied().collect(); - for req in a11y_requests - { - let Some( r ) = crate::a11y::tree::parse_id( req.target ) else { continue }; - if r.kind != 0 { continue; } - let idx = r.idx as usize; - let focus_target = if r.focus == 0 - { - SurfaceFocus::Main - } - else - { - let oi = ( r.focus as usize ).saturating_sub( 1 ); - let Some( id ) = overlay_keys.get( oi ).copied() else { continue }; - SurfaceFocus::Overlay( id ) - }; - let widget = match focus_target - { - SurfaceFocus::Main => data.main.frame.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned(), - SurfaceFocus::Overlay( id ) => data.overlays.get( &id ).and_then( |ss| ss.frame.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned() ), - }; - match req.action - { - accesskit::Action::Click => - { - if let Some( msg ) = widget.as_ref().and_then( |w| w.handlers.press_msg() ) - { - data.pending_msgs.push( msg ); - } - } - accesskit::Action::Focus => - { - data.set_focus( focus_target, Some( idx ), &qh ); - } - accesskit::Action::SetValue => - { - if let Some( w ) = widget - { - match ( &w.handlers, req.data ) - { - ( crate::widget::WidgetHandlers::Slider { .. }, Some( accesskit::ActionData::NumericValue( v ) ) ) => - { - if let Some( msg ) = w.handlers.slider_change_msg( v.clamp( 0.0, 1.0 ) as f32 ) - { - data.pending_msgs.push( msg ); - } - } - ( crate::widget::WidgetHandlers::TextEdit { .. }, Some( accesskit::ActionData::Value( s ) ) ) => - { - if let Some( msg ) = w.handlers.text_change_msg( &s ) - { - data.pending_msgs.push( msg ); - } - } - _ => {} - } - } - } - accesskit::Action::Increment | accesskit::Action::Decrement => - { - if let Some( w ) = widget - { - if let crate::widget::WidgetHandlers::Slider { value, .. } = &w.handlers - { - let step = 0.05f32; - let delta = if matches!( req.action, accesskit::Action::Increment ) { step } else { -step }; - let v = ( *value + delta ).clamp( 0.0, 1.0 ); - if let Some( msg ) = w.handlers.slider_change_msg( v ) - { - data.pending_msgs.push( msg ); - } - } - } - } - accesskit::Action::ScrollUp | accesskit::Action::ScrollDown - | accesskit::Action::ScrollLeft | accesskit::Action::ScrollRight => - { - const STEP: f32 = 80.0; - let ( dx, dy ) = match req.action - { - accesskit::Action::ScrollLeft => ( -STEP, 0.0 ), - accesskit::Action::ScrollRight => ( STEP, 0.0 ), - accesskit::Action::ScrollUp => ( 0.0, -STEP ), - accesskit::Action::ScrollDown => ( 0.0, STEP ), - _ => ( 0.0, 0.0 ), - }; - let ss = match focus_target - { - SurfaceFocus::Main => Some( &mut data.main ), - SurfaceFocus::Overlay( id ) => data.overlays.get_mut( &id ), - }; - if let Some( ss ) = ss - { - if let Some( ( _, _, _ ) ) = ss.frame.scroll_rects.last().copied() - { - let scroll_idx = ss.frame.scroll_rects.last().unwrap().1; - let entry = ss.frame.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) ); - entry.0 = ( entry.0 + dx ).max( 0.0 ); - entry.1 = ( entry.1 + dy ).max( 0.0 ); - ss.request_redraw(); - } - } - } - accesskit::Action::ScrollIntoView => - { - let Some( w ) = widget else { continue }; - let ss = match focus_target - { - SurfaceFocus::Main => Some( &mut data.main ), - SurfaceFocus::Overlay( id ) => data.overlays.get_mut( &id ), - }; - if let Some( ss ) = ss - { - let target = w.rect; - let probe = crate::types::Point { x: target.x + 1.0, y: target.y + 1.0 }; - let container = ss.frame.scroll_rects.iter().rev() - .find( |( r, _, _ )| r.contains( probe ) ) - .copied(); - if let Some( ( r, idx, _ ) ) = container - { - let entry = ss.frame.scroll_offsets.entry( idx ).or_insert( ( 0.0, 0.0 ) ); - if target.y < r.y { entry.1 -= r.y - target.y; } - if target.y + target.height > r.y + r.height - { - entry.1 += ( target.y + target.height ) - ( r.y + r.height ); - } - if target.x < r.x { entry.0 -= r.x - target.x; } - if target.x + target.width > r.x + r.width - { - entry.0 += ( target.x + target.width ) - ( r.x + r.width ); - } - entry.0 = entry.0.max( 0.0 ); - entry.1 = entry.1.max( 0.0 ); - ss.request_redraw(); - } - } - } - _ => {} - } - } - } - - if data.app.take_blur_request() - { - let qh = data.qh.clone(); - let focus = data.keyboard_focus; - data.set_focus( focus, None, &qh ); - data.focus_retry = None; - } - - if let Some( text ) = data.app.take_clipboard_request() - { - data.clipboard = text; - data.publish_clipboard_selection(); - } - data.sync_text_field(); - if let Some( text ) = data.app.take_primary_request() - { - data.publish_primary_selection( text ); - } - if let Some( kind ) = data.app.take_paste_request() - { - let text = match kind - { - crate::app::SelectionKind::Clipboard => data.clipboard.clone(), - crate::app::SelectionKind::Primary => data.primary.text.clone(), - }; - if !text.is_empty() { data.app.on_paste( &text ); } - } - - // Focus the widget with the requested WidgetId if the app requests it. - // Walks main + every overlay because the targeted widget can live - // on any of them (a search field on a launcher overlay, a text - // edit on a dialog modal, …). - let retry = data.focus_retry.take(); - let focus_request = data.app.take_focus_request() - .map( |id| ( id, FOCUS_RETRY_FRAMES ) ) - .or( retry ); - if let Some( ( id, frames_left ) ) = focus_request - { - let mut hit: Option<( SurfaceFocus, usize )> = data.main.frame.widget_rects.iter() - .find( |w| w.id == Some( id ) ) - .map( |w| ( SurfaceFocus::Main, w.flat_idx ) ); - if hit.is_none() - { - for ( ov_id, surf ) in &data.overlays - { - if let Some( w ) = surf.frame.widget_rects.iter().find( |w| w.id == Some( id ) ) - { - hit = Some( ( SurfaceFocus::Overlay( *ov_id ), w.flat_idx ) ); - break; - } - } - } - if let Some( ( focus, flat_idx ) ) = hit - { - let qh = data.qh.clone(); - data.set_focus( focus, Some( flat_idx ), &qh ); - match focus - { - SurfaceFocus::Main => data.main.request_redraw(), - SurfaceFocus::Overlay( id ) => - { - if let Some( s ) = data.overlays.get_mut( &id ) - { - s.request_redraw(); - } - } - } - } else { - let frames_left = if any_drawable { frames_left.saturating_sub( 1 ) } else { frames_left }; - if frames_left > 0 - { - data.reveal_clipped_widget( id ); - data.focus_retry = Some( ( id, frames_left ) ); - data.view_dirty = true; - data.main.request_redraw(); - } - } - } - } - - data.session.on_exit( &data.app ); - - Ok( () ) -} diff --git a/src/event_loop/setup.rs b/src/event_loop/setup.rs new file mode 100644 index 0000000..252d9c8 --- /dev/null +++ b/src/event_loop/setup.rs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Building the runtime's state for an app, and the event sources every backend installs on its loop. + +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; +use crate::types::Point; + +use super::{ AppData, RunError, SurfaceFocus, SurfaceState }; +use super::perf::PerfState; + +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 + { + let fullscreen = app.start_fullscreen(); + Self + { + app, + platform, + pointer_pos: Point::default(), + current_cursor_shape: None, + builtin_osk, + text_input_secure: false, + text_input_purpose: crate::osk::Purpose::Normal, + external_text_input: None, + primary_selection: String::new(), + primary_field: None, + a11y, + shift_pressed: false, + ctrl_pressed: false, + alt_pressed: false, + loop_handle, + compositor_repeat_rate: 0, + compositor_repeat_delay: 0, + key_repeat: None, + button_repeat: None, + clipboard: String::new(), + last_press_time: None, + last_press_pos: None, + debug_layout: std::env::var( "LTK_DEBUG_LAYOUT" ).is_ok(), + pending_msgs: Vec::new(), + pending_drag_inits: Vec::new(), + tooltip_pending: None, + tooltip_visible: None, + exit_requested: false, + applied_fullscreen: Some( fullscreen ), + main, + overlays: HashMap::new(), + secondaries: Vec::new(), + pointer_focus: SurfaceFocus::Main, + keyboard_focus: SurfaceFocus::Main, + press_on_focused: false, + touch_focus: HashMap::new(), + cached_view: None, + cached_overlays: None, + view_dirty: true, + overlays_dirty: true, + first_frame_committed: false, + focus_retry: None, + perf: PerfState::new(), + } + } + + /// 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:?}" ) ) )?; + 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| + { + 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() + { + 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| + { + 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 ) ) ) + } ) + .map_err( |e| RunError::EventLoop( format!( "poll timer insert_source: {e:?}" ) ) )?; + } + Ok( () ) + } +} diff --git a/src/event_loop/step.rs b/src/event_loop/step.rs new file mode 100644 index 0000000..16c86ad --- /dev/null +++ b/src/event_loop/step.rs @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The parts of a run-loop iteration that are the same on every backend. After waking, a backend handles what its system delivered, then calls [`AppData::before_draw`], [`AppData::draw_due`] and [`AppData::after_draw`], and sleeps at most [`AppData::next_wakeup`]. + +use std::time::Duration; + +use crate::app::{ App, InvalidationScope, SelectionKind }; +use crate::platform::Platform; + +use super::{ AppData, SurfaceFocus }; +use super::frame::draw_frame; +use super::invalidation::apply_invalidation; + +/// Drawn frames a focus request waits for its target to appear: one for the rebuilt view, one per scroll viewport moved to reveal the widget. +const FOCUS_RETRY_FRAMES: u8 = 4; + +impl AppData +{ + /// The longest the backend may sleep with no event: until a still press turns long, a tooltip shows or the built-in keyboard acts. Frames are paced by the system, so nothing else needs a deadline. + pub( crate ) fn next_wakeup( &self ) -> Option + { + [ self.next_long_press_wakeup(), self.next_tooltip_wakeup(), self.next_osk_wakeup() ] + .into_iter() + .flatten() + .min() + } + + /// Everything between handling the system's events and drawing: deadlines that passed, the app's external messages, the update, and what the update asks of the window. + pub( crate ) fn before_draw( &mut self ) + { + self.check_long_press_deadlines(); + self.check_tooltip_deadline(); + self.check_osk_deadlines(); + + let ext: Vec<_> = self.app.poll_external(); + self.pending_msgs.extend( ext ); + + self.process_messages(); + if self.app.requested_exit() { self.exit_requested = true; } + self.sync_fullscreen(); + self.sync_external_text_input(); + + // After `update()`, so the app's drag state set up by the paired long-press message is there to receive the origin. + let drag_inits: Vec<_> = self.pending_drag_inits.drain( .. ).collect(); + if !drag_inits.is_empty() + { + for origin in drag_inits + { + self.app.on_drag_move( origin.x, origin.y ); + } + self.dirty_caches(); + self.main.request_redraw(); + } + // The app state is now the source of truth, so the next keystroke must read it rather than a stale buffer (a password cleared on a failed login). + self.main.pending_text_values.clear(); + for ss in self.overlays.values_mut() + { + ss.pending_text_values.clear(); + } + } + + /// Everything after drawing: the accessibility tree and its actions, then the app's requests to blur, copy, paste and move the focus. `drawn` is what [`Self::draw_due`] returned. + pub( crate ) fn after_draw( &mut self, drawn: bool ) + { + if drawn { self.publish_a11y_tree(); } + self.apply_a11y_actions(); + + if self.app.take_blur_request() + { + let focus = self.keyboard_focus; + self.set_focus( focus, None ); + self.focus_retry = None; + } + + if let Some( text ) = self.app.take_clipboard_request() + { + self.publish_clipboard( text ); + } + self.sync_text_field(); + if let Some( text ) = self.app.take_primary_request() + { + self.publish_primary_selection( text ); + } + if let Some( kind ) = self.app.take_paste_request() + { + let text = match kind + { + SelectionKind::Clipboard => self.clipboard.clone(), + SelectionKind::Primary => self.primary_selection.clone(), + }; + if !text.is_empty() { self.app.on_paste( &text ); } + } + + self.apply_focus_request( drawn ); + } + + /// What follows the system's word that the last frame of `focus` is on screen: an animation draws its next frame, or, when the app animates only by moving subsurfaces `focus` hosts, leaves the cadence to them. Under the software animation cap a skipped frame keeps the cadence with a bare frame request. + pub( crate ) fn frame_presented( &mut self, focus: SurfaceFocus, hosts_subsurfaces: bool ) + { + let animating = self.app.is_animating(); + match focus + { + SurfaceFocus::Main => + { + self.main.frame_pending = false; + if !animating + { + self.perf.animation_stopped(); + return; + } + if hosts_subsurfaces && self.app.subsurface_motion_only() + { + self.main.frame_tick_due = true; + return; + } + let cap = self.app.cap_software_animation(); + if self.perf.animated_frame( crate::render::is_software_render(), cap ) + { + self.view_dirty = true; + self.main.request_redraw(); + } else if self.platform.request_frame( &self.main.platform, SurfaceFocus::Main ) { + self.main.frame_pending = true; + } + } + SurfaceFocus::Overlay( id ) => + { + let motion_only = hosts_subsurfaces && self.app.subsurface_motion_only(); + let Some( ss ) = self.overlays.get_mut( &id ) else { return }; + ss.frame_pending = false; + if !animating { return; } + if motion_only + { + ss.frame_tick_due = true; + } else { + ss.request_redraw(); + self.overlays_dirty = true; + } + } + } + } + + /// Focus the widget the app asked for, on the main surface or any overlay. One not laid out yet, or clipped by a scroll viewport, is waited for over a few drawn frames. + fn apply_focus_request( &mut self, drawn: bool ) + { + let retry = self.focus_retry.take(); + let Some( ( id, frames_left ) ) = self.app.take_focus_request() + .map( |id| ( id, FOCUS_RETRY_FRAMES ) ) + .or( retry ) + else { return }; + + let mut hit: Option<( SurfaceFocus, usize )> = self.main.frame.widget_rects.iter() + .find( |w| w.id == Some( id ) ) + .map( |w| ( SurfaceFocus::Main, w.flat_idx ) ); + if hit.is_none() + { + hit = self.overlays.iter().find_map( |( ov_id, surf )| + { + surf.frame.widget_rects.iter() + .find( |w| w.id == Some( id ) ) + .map( |w| ( SurfaceFocus::Overlay( *ov_id ), w.flat_idx ) ) + } ); + } + if let Some( ( focus, flat_idx ) ) = hit + { + self.set_focus( focus, Some( flat_idx ) ); + if let Some( ss ) = self.try_surface_mut( focus ) { ss.request_redraw(); } + return; + } + let frames_left = if drawn { frames_left.saturating_sub( 1 ) } else { frames_left }; + if frames_left > 0 + { + self.reveal_clipped_widget( id ); + self.focus_retry = Some( ( id, frames_left ) ); + self.view_dirty = true; + self.main.request_redraw(); + } + } + + /// Hand the queued messages to [`App::update`], folding their invalidation scopes into one, and re-sync the cursor the update may have changed. + pub( crate ) fn process_messages( &mut self ) + { + let msgs: Vec<_> = self.pending_msgs.drain( .. ).collect(); + if msgs.is_empty() { return; } + let mut scope = InvalidationScope::Only( Vec::new() ); + for msg in msgs + { + scope = scope.union( self.app.invalidate_after( &msg ) ); + self.app.update( msg ); + } + apply_invalidation( self, scope ); + // `update()` may have flipped the busy / loading flag + // the app reads from inside `cursor_override`. Re-sync + // the pointer cursor so the change propagates without + // waiting for the next motion event. + let pf = self.pointer_focus; + self.dispatch_cursor_shape( pf ); + } + + /// Draw every surface that is configured, dirty and not waiting on a frame callback, rebuilding the cached view and overlays first when they are stale. Returns whether any surface was due. + pub( crate ) fn draw_due( &mut self ) -> bool + { + let any_drawable = ( self.main.configured && self.main.needs_redraw && !self.main.frame_pending ) + || self.overlays.values().any( |ss| ss.configured && ss.needs_redraw && !ss.frame_pending ) + || self.secondaries.iter().any( |( _, ss )| ss.configured && ss.needs_redraw && !ss.frame_pending ); + if !any_drawable { return false; } + + // Rebuild while motion is in progress and on the first frame + // after it ends, so the settle frame paints at full quality + // instead of freezing one step short of the target. + // `wants_low_quality_paint` is the source of truth so + // finger-tracked drags get the same treatment. Slider / + // scroll drags are owned by the runtime gesture machine, + // not by `App::update`, so OR with the gesture state to + // pick up that motion signal too. + let runtime_slider_motion = + self.main.gesture.dragging_slider.is_some() + || self.overlays.values().any( |ss| ss.gesture.dragging_slider.is_some() ); + if runtime_slider_motion + { + self.view_dirty = true; + self.overlays_dirty = true; + } + if self.view_dirty + { + self.cached_view = Some( self.app.view() ); + self.view_dirty = false; + } + if self.overlays_dirty + { + let mut specs = self.app.overlays(); + if let Some( ts ) = self.tooltip_overlay() { specs.push( ts ); } + self.cached_overlays = Some( specs ); + self.overlays_dirty = false; + } + if draw_frame( self ) + { + self.app.on_first_frame_committed(); + } + true + } +} diff --git a/src/event_loop/surface.rs b/src/event_loop/surface.rs index 2167a33..e743885 100644 --- a/src/event_loop/surface.rs +++ b/src/event_loop/surface.rs @@ -1,30 +1,11 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use smithay_client_toolkit:: -{ - compositor::CompositorState, - shell:: - { - WaylandSurface, - wlr_layer::{ KeyboardInteractivity, Layer, LayerShell, LayerSurface }, - xdg::{ popup::Popup, window::Window }, - }, - shm::{ Shm, slot::SlotPool }, -}; -use smithay_client_toolkit::reexports::client:: -{ - protocol::wl_surface::WlSurface, - QueueHandle, -}; -use smithay_client_toolkit::session_lock::SessionLockSurface; use std::collections::HashMap; -use std::sync::Arc; -use super::app_data::AppData; use crate::app::{ OverlayId, SurfaceTarget }; -use crate::egl_context::{ EglContext, EglSurface }; use crate::input::GestureState; +use crate::platform::NativeSurface; use crate::render::Canvas; use crate::types::{ Point, Rect, WidgetId }; use crate::widget::LaidOutWidget; @@ -54,113 +35,6 @@ impl SurfaceFocus } } -/// Configuration for a layer-shell surface, used both for the main surface -/// (when the app uses [`crate::app::ShellMode::Layer`]) and for each overlay -/// returned by [`crate::app::App::overlays`]. -#[ derive( Clone ) ] -pub( crate ) struct LayerConfig -{ - pub layer: Layer, - pub exclusive_zone: i32, - pub anchor: crate::app::Anchor, - pub size: ( u32, u32 ), - pub keyboard_exclusive: bool, - /// Wayland surface role namespace, sent to the compositor for debugging. - pub namespace: &'static str, -} - -pub( crate ) enum SurfaceKind -{ - /// Layer shell surface, fully created and committed. - Layer( LayerSurface ), - /// XDG window fallback (no layer shell compositor support). - Window( Window ), - /// Layer shell is available but we are waiting for an output to be - /// advertised before creating the Wayland surface. - Pending( LayerConfig ), - /// XDG popup, child of the main window. Used for combo dropdowns, - /// context menus, tooltips. The compositor positions it relative - /// to an anchor rect specified at creation time and may flip it - /// (drop-up vs drop-down) when constrained. - Popup( Popup ), - /// `ext-session-lock-v1` lock surface, created in `SessionLockHandler::locked`. - Lock( SessionLockSurface ), - /// Session lock requested; waiting for the compositor's `locked` event - /// before the lock surface exists. - PendingLock, -} - -impl SurfaceKind -{ - pub( crate ) fn wl_surface( &self ) -> &WlSurface - { - match self - { - SurfaceKind::Layer( l ) => l.wl_surface(), - SurfaceKind::Window( w ) => w.wl_surface(), - SurfaceKind::Popup( p ) => p.wl_surface(), - SurfaceKind::Lock( s ) => s.wl_surface(), - // Unreachable: draw_frame is only called when configured == true, - // which only becomes true after on_configure, which requires a real surface. - SurfaceKind::Pending( .. ) | SurfaceKind::PendingLock => unreachable!( "surface not yet created" ), - } - } - - /// Same as [`wl_surface`] but returns `None` for a [`SurfaceKind::Pending`] - /// surface instead of panicking. Used by event dispatch to look up which - /// [`SurfaceState`] an incoming Wayland event belongs to without risk of - /// crashing during startup. - pub( crate ) fn try_wl_surface( &self ) -> Option<&WlSurface> - { - match self - { - SurfaceKind::Layer( l ) => Some( l.wl_surface() ), - SurfaceKind::Window( w ) => Some( w.wl_surface() ), - SurfaceKind::Popup( p ) => Some( p.wl_surface() ), - SurfaceKind::Lock( s ) => Some( s.wl_surface() ), - SurfaceKind::Pending( .. ) | SurfaceKind::PendingLock => None, - } - } - - /// Create the actual layer surface on `output` using `layer_shell`. Returns - /// true if the surface was created (i.e., was `Pending` before). - pub( crate ) fn materialize( - &mut self, - compositor: &CompositorState, - layer_shell: &LayerShell, - qh: &QueueHandle>, - output: &smithay_client_toolkit::reexports::client::protocol::wl_output::WlOutput, - ) -> bool - { - if let SurfaceKind::Pending( ref cfg ) = *self - { - let surface = compositor.create_surface( qh ); - let layer_surface = layer_shell.create_layer_surface( - qh, - surface, - cfg.layer, - Some( cfg.namespace ), - Some( output ), - ); - layer_surface.set_exclusive_zone( cfg.exclusive_zone ); - layer_surface.set_anchor( cfg.anchor.to_wlr_anchor() ); - let interactivity = if cfg.keyboard_exclusive - { - KeyboardInteractivity::Exclusive - } else { - KeyboardInteractivity::OnDemand - }; - layer_surface.set_keyboard_interactivity( interactivity ); - layer_surface.set_size( cfg.size.0, cfg.size.1 ); - layer_surface.commit(); - *self = SurfaceKind::Layer( layer_surface ); - true - } else { - false - } - } -} - /// The subset of per-surface state the draw pass owns and threads through /// [`crate::draw::DrawCtx`]: the laid-out widget rects it produces, the scroll / /// text-edit / a11y state carried across frames, and the previous frame's @@ -230,19 +104,25 @@ impl FrameState } } +/// Logical pixels along each edge of a surface that the system covers: status and navigation bars, a keyboard. +#[ derive( Clone, Copy, Debug, Default, PartialEq ) ] +pub( crate ) struct Insets +{ + pub top: f32, + pub right: f32, + pub bottom: f32, + pub left: f32, +} + /// Per-surface render + interaction state. /// /// Each Wayland surface managed by the event loop (main surface and any /// active overlays) owns an instance of this struct, so multiple surfaces /// can coexist without interfering. -pub( crate ) struct SurfaceState +pub( crate ) struct SurfaceState { - pub pool: Option, - /// EGL window surface for the GPU path. Populated on the first configure - /// when an [`EglContext`] is available; mutually exclusive with `pool` - /// (the path is locked after the first configure). - pub egl_surface: Option, - pub surface: SurfaceKind, + /// The platform's half: on Wayland the role object, the SHM pool or EGL window surface, and what the compositor was last told. + pub platform: S, pub canvas: Option, pub width: u32, pub height: u32, @@ -288,9 +168,8 @@ pub( crate ) struct SurfaceState pub touch_slots: HashMap, /// Height of the client-side title bar (0 for layer-shell surfaces). pub titlebar_height: f32, - /// Title bar height before decoration negotiation; restored when the - /// compositor hands decorations back to the client. - pub titlebar_base: f32, + /// Where the view is not laid out because the system covers it; the background still fills it. + pub insets: Insets, /// Title text shown in the client-side title bar. pub titlebar_title: String, /// Rect of the close button in the title bar (for hit testing). @@ -298,23 +177,7 @@ pub( crate ) struct SurfaceState /// 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, - /// Last size requested from the compositor via `layer_surface.set_size`. - /// Compared against the app's current [`crate::app::OverlaySpec::size`] - /// each reconcile pass so that overlays can resize after creation (used - /// for slide-down / grow animations). `(0, 0)` for non-layer surfaces - /// and for layer surfaces before their first configure. - pub last_requested_size: ( u32, u32 ), - /// Logical exclusive zone last sent with `set_exclusive_zone`. Tracked - /// alongside the size because a zone that stays at its creation-time - /// value while the surface keeps resizing leaves the compositor - /// reserving a band that no longer matches what the overlay paints. - pub last_requested_zone: i32, pub layer_anchor: Option, - /// Anchor rect the xdg-popup positioner was last configured with. - /// `None` for non-popup surfaces. - pub last_popup_anchor: Option, - /// Monotonic counter for `xdg_popup.reposition` tokens. - pub popup_reposition_token: u32, /// `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 /// being `false`: even if `needs_redraw` flips back to `true` we sit on @@ -326,18 +189,14 @@ pub( crate ) struct SurfaceState /// subsurfaces only; the subsurface reconcile keeps the cadence /// with a bare frame request + commit when nothing moved. pub frame_tick_due: bool, - /// Opaque rects (physical) last applied to the surface. - pub opaque_full: Option>, } -impl SurfaceState +impl SurfaceState { - pub( crate ) fn new( surface: SurfaceKind, titlebar_height: f32, titlebar_title: String ) -> Self + pub( crate ) fn new( platform: S, titlebar_height: f32, titlebar_title: String ) -> Self { Self { - pool: None, - egl_surface: None, - surface, + platform, canvas: None, width: 0, height: 0, @@ -356,18 +215,13 @@ impl SurfaceState osk: None, gesture: GestureState::new(), titlebar_height, - titlebar_base: titlebar_height, + insets: Insets::default(), titlebar_title, titlebar_close_rect: Rect::default(), scale_factor: 1, - last_requested_size: ( 0, 0 ), - last_requested_zone: 0, layer_anchor: None, - last_popup_anchor: None, - popup_reposition_token: 0, frame_pending: false, frame_tick_due: false, - opaque_full: None, } } @@ -393,98 +247,6 @@ impl SurfaceState self.height * self.scale_factor.max( 1 ) as u32 } - /// Configure the surface with a new logical size. Picks the rendering path - /// on the very first configure (GPU when `egl_context` is `Some` and the - /// EGL window can be created, SHM otherwise) and just resizes the existing - /// path on subsequent configures. The two paths are mutually exclusive - /// (`pool.is_some()` ⇔ SHM, `egl_surface.is_some()` ⇔ GPU) and locked - /// after the first configure — switching backends mid-life would require - /// tearing down the canvas as well. - pub( crate ) fn on_configure( - &mut self, - shm: &Shm, - egl_context: Option<&Arc>, - w: u32, - h: u32, - ) - { - let scale = self.scale_factor.max( 1 ) as u32; - let pw = w.saturating_mul( scale ); - let ph = h.saturating_mul( scale ); - let pool_bytes = (pw as u64) - .saturating_mul( ph as u64 ) - .saturating_mul( 4 ); - - const MAX_POOL: u64 = 256 * 1024 * 1024; - if pool_bytes > MAX_POOL || pw == 0 || ph == 0 - { - eprintln!( "[ltk] on_configure: size {w}×{h} (physical {pw}×{ph}) out of range — ignoring" ); - return; - } - let pool_bytes = pool_bytes as usize; - - if self.egl_surface.is_some() - { - // GPU path: resize the wl_egl_window so the next eglSwapBuffers - // publishes a buffer of the new size. The canvas FBO is reallocated - // by `Canvas::resize`, but only after we eglMakeCurrent — the draw - // path will do that, so we just record the pending size here. - if let Some( ref es ) = self.egl_surface - { - es.resize( pw as i32, ph as i32 ); - } - } else if self.pool.is_some() { - // SHM path: reallocate the pool to fit the new buffer size. - match SlotPool::new( pool_bytes, shm ) - { - Ok( pool ) => self.pool = Some( pool ), - Err( e ) => - { - eprintln!( "[ltk] SlotPool resize failed: {e} — keeping previous allocation" ); - return; - } - } - } else { - // First configure — pick the path. Try GPU when the bootstrap - // succeeded; fall back to SHM if surface creation fails. - let mut chose_gpu = false; - if let Some( ctx ) = egl_context - { - match ctx.create_surface( self.surface.wl_surface(), pw as i32, ph as i32 ) - { - Ok( es ) => - { - self.egl_surface = Some( es ); - chose_gpu = true; - } - Err( e ) => - { - eprintln!( "[ltk] eglCreateWindowSurface failed: {e} — using SHM" ); - } - } - } - if !chose_gpu - { - match SlotPool::new( pool_bytes, shm ) - { - Ok( pool ) => self.pool = Some( pool ), - Err( e ) => - { - eprintln!( "[ltk] SlotPool init failed: {e}" ); - return; - } - } - } - } - - self.width = w; - self.height = h; - self.surface.wl_surface().set_buffer_scale( self.scale_factor.max( 1 ) ); - self.configured = true; - self.needs_redraw = true; - self.content_dirty = true; - } - /// Request a redraw and mark the surface's content as dirty. /// /// Use this from every site that potentially changes what the app's view @@ -497,4 +259,13 @@ impl SurfaceState self.needs_redraw = true; self.content_dirty = true; } + + /// Take `w × h` logical pixels as the surface's size once the backend has resized its buffers, and redraw it whole. + pub( crate ) fn set_size( &mut self, w: u32, h: u32 ) + { + self.width = w; + self.height = h; + self.configured = true; + self.request_redraw(); + } } diff --git a/src/event_loop/tests.rs b/src/event_loop/tests.rs new file mode 100644 index 0000000..c705b40 --- /dev/null +++ b/src/event_loop/tests.rs @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The runtime driven on [`MockPlatform`]: input through the backend-neutral entry points, a step of the run loop, and what reached the platform. + +use std::sync::MutexGuard; + +use calloop::EventLoop; + +use crate::app::App; +use crate::input::event::{ KeyInput, PointerButton, PointerInput, PointerKind }; +use crate::osk::Purpose; +use crate::platform::mock::{ MockPlatform, Request }; +use crate::types::Rect; +use crate::widget::WidgetHandlers; +use crate::{ button, column, text_edit, Element, Keysym }; + +use super::{ AppData, SurfaceFocus, SurfaceState }; +use super::surface::Insets; + +const WIDTH: u32 = 400; +const HEIGHT: u32 = 300; + +#[ derive( Clone, Debug, PartialEq ) ] +enum Msg +{ + Pressed, + Typed( String ), +} + +#[ derive( Default ) ] +struct TestApp +{ + value: String, + secure: bool, + purpose: Purpose, + presses: u32, +} + +impl App for TestApp +{ + type Message = Msg; + + fn app_id( &self ) -> &str { "net.liberux.ltk.Test" } + fn save_state( &self ) -> Option> { None } + fn restore_state( &mut self, _state: Vec ) {} + + fn view( &self ) -> Element + { + column() + .push( button( "OK" ).on_press( Msg::Pressed ) ) + .push( text_edit( "", self.value.clone() ).secure( self.secure ).purpose( self.purpose ).on_change( Msg::Typed ) ) + .into() + } + + fn update( &mut self, msg: Msg ) + { + match msg + { + Msg::Pressed => self.presses += 1, + Msg::Typed( text ) => self.value = text, + } + } +} + +/// Field order matters: `data` holds a handle into `event_loop` and drops first. +struct Harness +{ + data: AppData, + _event_loop: EventLoop<'static, AppData>, + _globals: MutexGuard<'static, ()>, +} + +impl Harness +{ + /// `app` on a configured `WIDTH × HEIGHT` main surface at scale 1, not drawn yet. + fn new( app: TestApp ) -> Self + { + 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 ); + data.main.set_size( WIDTH, HEIGHT ); + data.on_main_resized( WIDTH, HEIGHT ); + Self { data, _event_loop: event_loop, _globals: globals } + } + + /// One iteration of the run loop's backend-neutral part. Returns what reached the platform since the last step. + fn step( &mut self ) -> Vec + { + self.data.process_messages(); + self.data.draw_due(); + self.data.platform.take_requests() + } + + /// What the backend does when the system says the last frame is on screen. + fn frame_done( &mut self ) + { + self.data.main.frame_pending = false; + } + + fn rect_of( &self, pick: impl Fn( &WidgetHandlers ) -> bool ) -> Rect + { + self.data.main.frame.widget_rects.iter() + .find( |w| pick( &w.handlers ) ) + .map( |w| w.rect ) + .expect( "widget laid out" ) + } + + fn button_rect( &self ) -> Rect + { + self.rect_of( |h| matches!( h, WidgetHandlers::Button { .. } ) ) + } + + fn field_rect( &self ) -> Rect + { + self.rect_of( |h| h.is_text_input() ) + } + + fn pointer( &mut self, kind: PointerKind, r: Rect ) + { + let position = ( ( r.x + r.width / 2.0 ) as f64, ( r.y + r.height / 2.0 ) as f64 ); + self.data.pointer_input( &PointerInput { focus: SurfaceFocus::Main, position, kind } ); + } + + fn hover( &mut self, r: Rect ) + { + self.pointer( PointerKind::Enter, r ); + self.pointer( PointerKind::Motion, r ); + } + + fn click( &mut self, r: Rect ) + { + self.hover( r ); + self.pointer( PointerKind::Press( PointerButton::Left ), r ); + self.pointer( PointerKind::Release( PointerButton::Left ), r ); + } + + fn key( &mut self, keysym: Keysym, text: &str ) + { + let key = KeyInput::synthetic( keysym, Some( text.to_string() ) ); + self.data.key_press( key.clone() ); + self.data.key_release( &key ); + } +} + +/// The damage of every presentation among `requests`. +fn presented( requests: &[Request] ) -> Vec> +{ + requests.iter().filter_map( |r| match r + { + Request::Present { damage, .. } => Some( damage.clone() ), + _ => None, + } ).collect() +} + +#[ test ] +fn the_first_frame_is_presented_whole_and_asks_for_the_next() +{ + let mut h = Harness::new( TestApp::default() ); + let requests = h.step(); + assert!( requests.contains( &Request::Present { frame: Some( SurfaceFocus::Main ), damage: Vec::new(), size: ( WIDTH, HEIGHT ) } ) ); + assert!( h.data.main.frame_pending ); + assert!( !h.data.main.content_dirty ); +} + +#[ test ] +fn nothing_is_drawn_while_a_frame_is_pending_or_when_nothing_changed() +{ + let mut h = Harness::new( TestApp::default() ); + h.step(); + let button = h.button_rect(); + h.hover( button ); + assert!( presented( &h.step() ).is_empty() ); + h.frame_done(); + assert_eq!( presented( &h.step() ).len(), 1 ); + h.frame_done(); + assert!( presented( &h.step() ).is_empty() ); +} + +#[ test ] +fn a_click_on_a_button_reaches_update() +{ + let mut h = Harness::new( TestApp::default() ); + h.step(); + let button = h.button_rect(); + h.click( button ); + h.step(); + assert_eq!( h.data.app.presses, 1 ); +} + +#[ test ] +fn hovering_a_widget_presents_only_its_rect() +{ + let mut h = Harness::new( TestApp::default() ); + h.step(); + h.frame_done(); + let button = h.button_rect(); + h.hover( button ); + let damage = presented( &h.step() ); + assert_eq!( damage.len(), 1 ); + let rects = &damage[ 0 ]; + assert!( !rects.is_empty(), "a hover should repaint part of the surface, not all of it" ); + let area: f32 = rects.iter().map( |r| r.width * r.height ).sum(); + assert!( area < ( WIDTH * HEIGHT ) as f32 / 2.0 ); + assert!( rects.iter().all( |r| r.x >= 0.0 && r.y >= 0.0 && r.x + r.width <= WIDTH as f32 && r.y + r.height <= HEIGHT as f32 ) ); +} + +#[ test ] +fn a_failed_presentation_leaves_the_next_frame_to_repaint_whole() +{ + let mut h = Harness::new( TestApp::default() ); + h.data.platform.fail_present = true; + assert!( presented( &h.step() ).is_empty() ); + assert!( !h.data.main.frame_pending ); + assert!( h.data.main.content_dirty ); + + h.data.platform.fail_present = false; + let button = h.button_rect(); + h.hover( button ); + assert_eq!( presented( &h.step() ), vec![ Vec::new() ] ); +} + +#[ test ] +fn a_secure_field_asks_the_input_method_for_a_password() +{ + let mut h = Harness::new( TestApp { secure: true, ..TestApp::default() } ); + h.step(); + let field = h.field_rect(); + h.click( field ); + assert!( h.step().contains( &Request::TextInput { secure: true, purpose: Purpose::Normal } ) ); +} + +#[ test ] +fn a_field_hands_its_purpose_to_the_input_method() +{ + let mut h = Harness::new( TestApp { purpose: Purpose::Terminal, ..TestApp::default() } ); + h.step(); + let field = h.field_rect(); + h.click( field ); + assert!( h.step().contains( &Request::TextInput { secure: false, purpose: Purpose::Terminal } ) ); +} + +#[ test ] +fn the_view_is_laid_out_inside_the_insets() +{ + let mut h = Harness::new( TestApp::default() ); + h.data.main.insets = Insets { top: 40.0, right: 0.0, bottom: 30.0, left: 10.0 }; + h.step(); + let button = h.button_rect(); + assert!( button.y >= 40.0 && button.x >= 10.0, "button at {button:?}" ); +} + +#[ test ] +fn copying_a_selection_publishes_the_clipboard() +{ + let mut h = Harness::new( TestApp { value: "hola".to_string(), ..TestApp::default() } ); + h.step(); + let field = h.field_rect(); + h.click( field ); + h.data.modifiers_changed( true, false, false ); + h.key( Keysym::a, "a" ); + h.key( Keysym::c, "c" ); + assert!( h.step().contains( &Request::Clipboard( "hola".to_string() ) ) ); + assert_eq!( h.data.clipboard, "hola" ); +} diff --git a/src/event_loop/text_editing/cursor.rs b/src/event_loop/text_editing/cursor.rs index a87bfa4..f981a6a 100644 --- a/src/event_loop/text_editing/cursor.rs +++ b/src/event_loop/text_editing/cursor.rs @@ -4,8 +4,9 @@ use crate::app::App; use crate::event_loop::app_data::AppData; use crate::event_loop::surface::SurfaceFocus; +use crate::platform::Platform; -impl AppData +impl AppData { /// Read the current text value of the focused widget — preferring /// the pending typed-but-not-yet-applied value when present, falling diff --git a/src/event_loop/text_editing/ime.rs b/src/event_loop/text_editing/ime.rs index e74d9c8..feb9bbe 100644 --- a/src/event_loop/text_editing/ime.rs +++ b/src/event_loop/text_editing/ime.rs @@ -1,60 +1,31 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use smithay_client_toolkit::reexports::client::QueueHandle; -use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_v3; - use crate::app::App; use crate::event_loop::app_data::AppData; use crate::event_loop::surface::SurfaceFocus; +use crate::platform::Platform; use crate::tree::find_widget; use crate::types::{ Length, Rect }; use crate::widget::WidgetHandlers; -impl AppData +impl AppData { - pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle, secure: bool, purpose: crate::osk::Purpose ) + pub( crate ) fn activate_text_input( &mut self, secure: bool, purpose: crate::osk::Purpose ) { self.text_input_secure = secure; self.text_input_purpose = purpose; - let ( hint, purpose ) = content_type( secure, purpose ); - match ( &self.text_input_manager, &self.text_input ) - { - ( Some( manager ), None ) => - { - let seats: Vec<_> = self.seat_state.seats().collect(); - if let Some( seat ) = seats.into_iter().next() - { - let ti = manager.get_text_input( &seat, qh, () ); - ti.enable(); - ti.set_content_type( hint, purpose ); - ti.commit(); - self.text_input = Some( ti ); - } else { - eprintln!( "ltk: activate_text_input: no seat available" ); - } - } - ( None, _ ) => - eprintln!( "ltk: activate_text_input: no text_input_manager (compositor did not advertise zwp_text_input_manager_v3)" ), - // A press on another text field, or on the focused one again: renew - // the enable so a compositor that folded its keyboard raises it. - ( Some( _ ), Some( ti ) ) => - { - ti.enable(); - ti.set_content_type( hint, purpose ); - ti.commit(); - } - } + self.platform.activate_text_input( secure, purpose ); } pub( crate ) fn deactivate_text_input( &mut self ) { - if let Some( ti ) = self.text_input.take() - { - ti.disable(); - ti.commit(); - ti.destroy(); - } + self.platform.deactivate_text_input(); + } + + pub( crate ) fn reenable_text_input( &self ) + { + self.platform.reenable_text_input( self.text_input_secure, self.text_input_purpose ); } /// Follow [`App::external_text_input`] while no ltk text widget holds the focus. @@ -68,11 +39,10 @@ impl AppData let wanted = self.app.external_text_input(); match ( wanted, self.external_text_input ) { - ( Some( _ ), _ ) if self.text_input.is_none() => + ( Some( _ ), _ ) if !self.platform.text_input_active() => { - let qh = self.qh.clone(); let purpose = self.app.external_text_purpose(); - self.activate_text_input( &qh, false, purpose ); + self.activate_text_input( false, purpose ); } ( Some( new ), Some( old ) ) if new != old => { @@ -82,18 +52,15 @@ impl AppData ( None, Some( _ ) ) => self.deactivate_text_input(), _ => {} } - self.external_text_input = wanted; - } - - pub( crate ) fn reenable_text_input( &self ) - { - if let Some( ti ) = &self.text_input + // The built-in keyboard follows the request itself, not the input method: shown when it starts or renews, hidden when it ends. + let focus = self.keyboard_focus; + match ( wanted, self.external_text_input ) { - let ( hint, purpose ) = content_type( self.text_input_secure, self.text_input_purpose ); - ti.enable(); - ti.set_content_type( hint, purpose ); - ti.commit(); + ( Some( new ), old ) if old != Some( new ) => self.show_osk_for( focus, self.app.external_text_purpose() ), + ( None, Some( _ ) ) => self.hide_osk( focus ), + _ => {} } + self.external_text_input = wanted; } /// Snapshot a focused-widget's geometry needed by the pointer @@ -133,23 +100,3 @@ impl AppData } } } - -/// Map a field's `secure` flag to the text-input-v3 content type. Secure -/// fields are flagged `Password` with `SensitiveData | HiddenText` so the -/// IME/OSK skips prediction, autocorrect and storing the value. -fn content_type( secure: bool, purpose: crate::osk::Purpose ) -> ( zwp_text_input_v3::ContentHint, zwp_text_input_v3::ContentPurpose ) -{ - if secure - { - return ( - zwp_text_input_v3::ContentHint::SensitiveData | zwp_text_input_v3::ContentHint::HiddenText, - zwp_text_input_v3::ContentPurpose::Password, - ); - } - let purpose = match purpose - { - crate::osk::Purpose::Normal => zwp_text_input_v3::ContentPurpose::Normal, - crate::osk::Purpose::Terminal => zwp_text_input_v3::ContentPurpose::Terminal, - }; - ( zwp_text_input_v3::ContentHint::None, purpose ) -} diff --git a/src/event_loop/text_editing/insert_delete.rs b/src/event_loop/text_editing/insert_delete.rs index 9a47321..c5272ad 100644 --- a/src/event_loop/text_editing/insert_delete.rs +++ b/src/event_loop/text_editing/insert_delete.rs @@ -6,8 +6,9 @@ use crate::event_loop::app_data::AppData; use crate::event_loop::surface::SurfaceFocus; use crate::tree::find_handlers; use crate::widget::WidgetHandlers; +use crate::platform::Platform; -impl AppData +impl AppData { pub( crate ) fn handle_text_insert( &mut self, focus: SurfaceFocus, text: &str ) { diff --git a/src/event_loop/text_editing/scroll.rs b/src/event_loop/text_editing/scroll.rs index 6797002..4bb0002 100644 --- a/src/event_loop/text_editing/scroll.rs +++ b/src/event_loop/text_editing/scroll.rs @@ -7,8 +7,9 @@ use crate::event_loop::surface::SurfaceFocus; use crate::tree::find_widget_at; use crate::types::Point; use crate::widget::text_edit::scroll::{ TextScroll, Window, WHEEL_LINES }; +use crate::platform::Platform; -impl AppData +impl AppData { pub( crate ) fn text_scroll( &self, focus: SurfaceFocus, idx: usize ) -> TextScroll { diff --git a/src/event_loop/text_editing/selection.rs b/src/event_loop/text_editing/selection.rs index 11edeac..e70bfe5 100644 --- a/src/event_loop/text_editing/selection.rs +++ b/src/event_loop/text_editing/selection.rs @@ -7,6 +7,7 @@ use crate::event_loop::surface::SurfaceFocus; use crate::tree::find_handlers; use crate::types::Point; use crate::widget::WidgetHandlers; +use crate::platform::Platform; /// Compute the byte range of the "word" surrounding `byte_offset` in /// `value`. A word is a maximal run of alphanumeric / underscore @@ -23,12 +24,9 @@ fn word_bounds_at( value: &str, byte_offset: usize ) -> ( usize, usize ) // Walk left from `bo` until we cross a non-word boundary. let mut start = bo; + for ( i, c ) in value[..bo].char_indices().rev() { - let mut iter = value[..bo].char_indices().rev(); - while let Some( ( i, c ) ) = iter.next() - { - if is_word( c ) { start = i; } else { break; } - } + if is_word( c ) { start = i; } else { break; } } // Walk right from `bo` until the same. @@ -41,7 +39,7 @@ fn word_bounds_at( value: &str, byte_offset: usize ) -> ( usize, usize ) ( start, end ) } -impl AppData +impl AppData { /// `true` when this press should be treated as the second click /// of a double-click pair. Looks at the timestamp + position of diff --git a/src/event_loop/tooltip.rs b/src/event_loop/tooltip.rs index 57683da..20a61c1 100644 --- a/src/event_loop/tooltip.rs +++ b/src/event_loop/tooltip.rs @@ -2,9 +2,10 @@ // Copyright (C) 2026 Liberux Labs, S. L. use super::app_data::AppData; -use super::surface::{ SurfaceFocus, SurfaceKind }; +use super::surface::SurfaceFocus; use crate::app::App; use crate::types::{ Length, Rect }; +use crate::platform::Platform; pub const TOOLTIP_DELAY: std::time::Duration = std::time::Duration::from_millis( 600 ); @@ -26,7 +27,7 @@ pub struct TooltipVisible pub text: String, } -impl AppData +impl AppData { pub( crate ) fn arm_tooltip( &mut self, focus: SurfaceFocus, flat_idx: usize ) { @@ -90,7 +91,7 @@ impl AppData pub( crate ) fn tooltip_popup( &self ) -> Option<( Rect, ( i32, i32 ) )> { let v = self.tooltip_visible.as_ref()?; - if v.focus != SurfaceFocus::Main || !matches!( self.main.surface, SurfaceKind::Window( _ ) ) { return None; } + if v.focus != SurfaceFocus::Main || !self.main_is_toplevel() { return None; } let ( w, h ) = tooltip_size( &v.text ); Some( ( v.anchor, ( w.ceil() as i32, h.ceil() as i32 ) ) ) } diff --git a/src/font_face.rs b/src/font_face.rs index fcd02ef..de21cab 100644 --- a/src/font_face.rs +++ b/src/font_face.rs @@ -405,7 +405,7 @@ impl Aabb fn finish( &self ) -> Option { - self.seen.then( || OutlineBounds + self.seen.then_some( OutlineBounds { xmin: self.xmin, ymin: self.ymin, diff --git a/src/gles_render/image.rs b/src/gles_render/image.rs index 86f0690..31ffd31 100644 --- a/src/gles_render/image.rs +++ b/src/gles_render/image.rs @@ -28,6 +28,8 @@ //! (`shared_image_cache`), so a surface that is torn down and recreated //! draws its images without uploading them again. +use std::collections::hash_map::Entry; + use glow::HasContext; use crate::render::helpers::fingerprint_rgba; @@ -128,7 +130,7 @@ impl ImageCache thread_local! { static SHARED_IMAGE_CACHES: std::cell::RefCell> )>> = - std::cell::RefCell::new( Vec::new() ); + const { std::cell::RefCell::new( Vec::new() ) }; } /// The cache for the context behind `gl`, created on first use. @@ -174,10 +176,11 @@ impl GlesCanvas let cache = std::rc::Rc::clone( &self.image_cache ); let mut cache = cache.borrow_mut(); - if !cache.map.contains_key( &cache_key ) + let cache = &mut *cache; + if let Entry::Vacant( vacant ) = cache.map.entry( cache_key ) { let tex = upload_rgba_texture( &self.gl, self.version, rgba_data, img_w as i32, img_h as i32 ); - cache.map.insert( cache_key, ( tex, img_w, img_h ) ); + vacant.insert( ( tex, img_w, img_h ) ); cache.bytes += ( img_w as usize ) * ( img_h as usize ) * 4; cache.lru.push_back( cache_key ); cache.evict_overflow( &self.gl ); diff --git a/src/gles_render/primitives.rs b/src/gles_render/primitives.rs index 286d4ab..1f74ae8 100644 --- a/src/gles_render/primitives.rs +++ b/src/gles_render/primitives.rs @@ -92,8 +92,7 @@ impl GlesCanvas { Some( w ) => { - let mut s = tiny_skia::Stroke::default(); - s.width = w; + let s = tiny_skia::Stroke { width: w, ..tiny_skia::Stroke::default() }; pixmap.stroke_path( &path, &paint, &s, tf, None ); } None => { pixmap.fill_path( &path, &paint, tiny_skia::FillRule::Winding, tf, None ); } diff --git a/src/gles_render/raii.rs b/src/gles_render/raii.rs index 2d8d2e1..78f9bd1 100644 --- a/src/gles_render/raii.rs +++ b/src/gles_render/raii.rs @@ -61,7 +61,7 @@ impl<'a> FboBinding<'a> } } -impl<'a> Drop for FboBinding<'a> +impl Drop for FboBinding<'_> { fn drop( &mut self ) { @@ -97,7 +97,7 @@ impl<'a> ProgramBinding<'a> } } -impl<'a> Drop for ProgramBinding<'a> +impl Drop for ProgramBinding<'_> { fn drop( &mut self ) { diff --git a/src/input/dispatch/coords.rs b/src/input/dispatch/coords.rs index dd84d2b..c506748 100644 --- a/src/input/dispatch/coords.rs +++ b/src/input/dispatch/coords.rs @@ -3,8 +3,9 @@ use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::platform::Platform; -impl AppData +impl AppData { /// Top-left of `focus` in main-surface (global) coordinates. Used to /// translate per-surface pointer coords into a single coordinate diff --git a/src/input/dispatch/outcomes.rs b/src/input/dispatch/outcomes.rs index fe872a3..e029177 100644 --- a/src/input/dispatch/outcomes.rs +++ b/src/input/dispatch/outcomes.rs @@ -3,10 +3,11 @@ use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::platform::Platform; use crate::input::gesture::{ MoveOutcome, ReleaseEvent }; -impl AppData +impl AppData { /// Apply the outcome of a motion event. Non-blocking side-effects /// only: push messages, call app callbacks, set redraw flags. @@ -117,10 +118,7 @@ impl AppData if self.app.subsurface_motion_only() { if self.main.frame_pending { return; } - let Some( wl ) = self.main.surface.try_wl_surface().cloned() else { return }; - let _ = wl.frame( &self.qh, SurfaceFocus::Main ); - wl.commit(); - self.main.frame_pending = true; + if self.platform.request_frame( &self.main.platform, SurfaceFocus::Main ) { self.main.frame_pending = true; } return; } self.dirty_caches(); diff --git a/src/input/dispatch/password_toggle.rs b/src/input/dispatch/password_toggle.rs index af17588..d0a1d6c 100644 --- a/src/input/dispatch/password_toggle.rs +++ b/src/input/dispatch/password_toggle.rs @@ -5,8 +5,9 @@ use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus }; use crate::types::Point; use crate::widget::WidgetHandlers; +use crate::platform::Platform; -impl AppData +impl AppData { /// If the press at `pos` lands on a password-toggle icon zone of /// the widget at `idx`, push the toggle message and return diff --git a/src/input/event.rs b/src/input/event.rs new file mode 100644 index 0000000..fd32215 --- /dev/null +++ b/src/input/event.rs @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Platform-neutral input events: what a platform backend hands the runtime. +//! +//! A backend turns what its input system reports into these types and passes them to the runtime through a small set of entry points on `AppData`: +//! +//! | Source | Entry points | +//! | --- | --- | +//! | Mouse, touchpad | `pointer_input( &PointerInput )` | +//! | Fingers | `touch_input( &TouchInput )`, and `reset_touch_state()` when the platform cancels the touch sequence | +//! | Keyboard | `keyboard_enter( focus )`, `keyboard_leave()`, `key_press( KeyInput )`, `key_release( &KeyInput )`, `key_repeated( KeyInput )`, `modifiers_changed( ctrl, shift, alt )`, `set_repeat_info( rate, delay )` | +//! +//! Everything past those entry points is shared by every backend: hover and focus, the gesture machine (tap, long-press, drag, scroll, swipe, fling), text editing, the context menu, the built-in on-screen keyboard, back handling, key repeat and every `App` input callback. `platform/wayland/input.rs` is the Wayland backend's side and the reference for a new one; it carries no input logic. +//! +//! The types carry only what that shared logic reads. What a protocol needs for its own requests stays with the backend, which records it before it forwards the event: Wayland's serials, which a window move, a selection or a cursor shape quote back to the compositor, are the example. +//! +//! # Units +//! +//! - **Positions** are in the **logical** coordinates of the surface the event belongs to, as a compositor reports them. The handlers scale them to physical pixels with the surface's scale factor, so a backend whose platform reports physical pixels divides by that factor first. +//! - **Time** is in milliseconds on the platform's monotonic input clock. Only the difference between two events is read (fling velocity), so the clock's origin does not matter. +//! - **Scroll distance** is in logical pixels, positive downwards and rightwards, as `wl_pointer.axis` reports it. +//! +//! # Surfaces +//! +//! Pointer events and the first event of each finger name the [`SurfaceFocus`] they land on. A backend with a single surface always passes `SurfaceFocus::Main`. Later events of a finger go to the surface it landed on, wherever it moves, so they carry no focus. The keyboard's focus is set by `keyboard_enter`. + +use crate::app::{ MouseButton, RawKey }; +use crate::event_loop::SurfaceFocus; +use crate::Keysym; + +/// A key pressed, released or repeated. +#[ derive( Debug, Clone, PartialEq ) ] +pub( crate ) struct KeyInput +{ + pub keysym: Keysym, + /// The physical key, in the platform's own numbering. A press and its release carry the same code even when a modifier changed the keysym in between, as Shift does to a letter. + pub keycode: u32, + /// What the key types once dead keys and compose sequences are applied. `None` or empty for a key that types nothing. + pub text: Option, +} + +impl KeyInput +{ + /// A key with no physical counterpart, such as one tapped on the built-in on-screen keyboard. + pub( crate ) fn synthetic( keysym: Keysym, text: Option ) -> Self + { + Self { keysym, keycode: 0, text } + } + + /// The key as [`App::on_raw_key_event`](crate::App::on_raw_key_event) reports it, with the modifiers held. A release types nothing. + pub( crate ) fn raw( &self, pressed: bool, ctrl: bool, shift: bool, alt: bool ) -> RawKey + { + RawKey + { + keysym: self.keysym, + keycode: self.keycode, + pressed, + ctrl, + shift, + alt, + text: self.text.clone().filter( |text| pressed && !text.is_empty() ), + } + } +} + +/// One event from a mouse or a touchpad. +#[ derive( Debug, Clone, Copy, PartialEq ) ] +pub( crate ) struct PointerInput +{ + pub focus: SurfaceFocus, + /// Where the pointer is, in logical coordinates of `focus`. + pub position: ( f64, f64 ), + pub kind: PointerKind, +} + +#[ derive( Debug, Clone, Copy, PartialEq ) ] +pub( crate ) enum PointerKind +{ + /// The pointer came onto the surface. The runtime then sets the cursor's shape, so a backend that must claim the cursor first does so before forwarding it. + Enter, + Leave, + Motion, + Press( PointerButton ), + Release( PointerButton ), + /// A wheel or a touchpad scrolled. + Axis + { + horizontal: Axis, + vertical: Axis, + source: AxisSource, + }, +} + +#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] +pub( crate ) enum PointerButton +{ + Left, + Right, + Middle, + /// The side button that means back, `BTN_SIDE` or `BTN_BACK` on Linux. + Back, + /// Any other button. The runtime ignores it. + Other, +} + +impl PointerButton +{ + /// The button as [`App::on_mouse_button`](crate::App::on_mouse_button) reports it, which knows only the three main ones. + pub( crate ) fn mouse_button( self ) -> Option + { + match self + { + PointerButton::Left => Some( MouseButton::Left ), + PointerButton::Right => Some( MouseButton::Right ), + PointerButton::Middle => Some( MouseButton::Middle ), + _ => None, + } + } +} + +/// Scroll along one axis. +#[ derive( Debug, Clone, Copy, PartialEq, Default ) ] +pub( crate ) struct Axis +{ + /// In logical pixels, positive downwards or rightwards. + pub distance: f64, + /// Wheel detents, fractional for a high-resolution wheel. `0.0` for a touchpad and any other source without detents. + pub notches: f32, +} + +#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] +pub( crate ) enum AxisSource +{ + Wheel, + /// A touchpad, or another source that scrolls continuously. + Continuous, + /// A tilted wheel, or a source the platform does not name. + Other, +} + +impl AxisSource +{ + /// What a viewport multiplies [`Axis::distance`] by: a wheel reports a few pixels per detent, which alone would scroll a page far too slowly. + pub( crate ) fn scroll_factor( self ) -> f32 + { + match self + { + AxisSource::Wheel => 10.0, + _ => 1.0, + } + } +} + +/// One finger's event. +#[ derive( Debug, Clone, Copy, PartialEq ) ] +pub( crate ) struct TouchInput +{ + /// Names the finger from its down to its up. The platform may give the same id to a later finger. + pub id: i32, + /// Milliseconds on the platform's monotonic input clock. + pub time: u32, + pub phase: TouchPhase, +} + +#[ derive( Debug, Clone, Copy, PartialEq ) ] +pub( crate ) enum TouchPhase +{ + /// The finger landed on `focus`, at `position` in its logical coordinates. + Down + { + focus: SurfaceFocus, + position: ( f64, f64 ), + }, + /// In logical coordinates of the surface the finger landed on. + Motion + { + position: ( f64, f64 ), + }, + /// The finger lifted where its last motion left it. + Up, +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + fn key( text: Option<&str> ) -> KeyInput + { + KeyInput { keysym: Keysym::a, keycode: 38, text: text.map( String::from ) } + } + + #[ test ] + fn a_pressed_key_reports_what_it_types() + { + let raw = key( Some( "a" ) ).raw( true, false, false, true ); + assert_eq!( raw.keysym, Keysym::a ); + assert_eq!( raw.keycode, 38 ); + assert!( raw.pressed && raw.alt && !raw.ctrl && !raw.shift ); + assert_eq!( raw.text.as_deref(), Some( "a" ) ); + } + + #[ test ] + fn a_released_key_types_nothing() + { + let raw = key( Some( "a" ) ).raw( false, false, false, false ); + assert!( !raw.pressed ); + assert_eq!( raw.text, None ); + } + + #[ test ] + fn empty_text_is_no_text() + { + assert_eq!( key( Some( "" ) ).raw( true, false, false, false ).text, None ); + assert_eq!( key( None ).raw( true, false, false, false ).text, None ); + } + + #[ test ] + fn a_synthetic_key_has_no_physical_code() + { + let key = KeyInput::synthetic( Keysym::Left, None ); + assert_eq!( key.keycode, 0 ); + assert_eq!( key.raw( true, true, false, false ).keysym, Keysym::Left ); + } + + #[ test ] + fn only_the_three_main_buttons_reach_the_app() + { + assert_eq!( PointerButton::Left.mouse_button(), Some( MouseButton::Left ) ); + assert_eq!( PointerButton::Right.mouse_button(), Some( MouseButton::Right ) ); + assert_eq!( PointerButton::Middle.mouse_button(), Some( MouseButton::Middle ) ); + assert_eq!( PointerButton::Back.mouse_button(), None ); + assert_eq!( PointerButton::Other.mouse_button(), None ); + } + + #[ test ] + fn a_wheel_scrolls_further_than_a_touchpad() + { + assert_eq!( AxisSource::Wheel.scroll_factor(), 10.0 ); + assert_eq!( AxisSource::Continuous.scroll_factor(), 1.0 ); + assert_eq!( AxisSource::Other.scroll_factor(), 1.0 ); + } +} diff --git a/src/input/keyboard/back.rs b/src/input/keyboard/back.rs index e6eba0b..d39defa 100644 --- a/src/input/keyboard/back.rs +++ b/src/input/keyboard/back.rs @@ -9,13 +9,12 @@ //! ([`AppData::dismiss_transient`]): it cancels transient UI, never //! navigates and never leaves the app. -use smithay_client_toolkit::seat::keyboard::Keysym; -use smithay_client_toolkit::reexports::client::QueueHandle; - use crate::app::{ App, BackAtRoot, BackResponse }; -use crate::event_loop::{ AppData, SurfaceFocus, SurfaceKind }; +use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::platform::Platform; +use crate::Keysym; -impl AppData +impl AppData { /// Close the top-most piece of transient UI the runtime owns. Order: /// open xdg-popup overlays, the built-in context menu, an active text @@ -46,12 +45,12 @@ impl AppData false } - pub( crate ) fn handle_back( &mut self, focus: SurfaceFocus, qh: &QueueHandle ) + pub( crate ) fn handle_back( &mut self, focus: SurfaceFocus ) { if self.dismiss_transient( focus ) { return; } if self.focused_text_input( focus ) { - self.set_focus( focus, None, qh ); + self.set_focus( focus, None ); return; } self.back_into_app(); @@ -78,10 +77,10 @@ impl AppData fn back_at_root( &mut self ) { - let SurfaceKind::Window( ref window ) = self.main.surface else { return }; + if !self.platform.back_leaves( &self.main.platform ) { return; } match self.app.back_at_root() { - BackAtRoot::Minimize => window.set_minimized(), + BackAtRoot::Minimize => self.platform.minimize_window( &self.main.platform ), BackAtRoot::Close => { if self.app.on_close_requested() { self.exit_requested = true; } diff --git a/src/input/keyboard/dispatch.rs b/src/input/keyboard/dispatch.rs index 56ab698..6175f4d 100644 --- a/src/input/keyboard/dispatch.rs +++ b/src/input/keyboard/dispatch.rs @@ -1,23 +1,20 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use smithay_client_toolkit::seat::keyboard::{ KeyEvent, Keysym }; - use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::Keysym; +use crate::platform::Platform; -impl AppData +use super::super::event::KeyInput; + +impl AppData { - /// Run the same dispatch logic that the Wayland press-key handler - /// runs, but without the trait-callback signature — used both by - /// the trait method and by the key-repeat timer. - pub( crate ) fn dispatch_key( &mut self, focus: SurfaceFocus, event: KeyEvent ) + /// What a key does to the focused widget or the app, shared by a + /// press, the runtime's repeat timer, the platform's own repeat and + /// the built-in on-screen keyboard. + pub( crate ) fn dispatch_key( &mut self, focus: SurfaceFocus, event: KeyInput ) { - // `set_focus` (Tab handling) needs a `QueueHandle`. Cloning is - // cheap (an `Arc`-equivalent under the hood) and avoids - // threading the qh through every helper. - let qh = self.qh.clone(); - let qh = &qh; match event.keysym { Keysym::BackSpace => self.handle_key_backspace( focus, &event ), @@ -31,9 +28,9 @@ impl AppData Keysym::c | Keysym::C if self.ctrl_pressed => self.handle_key_ctrl_c( focus ), Keysym::x | Keysym::X if self.ctrl_pressed => self.handle_key_ctrl_x( focus ), Keysym::v | Keysym::V if self.ctrl_pressed => self.handle_key_ctrl_v( focus ), - Keysym::Tab | Keysym::ISO_Left_Tab => self.handle_key_tab( focus, &event, qh ), - Keysym::Escape => self.handle_key_escape( focus, &event, qh ), - Keysym::XF86_Back => self.handle_back( focus, qh ), + Keysym::Tab | Keysym::ISO_Left_Tab => self.handle_key_tab( focus, &event ), + Keysym::Escape => self.handle_key_escape( focus, &event ), + Keysym::XF86_Back => self.handle_back( focus ), Keysym::space => self.handle_key_space( focus, &event ), _ => self.handle_key_default( focus, &event ), } diff --git a/src/input/keyboard/mod.rs b/src/input/keyboard/mod.rs index 5c507d3..877107a 100644 --- a/src/input/keyboard/mod.rs +++ b/src/input/keyboard/mod.rs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -//! Wayland keyboard → ltk dispatch. +//! Keyboard → ltk dispatch. //! -//! Translates `wl_keyboard` events into focus / text-insertion / +//! Translates [`KeyInput`] events into focus / text-insertion / //! widget-submit actions. Does not share state with the gesture state //! machine — keyboard tracks its own focus (`AppData::keyboard_focus`) //! and modifier flags (`shift_pressed`, `ctrl_pressed`, `alt_pressed`). @@ -13,18 +13,12 @@ //! branch on "is a widget focused?" — Return submits / Space presses / //! typing inserts all funnel through that check. -use smithay_client_toolkit::seat::keyboard:: -{ - KeyboardHandler, KeyEvent, Keysym, Modifiers, RawModifiers, RepeatInfo, -}; -use smithay_client_toolkit::reexports::client:: -{ - protocol::{ wl_keyboard::WlKeyboard, wl_surface::WlSurface }, - Connection, QueueHandle, -}; - -use crate::app::{ App, RawKey }; +use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::Keysym; +use crate::platform::Platform; + +use super::event::KeyInput; pub( super ) mod back; pub( super ) mod dispatch; @@ -32,51 +26,18 @@ pub( super ) mod text_keys; pub( super ) mod shortcuts; pub( super ) mod nav; -impl AppData +impl AppData { - fn raw_key( &self, event: &KeyEvent, pressed: bool ) -> RawKey + /// The keyboard focus moved onto `focus`. + pub( crate ) fn keyboard_enter( &mut self, focus: SurfaceFocus ) { - RawKey - { - keysym: event.keysym, - keycode: event.raw_code, - pressed, - ctrl: self.ctrl_pressed, - shift: self.shift_pressed, - alt: self.alt_pressed, - text: event.utf8.clone().filter( |text| pressed && !text.is_empty() ), - } - } -} - -impl KeyboardHandler for AppData -{ - fn enter( - &mut self, - _conn: &Connection, - _qh: &QueueHandle, - _keyboard: &WlKeyboard, - surface: &WlSurface, - _serial: u32, - _raw: &[u32], - _keysyms: &[Keysym], - ) - { - let focus = self.focus_for_surface( surface ).unwrap_or( SurfaceFocus::Main ); - let _ = surface; self.keyboard_focus = focus; self.surface_mut( focus ).request_redraw(); if let Some( ref mut a ) = self.a11y { a.set_window_focus( true ); } } - fn leave( - &mut self, - _conn: &Connection, - _qh: &QueueHandle, - _keyboard: &WlKeyboard, - _surface: &WlSurface, - _serial: u32, - ) + /// The keyboard focus left every surface of the app. + pub( crate ) fn keyboard_leave( &mut self ) { self.stop_key_repeat(); self.keyboard_focus = SurfaceFocus::Main; @@ -84,117 +45,58 @@ impl KeyboardHandler for AppData if let Some( ref mut a ) = self.a11y { a.set_window_focus( false ); } } - fn press_key( - &mut self, - _conn: &Connection, - _qh: &QueueHandle, - _keyboard: &WlKeyboard, - serial: u32, - event: KeyEvent, - ) + /// A key went down on the surface with the keyboard focus. Starts the runtime's own repeat for it. + pub( crate ) fn key_press( &mut self, key: KeyInput ) { - self.last_input_serial = serial; let focus = self.keyboard_focus; - // Raw observer hook (e.g. forwarding to an embedded WPE view). - // Fires before the focus-aware dispatch. Back is a navigation - // request, not input for the embedded system. - if event.keysym != Keysym::XF86_Back + // Back is a navigation request, not input for an embedded system. + if key.keysym != Keysym::XF86_Back { - let key = self.raw_key( &event, true ); - self.app.on_raw_key_event( &key ); + let raw = key.raw( true, self.ctrl_pressed, self.shift_pressed, self.alt_pressed ); + self.app.on_raw_key_event( &raw ); } - // A new press always cancels any in-flight repeat — the user - // has either released the previous key or is pressing a - // different one. Either way, the prior timer's keysym should - // not keep firing. self.stop_key_repeat(); - if event.keysym != Keysym::XF86_Back && self.app.claims_keyboard() { return; } - self.dispatch_key( focus, event.clone() ); - self.start_key_repeat( focus, event ); + if key.keysym != Keysym::XF86_Back && self.app.claims_keyboard() { return; } + self.dispatch_key( focus, key.clone() ); + self.start_key_repeat( focus, key ); } - fn release_key( - &mut self, - _conn: &Connection, - _qh: &QueueHandle, - _keyboard: &WlKeyboard, - _serial: u32, - event: KeyEvent, - ) + pub( crate ) fn key_release( &mut self, key: &KeyInput ) { - if event.keysym != Keysym::XF86_Back + if key.keysym != Keysym::XF86_Back { - let key = self.raw_key( &event, false ); - self.app.on_raw_key_event( &key ); + let raw = key.raw( false, self.ctrl_pressed, self.shift_pressed, self.alt_pressed ); + self.app.on_raw_key_event( &raw ); } - // Only stop if the released key matches the one currently - // repeating; releasing a non-repeating key (e.g. a shift that - // snuck through, or any key we never armed) leaves the timer - // untouched. - if let Some( ref state ) = self.key_repeat + // Releasing a key other than the repeating one, a modifier for + // instance, leaves the repeat running. + if self.key_repeat.as_ref().is_some_and( |state| state.event.keysym == key.keysym ) { - if state.event.keysym == event.keysym - { - self.stop_key_repeat(); - } + self.stop_key_repeat(); } } - fn update_modifiers( - &mut self, - _conn: &Connection, - _qh: &QueueHandle, - _keyboard: &WlKeyboard, - _serial: u32, - modifiers: Modifiers, - _raw_modifiers: RawModifiers, - _layout: u32, - ) + /// A repeat the platform generated itself. The runtime's own repeat stops so the key does not fire twice. + pub( crate ) fn key_repeated( &mut self, key: KeyInput ) { - self.shift_pressed = modifiers.shift; - self.ctrl_pressed = modifiers.ctrl; - self.alt_pressed = modifiers.alt; - self.app.on_modifiers( modifiers.ctrl, modifiers.shift, modifiers.alt ); - } - - fn update_repeat_info( - &mut self, - _conn: &Connection, - _qh: &QueueHandle, - _keyboard: &WlKeyboard, - info: RepeatInfo, - ) - { - match info - { - RepeatInfo::Repeat { rate, delay } => - { - self.compositor_repeat_rate = rate.get(); - self.compositor_repeat_delay = delay; - } - RepeatInfo::Disable => - { - self.compositor_repeat_rate = 0; - self.compositor_repeat_delay = 0; - self.stop_key_repeat(); - } - } - } - - fn repeat_key( - &mut self, - _conn: &Connection, - _qh: &QueueHandle, - _keyboard: &WlKeyboard, - _serial: u32, - event: KeyEvent, - ) - { - // Compositor-driven repeat (wl_keyboard v10). When the - // compositor takes the repeat job we just re-dispatch — and - // suppress our internal timer to avoid double-firing. self.stop_key_repeat(); let focus = self.keyboard_focus; - self.dispatch_key( focus, event ); + self.dispatch_key( focus, key ); + } + + pub( crate ) fn modifiers_changed( &mut self, ctrl: bool, shift: bool, alt: bool ) + { + self.shift_pressed = shift; + self.ctrl_pressed = ctrl; + self.alt_pressed = alt; + self.app.on_modifiers( ctrl, shift, alt ); + } + + /// The platform's key repeat: `rate` keys a second after `delay` milliseconds. A `rate` of 0 means the platform asked for no repeat. + pub( crate ) fn set_repeat_info( &mut self, rate: u32, delay: u32 ) + { + self.compositor_repeat_rate = rate; + self.compositor_repeat_delay = delay; + if rate == 0 { self.stop_key_repeat(); } } } diff --git a/src/input/keyboard/nav.rs b/src/input/keyboard/nav.rs index 6354744..f4252f4 100644 --- a/src/input/keyboard/nav.rs +++ b/src/input/keyboard/nav.rs @@ -4,8 +4,9 @@ use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus }; use crate::widget::scroll::offset_into_view; +use crate::platform::Platform; -impl AppData +impl AppData { /// Step the topmost scroll's `hovered_idx` one item up or down with /// the keyboard. Returns `true` when the move was applied (a scroll diff --git a/src/input/keyboard/shortcuts.rs b/src/input/keyboard/shortcuts.rs index 95dba20..bea19b5 100644 --- a/src/input/keyboard/shortcuts.rs +++ b/src/input/keyboard/shortcuts.rs @@ -1,14 +1,15 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use smithay_client_toolkit::seat::keyboard::{ KeyEvent, Keysym }; -use smithay_client_toolkit::reexports::client::QueueHandle; - use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus }; use crate::tree::{ find_handlers, next_focusable_index }; +use crate::Keysym; +use crate::platform::Platform; -impl AppData +use super::super::event::KeyInput; + +impl AppData { pub( super ) fn handle_key_ctrl_a( &mut self, focus: SurfaceFocus ) { @@ -46,7 +47,7 @@ impl AppData if is_text { self.handle_paste( focus ); } } - pub( super ) fn handle_key_tab( &mut self, focus: SurfaceFocus, event: &KeyEvent, qh: &QueueHandle ) + pub( super ) fn handle_key_tab( &mut self, focus: SurfaceFocus, event: &KeyInput ) { let reverse = event.keysym == Keysym::ISO_Left_Tab || self.shift_pressed; let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed ); @@ -60,17 +61,17 @@ impl AppData let next_idx = next_focusable_index( &ss.frame.widget_rects, ss.focused_idx, reverse ); if let Some( next_idx ) = next_idx { - self.set_focus( focus, Some( next_idx ), qh ); + self.set_focus( focus, Some( next_idx ) ); } } } - pub( super ) fn handle_key_escape( &mut self, focus: SurfaceFocus, event: &KeyEvent, qh: &QueueHandle ) + pub( super ) fn handle_key_escape( &mut self, focus: SurfaceFocus, event: &KeyInput ) { // Esc peels off transient UI one layer at a time; with none // left it drops focus and lets the app see Esc. if self.dismiss_transient( focus ) { return; } - self.set_focus( focus, None, qh ); + self.set_focus( focus, None ); if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed ) { self.pending_msgs.push( msg ); diff --git a/src/input/keyboard/text_keys.rs b/src/input/keyboard/text_keys.rs index 765910e..a3963d3 100644 --- a/src/input/keyboard/text_keys.rs +++ b/src/input/keyboard/text_keys.rs @@ -1,13 +1,15 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use smithay_client_toolkit::seat::keyboard::{ KeyEvent, Keysym }; - use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus }; use crate::tree::find_handlers; +use crate::Keysym; +use crate::platform::Platform; -impl AppData +use super::super::event::KeyInput; + +impl AppData { /// Whether the focused widget is an editable text input; any other /// focused widget lets text keys bubble to the app. @@ -19,7 +21,7 @@ impl AppData .is_some_and( |h| h.is_text_input() ) } - pub( super ) fn handle_key_backspace( &mut self, focus: SurfaceFocus, event: &KeyEvent ) + pub( super ) fn handle_key_backspace( &mut self, focus: SurfaceFocus, event: &KeyInput ) { if self.focused_text_input( focus ) { self.handle_backspace( focus ); } else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed ) @@ -28,7 +30,7 @@ impl AppData } } - pub( super ) fn handle_key_delete( &mut self, focus: SurfaceFocus, event: &KeyEvent ) + pub( super ) fn handle_key_delete( &mut self, focus: SurfaceFocus, event: &KeyInput ) { // Supr / Forward-Delete: same shape as Backspace but // removes the char *after* the cursor. @@ -39,7 +41,7 @@ impl AppData } } - pub( super ) fn handle_key_return( &mut self, focus: SurfaceFocus, event: &KeyEvent ) + pub( super ) fn handle_key_return( &mut self, focus: SurfaceFocus, event: &KeyInput ) { let focused = self.surface( focus ).focused_idx; // Enter targets, in order: (a) the focused widget's submit @@ -82,7 +84,7 @@ impl AppData } } - pub( super ) fn handle_key_arrow_vertical( &mut self, focus: SurfaceFocus, event: &KeyEvent ) + pub( super ) fn handle_key_arrow_vertical( &mut self, focus: SurfaceFocus, event: &KeyInput ) { let is_text = self.focused_text_input( focus ); let reverse = event.keysym == Keysym::Up; @@ -106,7 +108,7 @@ impl AppData } } - pub( super ) fn handle_key_arrow_horizontal( &mut self, focus: SurfaceFocus, event: &KeyEvent ) + pub( super ) fn handle_key_arrow_horizontal( &mut self, focus: SurfaceFocus, event: &KeyInput ) { let is_text = self.focused_text_input( focus ); let extend = self.shift_pressed; @@ -136,7 +138,7 @@ impl AppData if self.focused_text_input( focus ) { self.handle_cursor_end( focus, self.shift_pressed ); } } - pub( super ) fn handle_key_space( &mut self, focus: SurfaceFocus, event: &KeyEvent ) + pub( super ) fn handle_key_space( &mut self, focus: SurfaceFocus, event: &KeyInput ) { let ss = self.surface( focus ); let press = ss.focused_idx @@ -153,7 +155,7 @@ impl AppData } } - pub( super ) fn handle_key_default( &mut self, focus: SurfaceFocus, event: &KeyEvent ) + pub( super ) fn handle_key_default( &mut self, focus: SurfaceFocus, event: &KeyInput ) { if self.ctrl_pressed { @@ -163,7 +165,7 @@ impl AppData } } else if self.focused_text_input( focus ) { - if let Some( txt ) = event.utf8.clone() + if let Some( txt ) = event.text.clone() { if !txt.is_empty() && txt.chars().all( |c| !c.is_control() ) { diff --git a/src/input/mod.rs b/src/input/mod.rs index 92338dc..9db12c3 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -3,9 +3,7 @@ //! Input-event layer. //! -//! Three Wayland input sources — `wl_keyboard`, `wl_pointer`, -//! `wl_touch` — land in their respective submodules and translate -//! into ltk-level actions. Keyboard is standalone (it drives focus +//! A platform backend turns its input system's events into the platform-neutral types of [`event`] and hands them to the entry points that module lists; `platform/wayland/input.rs` does so for `wl_keyboard`, `wl_pointer` and `wl_touch`. From there the three sources land in their respective submodules and translate into ltk-level actions. Keyboard is standalone (it drives focus //! and text insertion, no gesture lifecycle); pointer and touch share //! the [`gesture::GestureState`] state machine so press → move → //! release logic (long-press, slider drag, scroll, swipe commit, tap, @@ -18,10 +16,11 @@ //! without exposing the helpers at the crate root. //! //! With [`GestureState`] owning the press / move / release lifecycle -//! and [`dispatch`] owning the side-effects, the Wayland-facing +//! and [`dispatch`] owning the side-effects, the backend-facing //! handlers stay small and a hypothetical new input source (stylus, //! gamepad) can plug in by feeding the state machine the same way. +pub( crate ) mod event; pub( crate ) mod gesture; pub( crate ) mod keyboard; pub( crate ) mod pointer; diff --git a/src/input/pointer/enter.rs b/src/input/pointer/enter.rs index bddf24f..0b904c5 100644 --- a/src/input/pointer/enter.rs +++ b/src/input/pointer/enter.rs @@ -1,43 +1,17 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use smithay_client_toolkit::seat::pointer::{ PointerEvent, PointerEventKind }; -use smithay_client_toolkit::reexports::client:: -{ - protocol::wl_pointer::WlPointer, - Connection, QueueHandle, -}; - use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::platform::Platform; -impl AppData +impl AppData { - pub( super ) fn on_pointer_enter( - &mut self, - _conn: &Connection, - _qh: &QueueHandle, - _pointer: &WlPointer, - event: &PointerEvent, - ) + pub( super ) fn on_pointer_enter( &mut self, focus: SurfaceFocus ) { - let focus = self.focus_for_surface( &event.surface ) - .unwrap_or( SurfaceFocus::Main ); - if let PointerEventKind::Enter { serial } = event.kind - { - // Pointer just entered our surface — capture the - // serial that wp_cursor_shape_device_v1::set_shape - // requires, then push the initial cursor shape - // *unconditionally*. Resetting `current_cursor_shape` - // to `None` is what forces the dispatch: the - // compositor was showing whatever the previous - // client asked for (e.g. a text I-beam from a - // terminal under our window), and we must claim - // the cursor for our surface even when the target - // equals what we last pushed. - self.last_pointer_enter_serial = serial; - self.current_cursor_shape = None; - self.dispatch_cursor_shape( focus ); - } + // The cursor shows whatever the previous surface asked for, so the + // shape is pushed even when it equals the one we last set. + self.current_cursor_shape = None; + self.dispatch_cursor_shape( focus ); } } diff --git a/src/input/pointer/helpers.rs b/src/input/pointer/helpers.rs index db83a7f..f71e164 100644 --- a/src/input/pointer/helpers.rs +++ b/src/input/pointer/helpers.rs @@ -4,13 +4,14 @@ use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus }; use crate::tree::find_handlers; +use crate::platform::Platform; use super::super::gesture::SwipeConfig; /// Pointer-side helpers on `AppData`. Split out so touch can call the /// same swipe-config factory; `apply_*` helpers live in `dispatch.rs` /// because they are shared. -impl AppData +impl AppData { /// Snapshot the swipe thresholds + surface dimensions into a /// [`SwipeConfig`] for the gesture machine. Called once per diff --git a/src/input/pointer/mod.rs b/src/input/pointer/mod.rs index 57e23c6..0372eb4 100644 --- a/src/input/pointer/mod.rs +++ b/src/input/pointer/mod.rs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -//! Wayland pointer → ltk dispatch. +//! Pointer → ltk dispatch. //! -//! Each `wl_pointer` event (motion / press / release / axis) translates +//! Each [`PointerInput`] (motion / press / release / axis) translates //! into a call on the surface's [`GestureState`](super::gesture::GestureState) //! plus the follow-up side-effects that can only live at the //! [`AppData`] level (pending-message push, surface redraw, dirty-cache @@ -22,15 +22,11 @@ //! `scroll_offsets` map; axis events do not have a press/release //! lifecycle so they bypass the state machine entirely. -use smithay_client_toolkit::seat::pointer::{ PointerEvent, PointerEventKind, PointerHandler }; -use smithay_client_toolkit::reexports::client:: -{ - protocol::wl_pointer::WlPointer, - Connection, QueueHandle, -}; - use crate::app::App; -use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::event_loop::AppData; +use crate::platform::Platform; + +use super::event::{ PointerButton, PointerInput, PointerKind }; pub( crate ) mod enter; pub( crate ) mod motion; @@ -39,60 +35,39 @@ pub( crate ) mod release; pub( crate ) mod scroll; pub( crate ) mod helpers; -impl PointerHandler for AppData +impl AppData { - fn pointer_frame( - &mut self, - conn: &Connection, - qh: &QueueHandle, - pointer: &WlPointer, - events: &[PointerEvent], - ) + /// The entry point for a mouse or a touchpad. See [`super::event`]. + pub( crate ) fn pointer_input( &mut self, input: &PointerInput ) { - for event in events + self.pointer_focus = input.focus; + self.report_mouse_button( input ); + match input.kind { - if self.is_secondary_surface( &event.surface ) { continue; } - let focus = self.focus_for_surface( &event.surface ) - .unwrap_or( SurfaceFocus::Main ); - self.pointer_focus = focus; - self.report_mouse_button( focus, event ); - match event.kind - { - PointerEventKind::Enter { .. } => self.on_pointer_enter( conn, qh, pointer, event ), - PointerEventKind::Leave { .. } => self.app.on_pointer_hover( focus.target(), None ), - PointerEventKind::Motion { .. } => self.on_pointer_motion( conn, qh, pointer, event ), - PointerEventKind::Press { button: 0x110, .. } => self.on_pointer_left_press( conn, qh, pointer, event ), - PointerEventKind::Press { button: 0x111, .. } => self.on_pointer_right_click( conn, qh, pointer, event ), - PointerEventKind::Press { button: 0x112, .. } => self.on_pointer_middle_press( qh, event ), - // BTN_SIDE / BTN_BACK: the mouse's back button. - PointerEventKind::Press { button: 0x113 | 0x116, .. } => self.handle_back( focus, qh ), - PointerEventKind::Release { button: 0x110, .. } => self.on_pointer_release( conn, qh, pointer, event ), - PointerEventKind::Axis { .. } => self.on_pointer_axis( conn, qh, pointer, event ), - _ => {} - } + PointerKind::Enter => self.on_pointer_enter( input.focus ), + PointerKind::Leave => self.app.on_pointer_hover( input.focus.target(), None ), + PointerKind::Motion => self.on_pointer_motion( input ), + PointerKind::Press( PointerButton::Left ) => self.on_pointer_left_press( input ), + PointerKind::Press( PointerButton::Right ) => self.on_pointer_right_click( input ), + PointerKind::Press( PointerButton::Middle ) => self.on_pointer_middle_press( input ), + PointerKind::Press( PointerButton::Back ) => self.handle_back( input.focus ), + PointerKind::Release( PointerButton::Left ) => self.on_pointer_release( input ), + PointerKind::Axis { horizontal, vertical, source } => self.on_pointer_axis( input, horizontal, vertical, source ), + _ => {} } } -} -impl AppData -{ /// Hand a press or release of the left, middle or right button to [`App::on_mouse_button`]. - fn report_mouse_button( &mut self, focus: SurfaceFocus, event: &PointerEvent ) + fn report_mouse_button( &mut self, input: &PointerInput ) { - let ( code, pressed ) = match event.kind + let ( button, pressed ) = match input.kind { - PointerEventKind::Press { button, .. } => ( button, true ), - PointerEventKind::Release { button, .. } => ( button, false ), - _ => return, + PointerKind::Press( button ) => ( button, true ), + PointerKind::Release( button ) => ( button, false ), + _ => return, }; - let button = match code - { - 0x110 => crate::app::MouseButton::Left, - 0x111 => crate::app::MouseButton::Right, - 0x112 => crate::app::MouseButton::Middle, - _ => return, - }; - let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 ); + let Some( button ) = button.mouse_button() else { return }; + let pos = self.surface( input.focus ).to_physical( input.position.0, input.position.1 ); self.app.on_mouse_button( &crate::app::MouseButtonEvent { button, diff --git a/src/input/pointer/motion.rs b/src/input/pointer/motion.rs index 333688c..50e7b66 100644 --- a/src/input/pointer/motion.rs +++ b/src/input/pointer/motion.rs @@ -1,32 +1,20 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use smithay_client_toolkit::seat::pointer::PointerEvent; -use smithay_client_toolkit::reexports::client:: -{ - protocol::wl_pointer::WlPointer, - Connection, QueueHandle, -}; - use crate::app::App; -use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::event_loop::AppData; use crate::tree::{ find_handlers, find_widget_at }; +use crate::platform::Platform; use super::helpers::hover_affects_paint; +use super::super::event::PointerInput; -impl AppData +impl AppData { - pub( super ) fn on_pointer_motion( - &mut self, - _conn: &Connection, - _qh: &QueueHandle, - _pointer: &WlPointer, - event: &PointerEvent, - ) + pub( super ) fn on_pointer_motion( &mut self, input: &PointerInput ) { - let focus = self.focus_for_surface( &event.surface ) - .unwrap_or( SurfaceFocus::Main ); - let pp = self.surface( focus ).to_physical( event.position.0, event.position.1 ); + let focus = input.focus; + let pp = self.surface( focus ).to_physical( input.position.0, input.position.1 ); self.pointer_pos = pp; self.app.on_pointer_move( pp.x, pp.y ); self.app.on_pointer_hover( focus.target(), Some( ( pp.x, pp.y ) ) ); diff --git a/src/input/pointer/press.rs b/src/input/pointer/press.rs index 1947e00..5ce9d11 100644 --- a/src/input/pointer/press.rs +++ b/src/input/pointer/press.rs @@ -1,30 +1,19 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use smithay_client_toolkit::seat::pointer::{ PointerEvent, PointerEventKind }; -use smithay_client_toolkit::reexports::client:: -{ - protocol::wl_pointer::WlPointer, - Connection, QueueHandle, -}; - use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::platform::Platform; use crate::tree::{ find_handlers, find_widget_at }; use crate::widget::WidgetHandlers; -impl AppData +use super::super::event::PointerInput; + +impl AppData { - pub( super ) fn on_pointer_right_click( - &mut self, - _conn: &Connection, - qh: &QueueHandle, - _pointer: &WlPointer, - event: &PointerEvent, - ) + pub( super ) fn on_pointer_right_click( &mut self, input: &PointerInput ) { - let focus = self.focus_for_surface( &event.surface ) - .unwrap_or( SurfaceFocus::Main ); + let focus = input.focus; // Right-click: the desktop equivalent of a touch // long-press. Three cases on the press target: // @@ -40,7 +29,7 @@ impl AppData // skip `handle_text_pointer_down`) so the common // "select text → right-click → Copy" flow works. // * Anywhere else — dismiss any already-open menu. - let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 ); + let pos = self.surface( focus ).to_physical( input.position.0, input.position.1 ); self.pointer_pos = pos; self.app.on_pointer_move( pos.x, pos.y ); let hit_idx = find_widget_at( &self.surface( focus ).frame.widget_rects, pos ); @@ -60,7 +49,7 @@ impl AppData let idx = hit_idx.unwrap(); if self.surface( focus ).focused_idx != Some( idx ) { - self.set_focus( focus, hit_idx, qh ); + self.set_focus( focus, hit_idx ); } self.show_context_menu( focus, idx, pos ); } else { @@ -71,44 +60,26 @@ impl AppData } /// Paste the primary selection into the text field under the pointer, or tell the app. - pub( super ) fn on_pointer_middle_press( &mut self, qh: &QueueHandle, event: &PointerEvent ) + pub( super ) fn on_pointer_middle_press( &mut self, input: &PointerInput ) { - let PointerEventKind::Press { serial, .. } = event.kind else { return }; - self.last_pointer_serial = serial; - self.last_input_serial = serial; - let focus = self.focus_for_surface( &event.surface ).unwrap_or( SurfaceFocus::Main ); - let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 ); + let focus = input.focus; + let pos = self.surface( focus ).to_physical( input.position.0, input.position.1 ); self.pointer_pos = pos; let rects = &self.surface( focus ).frame.widget_rects; let field = find_widget_at( rects, pos ).filter( |&idx| find_handlers( rects, idx ).is_some_and( |h| h.is_text_input() ) ); let Some( idx ) = field else { return self.app.on_pointer_middle( pos.x, pos.y ) }; self.press_on_focused = self.surface( focus ).focused_idx == Some( idx ); - if !self.press_on_focused { self.set_focus( focus, Some( idx ), qh ); } + if !self.press_on_focused { self.set_focus( focus, Some( idx ) ); } self.handle_text_pointer_down( focus, idx, pos ); - let text = self.primary.text.clone(); + let text = self.primary_selection.clone(); if !text.is_empty() { self.handle_text_insert( focus, &text ); } } - pub( super ) fn on_pointer_left_press( - &mut self, - _conn: &Connection, - qh: &QueueHandle, - _pointer: &WlPointer, - event: &PointerEvent, - ) + pub( super ) fn on_pointer_left_press( &mut self, input: &PointerInput ) { - let focus = self.focus_for_surface( &event.surface ) - .unwrap_or( SurfaceFocus::Main ); - let serial = if let PointerEventKind::Press { serial, .. } = event.kind - { - serial - } else { - return; - }; - self.last_pointer_serial = serial; - self.last_input_serial = serial; + let focus = input.focus; self.cancel_tooltip(); - let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 ); + let pos = self.surface( focus ).to_physical( input.position.0, input.position.1 ); self.pointer_pos = pos; self.app.on_pointer_move( pos.x, pos.y ); if matches!( focus, SurfaceFocus::Main ) && !self.overlays.is_empty() @@ -121,13 +92,7 @@ impl AppData // item activates (Copy / Cut / Paste) or the // click is outside the menu and dismisses it — // in both cases we consume the event. - if self.surface( focus ).context_menu.is_some() - { - if self.handle_context_menu_press( focus, pos ) - { - return; - } - } + if self.surface( focus ).context_menu.is_some() && self.handle_context_menu_press( focus, pos ) { return; } if self.osk_press( focus, pos ) { return; } // Client-side title bar interaction — pointer-only @@ -153,31 +118,13 @@ impl AppData // the titlebar) and over the gesture machine. if let Some( edge ) = self.resize_edge_under_pointer( focus ) { - if let crate::event_loop::SurfaceKind::Window( ref window ) = self.main.surface - { - let seats: Vec<_> = self.seat_state.seats().collect(); - if let Some( seat ) = seats.into_iter().next() - { - window.resize( &seat, serial, edge ); - } - } + self.platform.begin_window_resize( &self.main.platform, edge ); return; } if tb_h > 0.0 && pos.y < tb_h { - // Drag to move the window. - if matches!( focus, SurfaceFocus::Main ) - { - if let crate::event_loop::SurfaceKind::Window( ref window ) = self.main.surface - { - let seats: Vec<_> = self.seat_state.seats().collect(); - if let Some( seat ) = seats.into_iter().next() - { - window.move_( &seat, serial ); - } - } - } + if matches!( focus, SurfaceFocus::Main ) { self.platform.begin_window_move( &self.main.platform ); } return; } @@ -203,7 +150,7 @@ impl AppData result }; self.press_on_focused = outcome.hit_idx.is_some() && self.surface( focus ).focused_idx == outcome.hit_idx; - self.set_focus( focus, outcome.hit_idx, qh ); + self.set_focus( focus, outcome.hit_idx ); self.osk_after_press( focus, outcome.hit_idx ); if let Some( msg ) = outcome.initial_slider_msg { diff --git a/src/input/pointer/release.rs b/src/input/pointer/release.rs index 082d766..4768a19 100644 --- a/src/input/pointer/release.rs +++ b/src/input/pointer/release.rs @@ -1,29 +1,18 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use smithay_client_toolkit::seat::pointer::PointerEvent; -use smithay_client_toolkit::reexports::client:: -{ - protocol::wl_pointer::WlPointer, - Connection, QueueHandle, -}; - use crate::app::App; -use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::event_loop::AppData; +use crate::platform::Platform; -impl AppData +use super::super::event::PointerInput; + +impl AppData { - pub( super ) fn on_pointer_release( - &mut self, - _conn: &Connection, - _qh: &QueueHandle, - _pointer: &WlPointer, - event: &PointerEvent, - ) + pub( super ) fn on_pointer_release( &mut self, input: &PointerInput ) { - let focus = self.focus_for_surface( &event.surface ) - .unwrap_or( SurfaceFocus::Main ); - let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 ); + let focus = input.focus; + let pos = self.surface( focus ).to_physical( input.position.0, input.position.1 ); self.pointer_pos = pos; self.app.on_pointer_move( pos.x, pos.y ); self.app.on_pointer_button( pos.x, pos.y, false ); diff --git a/src/input/pointer/scroll.rs b/src/input/pointer/scroll.rs index c861b1e..1a5e69f 100644 --- a/src/input/pointer/scroll.rs +++ b/src/input/pointer/scroll.rs @@ -1,42 +1,20 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use smithay_client_toolkit::seat::pointer::{ PointerEvent, PointerEventKind }; -use smithay_client_toolkit::reexports::client:: -{ - protocol::{ wl_pointer, wl_pointer::WlPointer }, - Connection, QueueHandle, -}; - use crate::app::App; -use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::event_loop::AppData; +use crate::platform::Platform; -impl AppData +use super::super::event::{ Axis, AxisSource, PointerInput }; + +impl AppData { - pub( super ) fn on_pointer_axis( - &mut self, - _conn: &Connection, - _qh: &QueueHandle, - _pointer: &WlPointer, - event: &PointerEvent, - ) + pub( super ) fn on_pointer_axis( &mut self, input: &PointerInput, horizontal: Axis, vertical: Axis, source: AxisSource ) { - let focus = self.focus_for_surface( &event.surface ) - .unwrap_or( SurfaceFocus::Main ); - let ( horizontal, vertical, source ) = if let PointerEventKind::Axis { horizontal, vertical, source, .. } = event.kind - { - ( horizontal, vertical, source ) - } else { - return; - }; - let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 ); - let notches = if vertical.value120 != 0 - { - vertical.value120 as f32 / 120.0 - } else { - vertical.discrete as f32 - }; - if self.scroll_text_under( focus, pos, notches, vertical.absolute as f32 ) { return; } + let focus = input.focus; + let pos = self.surface( focus ).to_physical( input.position.0, input.position.1 ); + let notches = vertical.notches; + if self.scroll_text_under( focus, pos, notches, vertical.distance as f32 ) { return; } let scroll_hit = { let ss = self.surface( focus ); @@ -46,13 +24,9 @@ impl AppData }; if let Some( ( scroll_idx, axis ) ) = scroll_hit { - let multiplier = match source - { - Some( wl_pointer::AxisSource::Wheel ) => 10.0, - _ => 1.0, - }; - let step_x = horizontal.absolute as f32 * multiplier; - let step_y = vertical.absolute as f32 * multiplier; + let multiplier = source.scroll_factor(); + let step_x = horizontal.distance as f32 * multiplier; + let step_y = vertical.distance as f32 * multiplier; let ss = self.surface_mut( focus ); let entry = ss.frame.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) ); // Wheels report on a single axis at a time; route to @@ -82,22 +56,13 @@ impl AppData // surface the event to the app so embedded content // (e.g. a WPE view) can handle scrolling itself: // discrete detents first, then the raw axis. - let notches_x = if horizontal.value120 != 0 - { - horizontal.value120 as f32 / 120.0 - } else { - horizontal.discrete as f32 - }; + let notches_x = horizontal.notches; let consumed = ( notches != 0.0 || notches_x != 0.0 ) && self.app.on_pointer_wheel( pos.x, pos.y, notches_x, notches ); if !consumed { - let multiplier = match source - { - Some( wl_pointer::AxisSource::Wheel ) => 10.0, - _ => 1.0, - }; - let dx = horizontal.absolute as f32 * multiplier; - let dy = vertical.absolute as f32 * multiplier; + let multiplier = source.scroll_factor(); + let dx = horizontal.distance as f32 * multiplier; + let dy = vertical.distance as f32 * multiplier; self.app.on_pointer_axis( pos.x, pos.y, dx, dy ); } // The hook may mutate view-driving state (a wheel-stepped diff --git a/src/input/repeat/button.rs b/src/input/repeat/button.rs index d78bd28..d8908bf 100644 --- a/src/input/repeat/button.rs +++ b/src/input/repeat/button.rs @@ -2,12 +2,13 @@ // 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; -impl AppData +impl AppData { /// Schedule a button-press repeat timer. The runtime fires the /// `on_press` message *immediately* on press too — this fn only @@ -43,12 +44,12 @@ impl AppData // two seconds. 120 ms is fast enough to ramp through values, // slow enough that the user can still release on the value // they want. - if matches!( self.effective_repeat_interval(), None ) { return; } + if self.effective_repeat_interval().is_none() { return; } 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.loop_handle.insert_source( timer, move |_, _, data: &mut AppData| { let live_msg = crate::tree::find_handlers( &data.surface( focus ).frame.widget_rects, diff --git a/src/input/repeat/key.rs b/src/input/repeat/key.rs index f4e389f..234b2a6 100644 --- a/src/input/repeat/key.rs +++ b/src/input/repeat/key.rs @@ -3,12 +3,14 @@ use std::time::Duration; -use smithay_client_toolkit::seat::keyboard::KeyEvent; use calloop::timer::{ Timer, TimeoutAction }; use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus }; use crate::event_loop::repeat::KeyRepeatState; +use crate::platform::Platform; + +use super::super::event::KeyInput; /// Built-in fallback for the initial key-repeat delay when the /// compositor does not advertise one and the app does not override @@ -20,7 +22,7 @@ const DEFAULT_REPEAT_DELAY: Duration = Duration::from_millis( 500 ); /// for "fast" without straying into uncomfortably-twitchy territory. const DEFAULT_REPEAT_INTERVAL: Duration = Duration::from_millis( 33 ); -impl AppData +impl AppData { /// Compute the effective initial repeat delay — app override wins, /// then compositor info, then a built-in default. @@ -65,7 +67,7 @@ impl AppData /// app's [`crate::App::key_repeats`] gate returns `false` for this /// keysym, when repeat is disabled, or when the calloop timer /// insertion fails (in which case held keys simply do not repeat). - pub( crate ) fn start_key_repeat( &mut self, focus: SurfaceFocus, event: KeyEvent ) + pub( crate ) fn start_key_repeat( &mut self, focus: SurfaceFocus, event: KeyInput ) { if !self.app.key_repeats( event.keysym ) { return; } let interval = match self.effective_repeat_interval() @@ -77,16 +79,12 @@ impl AppData 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.loop_handle.insert_source( timer, move |_, _, data: &mut AppData| { data.dispatch_key( focus, event_for_timer.clone() ); TimeoutAction::ToDuration( interval ) } ); - match token - { - Ok( token ) => { self.key_repeat = Some( KeyRepeatState { event, token } ); } - Err( _ ) => {} - } + if let Ok( token ) = token { self.key_repeat = Some( KeyRepeatState { event, token } ); } } /// Cancel any active key-repeat timer. diff --git a/src/input/touch/mod.rs b/src/input/touch/mod.rs index 7dcb26b..10aaef5 100644 --- a/src/input/touch/mod.rs +++ b/src/input/touch/mod.rs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -//! Wayland touch → ltk dispatch. +//! Touch → ltk dispatch. //! -//! `wl_touch` down / up / motion map directly onto the three +//! A finger's down / up / motion ([`TouchInput`]) map directly onto the three //! lifecycle methods of [`GestureState`](super::gesture::GestureState), //! same as pointer press / release / motion. Touch has no hover and //! no scroll-axis event, so this file is shorter than `pointer.rs`; @@ -20,8 +20,7 @@ //! two-finger pan without losing the built-in gestures. Slot //! ownership is recorded on `SurfaceState::primary_touch_id` plus //! `SurfaceState::touch_slots` for the auxiliary fingers' last -//! position (Wayland `wl_touch.up` does not carry one, so the slot -//! cache supplies it). +//! position (an up carries none, so the slot cache supplies it). //! //! The cross-surface routing map `AppData::touch_focus` is still //! per touch id, so an auxiliary slot that landed on an overlay @@ -35,34 +34,28 @@ //! in `SurfaceState::touch_slots` for all fingers in that mode so //! `up` can report a release point. -use smithay_client_toolkit::seat::touch::TouchHandler; -use smithay_client_toolkit::reexports::client:: -{ - protocol::{ wl_surface::WlSurface, wl_touch::WlTouch }, - Connection, QueueHandle, -}; - use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus, SurfaceState }; use crate::tree::find_handlers; +use crate::platform::Platform; -impl TouchHandler for AppData +use super::event::{ TouchInput, TouchPhase }; + +impl AppData { - fn down( - &mut self, - _conn: &Connection, - qh: &QueueHandle, - _touch: &WlTouch, - serial: u32, - time: u32, - surface: WlSurface, - id: i32, - position: ( f64, f64 ), - ) + /// The entry point for a finger. See [`super::event`]. + pub( crate ) fn touch_input( &mut self, input: &TouchInput ) + { + match input.phase + { + TouchPhase::Down { focus, position } => self.touch_down( focus, input.id, input.time, position ), + TouchPhase::Motion { position } => self.touch_motion( input.id, input.time, position ), + TouchPhase::Up => self.touch_up( input.id, input.time ), + } + } + + fn touch_down( &mut self, focus: SurfaceFocus, id: i32, time: u32, position: ( f64, f64 ) ) { - self.last_input_serial = serial; - if self.is_secondary_surface( &surface ) { return; } - let focus = self.focus_for_surface( &surface ).unwrap_or( SurfaceFocus::Main ); self.touch_focus.insert( id, focus ); // A second finger landing on a plain press means the first was a @@ -87,7 +80,7 @@ impl TouchHandler for AppData // Raw-touch apps take every finger verbatim; the gesture // machine never arms. Slots are tracked so `up` (which carries - // no position in wl_touch) can report the release point. + // no position) can report the release point. if self.app.claims_raw_touch() { self.surface_mut( focus ).touch_slots.insert( id, pos ); @@ -99,7 +92,7 @@ impl TouchHandler for AppData // auxiliary-slot path, so `motion` / `up` need no special casing. if raw_here { - self.set_focus( focus, None, qh ); + self.set_focus( focus, None ); self.surface_mut( focus ).touch_slots.insert( id, pos ); self.app.on_touch_down( id as i64, pos.x, pos.y ); return; @@ -145,13 +138,7 @@ impl TouchHandler for AppData // Built-in context menu intercepts the touch before the // regular gesture machine — same logic as the pointer path. - if self.surface( focus ).context_menu.is_some() - { - if self.handle_context_menu_press( focus, pos ) - { - return; - } - } + if self.surface( focus ).context_menu.is_some() && self.handle_context_menu_press( focus, pos ) { return; } if self.osk_press( focus, pos ) { return; } self.app.on_pointer_move( pos.x, pos.y ); @@ -166,7 +153,7 @@ impl TouchHandler for AppData result }; self.press_on_focused = outcome.hit_idx.is_some() && self.surface( focus ).focused_idx == outcome.hit_idx; - self.set_focus( focus, outcome.hit_idx, qh ); + self.set_focus( focus, outcome.hit_idx ); self.osk_after_press( focus, outcome.hit_idx ); if let Some( msg ) = outcome.initial_slider_msg { @@ -221,15 +208,7 @@ impl TouchHandler for AppData } } - fn up( - &mut self, - _conn: &Connection, - _qh: &QueueHandle, - _touch: &WlTouch, - _serial: u32, - time: u32, - id: i32, - ) + fn touch_up( &mut self, id: i32, time: u32 ) { let focus = self.touch_focus.remove( &id ).unwrap_or( SurfaceFocus::Main ); if self.app.claims_raw_touch() @@ -243,9 +222,8 @@ impl TouchHandler for AppData return; } // Auxiliary release: not the primary slot → recover the last - // recorded position for the up callback (Wayland's `wl_touch.up` - // does not carry one) and bail before reaching the gesture - // machine. + // recorded position for the up callback (an up carries none) + // and bail before reaching the gesture machine. let is_primary = { let ss = self.surface_mut( focus ); @@ -267,15 +245,7 @@ impl TouchHandler for AppData self.release_primary( focus ); } - fn motion( - &mut self, - _conn: &Connection, - _qh: &QueueHandle, - _touch: &WlTouch, - time: u32, - id: i32, - position: ( f64, f64 ), - ) + fn touch_motion( &mut self, id: i32, time: u32, position: ( f64, f64 ) ) { let focus = *self.touch_focus.get( &id ).unwrap_or( &SurfaceFocus::Main ); let pp = self.surface( focus ).to_physical( position.0, position.1 ); @@ -335,35 +305,8 @@ impl TouchHandler for AppData } } - fn shape( - &mut self, - _conn: &Connection, - _qh: &QueueHandle, - _touch: &WlTouch, - _id: i32, - _major: f64, - _minor: f64, - ) {} - - fn orientation( - &mut self, - _conn: &Connection, - _qh: &QueueHandle, - _touch: &WlTouch, - _id: i32, - _orientation: f64, - ) {} - - fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle, _touch: &WlTouch ) - { - self.reset_touch_state(); - } -} - -impl AppData -{ - /// Release the primary slot's gesture. Touch-up carries no position in - /// wl_touch, so the last motion's position is the release point. + /// Release the primary slot's gesture. An up carries no position, so + /// the last motion's position is the release point. fn release_primary( &mut self, focus: SurfaceFocus ) { let pos = self.pointer_pos; @@ -388,8 +331,9 @@ impl AppData /// Drop every in-flight touch gesture across all surfaces, notifying /// the app of any slot still down that it was tracking raw (auxiliary - /// fingers, or every finger under `claims_raw_touch`). Shared by the - /// `wl_touch.cancel` handler and the touch-capability add / remove path + /// fingers, or every finger under `claims_raw_touch`). The entry point + /// for a platform's touch cancel (`wl_touch.cancel` on Wayland), and + /// shared with the touch-capability add / remove path /// (suspend / resume on devices that power the touchscreen down): a /// yanked capability never delivers the pending `up` / `cancel`, so /// without this the stale `primary_touch_id` / gesture state strands and @@ -406,7 +350,7 @@ impl AppData // notified with a meaningful release point, then drop every // per-surface slot in one pass. let mut aux_releases: Vec<( i32, crate::types::Point )> = Vec::new(); - let collect = |ss: &mut SurfaceState, out: &mut Vec<( i32, crate::types::Point )>| + let collect = |ss: &mut SurfaceState, out: &mut Vec<( i32, crate::types::Point )>| { for ( id, pos ) in ss.touch_slots.drain() { out.push( ( id, pos ) ); } ss.primary_touch_id = None; diff --git a/src/layout/column.rs b/src/layout/column.rs index bfde951..a0148c9 100644 --- a/src/layout/column.rs +++ b/src/layout/column.rs @@ -240,10 +240,7 @@ impl Column let fixed_h: f32 = self.children.iter() .map( |c| { - if matches!( c, Element::Scroll( s ) if s.axis.allows_y() ) - { - 0.0 - } else if matches!( c, Element::Flex( _ ) ) + if matches!( c, Element::Scroll( s ) if s.axis.allows_y() ) || matches!( c, Element::Flex( _ ) ) { 0.0 } else if let Element::Spacer( s ) = c { diff --git a/src/layout/wrap_grid.rs b/src/layout/wrap_grid.rs index 32c1fe1..26a39e7 100644 --- a/src/layout/wrap_grid.rs +++ b/src/layout/wrap_grid.rs @@ -148,7 +148,7 @@ impl WrapGrid return ( max_width, 0.0 ); } let cell_w = (inner_w - sx * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32; - let row_count = (self.children.len() + cols - 1) / cols; + let row_count = self.children.len().div_ceil( cols ); let mut total_h = pad * 2.0; for row in 0..row_count @@ -179,7 +179,7 @@ impl WrapGrid let x0 = rect.x + pad; let mut y = rect.y + pad; - let row_count = (self.children.len() + cols - 1) / cols; + let row_count = self.children.len().div_ceil( cols ); let mut out = Vec::with_capacity( self.children.len() ); for row in 0..row_count @@ -236,6 +236,75 @@ impl From> for Element } } +/// Create a grid layout with the given number of columns. +/// +/// Use [`.push()`](WrapGrid::push), [`.spacing()`](WrapGrid::spacing), and +/// [`.padding()`](WrapGrid::padding) to populate and style the grid. +/// +/// ```rust,no_run +/// # use ltk::{ button, grid, WrapGrid }; +/// # #[ derive( Clone ) ] enum Msg { A } +/// # fn _ex() -> WrapGrid { +/// grid( 4 ).padding( 16.0 ).spacing( 8.0 ).push( button( "A" ).on_press( Msg::A ) ) +/// # } +/// ``` +pub fn grid( columns: usize ) -> WrapGrid +{ + WrapGrid + { + children: Vec::new(), + columns, + min_cell_width: None, + max_columns: None, + spacing_x: Length::px( 8.0 ), + spacing_y: Length::px( 8.0 ), + padding: Length::px( 0.0 ), + centre_last_row: false, + } +} + +/// Create an adaptive grid: the column count is derived at layout time +/// from the available width, fitting as many columns as possible while +/// keeping every cell at least `min_cell_width` wide (never fewer than +/// one). Cells then share the width equally, so they range between +/// `min_cell_width` and just under twice it — cap the count with +/// [`max_columns`](WrapGrid::max_columns) to let cells grow instead on +/// very wide surfaces. +/// +/// Accepts logical `f32` pixels or any [`Length`] (e.g. +/// `Length::fluid( 96.0 )` for a threshold that scales with the +/// surface). +/// +/// ```rust,no_run +/// # use std::sync::Arc; +/// # use ltk::{ grid_min_cell, icon_button, scroll, Element }; +/// # #[ derive( Clone ) ] enum Msg { Open( usize ) } +/// # fn _ex( data: Arc>, w: u32, h: u32 ) -> Element { +/// scroll( +/// grid_min_cell( 96.0 ) +/// .max_columns( 8 ) +/// .spacing( 12.0 ) +/// .push( icon_button( data, w, h ).on_press( Msg::Open( 0 ) ) ) +/// // ... +/// ) +/// .into() +/// # } +/// ``` +pub fn grid_min_cell( min_cell_width: impl Into ) -> WrapGrid +{ + WrapGrid + { + children: Vec::new(), + columns: 0, + min_cell_width: Some( min_cell_width.into() ), + max_columns: None, + spacing_x: Length::px( 8.0 ), + spacing_y: Length::px( 8.0 ), + padding: Length::px( 0.0 ), + centre_last_row: false, + } +} + #[ cfg( test ) ] mod tests { @@ -491,72 +560,3 @@ mod tests assert!( rects[2].0.x.abs() < 0.01 ); } } - -/// Create a grid layout with the given number of columns. -/// -/// Use [`.push()`](WrapGrid::push), [`.spacing()`](WrapGrid::spacing), and -/// [`.padding()`](WrapGrid::padding) to populate and style the grid. -/// -/// ```rust,no_run -/// # use ltk::{ button, grid, WrapGrid }; -/// # #[ derive( Clone ) ] enum Msg { A } -/// # fn _ex() -> WrapGrid { -/// grid( 4 ).padding( 16.0 ).spacing( 8.0 ).push( button( "A" ).on_press( Msg::A ) ) -/// # } -/// ``` -pub fn grid( columns: usize ) -> WrapGrid -{ - WrapGrid - { - children: Vec::new(), - columns, - min_cell_width: None, - max_columns: None, - spacing_x: Length::px( 8.0 ), - spacing_y: Length::px( 8.0 ), - padding: Length::px( 0.0 ), - centre_last_row: false, - } -} - -/// Create an adaptive grid: the column count is derived at layout time -/// from the available width, fitting as many columns as possible while -/// keeping every cell at least `min_cell_width` wide (never fewer than -/// one). Cells then share the width equally, so they range between -/// `min_cell_width` and just under twice it — cap the count with -/// [`max_columns`](WrapGrid::max_columns) to let cells grow instead on -/// very wide surfaces. -/// -/// Accepts logical `f32` pixels or any [`Length`] (e.g. -/// `Length::fluid( 96.0 )` for a threshold that scales with the -/// surface). -/// -/// ```rust,no_run -/// # use std::sync::Arc; -/// # use ltk::{ grid_min_cell, icon_button, scroll, Element }; -/// # #[ derive( Clone ) ] enum Msg { Open( usize ) } -/// # fn _ex( data: Arc>, w: u32, h: u32 ) -> Element { -/// scroll( -/// grid_min_cell( 96.0 ) -/// .max_columns( 8 ) -/// .spacing( 12.0 ) -/// .push( icon_button( data, w, h ).on_press( Msg::Open( 0 ) ) ) -/// // ... -/// ) -/// .into() -/// # } -/// ``` -pub fn grid_min_cell( min_cell_width: impl Into ) -> WrapGrid -{ - WrapGrid - { - children: Vec::new(), - columns: 0, - min_cell_width: Some( min_cell_width.into() ), - max_columns: None, - spacing_x: Length::px( 8.0 ), - spacing_y: Length::px( 8.0 ), - padding: Length::px( 0.0 ), - centre_last_row: false, - } -} diff --git a/src/lib.rs b/src/lib.rs index 88f36c2..1caf9f6 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,12 @@ // per call site becomes impossible to enforce — the kind of drift that // hides UB in renderer code where every glow::* call is `unsafe fn`. #![ deny( unsafe_op_in_unsafe_fn ) ] +// Without a backend there is no runtime to use the helpers it shares with the core. +#![ cfg_attr( not( backend ), allow( dead_code, unused_imports ) ) ] +// Wayland drives the whole runtime, so its build is where dead code shows; a backend that drives part of it would warn about the rest. +#![ cfg_attr( all( backend, not( feature = "wayland" ) ), allow( dead_code ) ) ] +// Drawing and damage passes take the geometry and state of a surface as plain arguments, and caches are keyed by tuples; splitting either up for the lint would only add types. The quick start keeps its `fn main` to show how an app starts. +#![ allow( clippy::too_many_arguments, clippy::type_complexity, clippy::needless_doctest_main ) ] //! # ltk — Liberux ToolKit //! @@ -300,11 +306,18 @@ pub mod nav_stack; pub( crate ) mod tree; pub( crate ) mod draw; +#[ cfg( backend ) ] pub( crate ) mod input; +#[ cfg( backend ) ] pub( crate ) mod event_loop; +#[ cfg( backend ) ] +pub( crate ) mod platform; +#[ cfg( feature = "wayland" ) ] pub( crate ) mod protocol; +#[ cfg( feature = "wayland" ) ] pub( crate ) mod session_state; pub( crate ) mod secure_mem; +pub( crate ) mod resources; pub mod gles_render; pub mod egl_context; pub mod core; @@ -406,9 +419,12 @@ pub use layout::wrap_grid::{ WrapGrid, grid, grid_min_cell }; pub use widget::scroll::{ scroll, ScrollAxis }; pub use widget::viewport::{ Viewport, viewport }; pub use widget::carousel::{ Carousel, carousel }; -pub use app::run; -pub use app::{ try_run, RunError }; -pub use smithay_client_toolkit::seat::keyboard::Keysym; +#[ cfg( backend ) ] +pub use app::{ run, try_run, RunError }; +#[ cfg( feature = "android" ) ] +pub use platform::android::{ keep_running, set_android_app, AndroidApp }; +pub use resources::{ resource_dir, set_resource_dir }; +pub use xkeysym::Keysym; /// Widgets — the interactive and decorative leaves of the [`Element`] /// tree. @@ -516,8 +532,9 @@ pub mod window progress_bar, list_item, slider, vslider, scroll, viewport, column, row, stack, grid, grid_min_cell, spacer, TextAlign, SliderAxis, - run, }; + #[ cfg( backend ) ] + pub use crate::run; } /// Shell and layer-surface API. @@ -593,5 +610,6 @@ pub mod test_support pub use crate::widget::{ LaidOutWidget, WidgetHandlers }; pub use crate::widget::slider::{ value_from_x_in_rect, value_from_pos_in_rect, thumb_design_px, SliderAxis }; pub use crate::widget::vslider::value_from_y_in_rect; + #[ cfg( backend ) ] pub use crate::event_loop::diff_overlay_ids; } diff --git a/src/osk/layout.rs b/src/osk/layout.rs index 8fd1343..b9f360f 100644 --- a/src/osk/layout.rs +++ b/src/osk/layout.rs @@ -234,6 +234,7 @@ pub fn search_paths() -> Vec } else if let Some( home ) = std::env::var_os( "HOME" ) { out.push( PathBuf::from( home ).join( ".local/share/ltk/keyboards" ) ); } + if let Some( bundle ) = crate::resources::resource_dir() { out.push( bundle.join( "keyboards" ) ); } out.push( PathBuf::from( "/usr/share/ltk/keyboards" ) ); out } diff --git a/src/platform/android/assets.rs b/src/platform/android/assets.rs new file mode 100644 index 0000000..88852c9 --- /dev/null +++ b/src/platform/android/assets.rs @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The APK's copy of ltk's resources: `assets/ltk/`, unpacked into the app's internal storage and used through [`crate::set_resource_dir`], so themes and fonts are read from files as on any other system. +//! +//! The asset manager lists the files of a directory but not its subdirectories, so `assets/ltk/MANIFEST` lists the tree: one path per line, relative to `assets/ltk/`; empty lines and lines starting with `#` are skipped. The tree is unpacked again only when the manifest differs from the one unpacked last, so a packager puts a hash of the contents on a `#` line. + +use std::ffi::CString; +use std::io::{ self, Read }; +use std::path::{ Component, Path }; + +use android_activity::AndroidApp; +use ndk::asset::AssetManager; + +const ROOT: &str = "ltk"; +const MANIFEST: &str = "MANIFEST"; + +/// Unpack the APK's resources when it carries them and they changed since the last start, and use them. +pub( super ) fn install( activity: &AndroidApp ) +{ + let Some( data ) = activity.internal_data_path() else { return }; + let assets = activity.asset_manager(); + let Some( manifest ) = read_asset( &assets, MANIFEST ) else { return }; + let dir = data.join( ROOT ); + let stamp = dir.join( MANIFEST ); + if std::fs::read( &stamp ).ok().as_deref() != Some( manifest.as_slice() ) + { + // The manifest goes last, so a start that dies halfway unpacks again. + if let Err( e ) = unpack( &assets, &manifest, &dir ).and_then( |()| std::fs::write( &stamp, &manifest ) ) + { + eprintln!( "[ltk] unpacking the APK's resources: {e}" ); + return; + } + } + crate::resources::set_resource_dir( dir ); +} + +fn read_asset( assets: &AssetManager, path: &str ) -> Option> +{ + let name = CString::new( format!( "{ROOT}/{path}" ) ).ok()?; + let mut asset = assets.open( &name )?; + let mut bytes = Vec::with_capacity( asset.length() ); + asset.read_to_end( &mut bytes ).ok()?; + Some( bytes ) +} + +/// Replace `dir` with the files `manifest` lists, which must stay inside it. +fn unpack( assets: &AssetManager, manifest: &[u8], dir: &Path ) -> io::Result<()> +{ + let text = std::str::from_utf8( manifest ).map_err( |e| io::Error::new( io::ErrorKind::InvalidData, e ) )?; + // A file the previous version shipped may be gone from this one. + let _ = std::fs::remove_dir_all( dir ); + std::fs::create_dir_all( dir )?; + for line in text.lines().map( str::trim ).filter( |l| !l.is_empty() && !l.starts_with( '#' ) ) + { + let rel = Path::new( line ); + if !rel.components().all( |c| matches!( c, Component::Normal( _ ) ) ) + { + return Err( io::Error::new( io::ErrorKind::InvalidData, format!( "{line}: not a path inside {ROOT}/" ) ) ); + } + let bytes = read_asset( assets, line ) + .ok_or_else( || io::Error::new( io::ErrorKind::NotFound, format!( "asset {ROOT}/{line}" ) ) )?; + let target = dir.join( rel ); + if let Some( parent ) = target.parent() { std::fs::create_dir_all( parent )?; } + std::fs::write( target, bytes )?; + } + Ok( () ) +} diff --git a/src/platform/android/input.rs b/src/platform/android/input.rs new file mode 100644 index 0000000..56b1700 --- /dev/null +++ b/src/platform/android/input.rs @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Android's motion and key events turned into [`crate::input::event`]. +//! +//! Fingers and styluses are touches; a mouse is a pointer. Positions come in window pixels and are divided by `AndroidPlatform::px_per_logical`. Keys repeat on Android's own clock, so the runtime's repeat stays off. Dead keys are not composed yet. + +use std::collections::hash_map::Entry; + +use android_activity::InputStatus; +use android_activity::input:: +{ + Axis as MotionAxis, Button, InputEvent, KeyAction, KeyEvent, KeyMapChar, Keycode, + MetaState, MotionAction, MotionEvent, Pointer, ToolType, +}; + +use crate::app::App; +use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::input::event::{ Axis, AxisSource, KeyInput, PointerButton, PointerInput, PointerKind, TouchInput, TouchPhase }; +use crate::Keysym; + +use super::keys; + +/// `AMETA_ALT_*`, `AMETA_CTRL_*` and `AMETA_META_*`: the modifiers that change what a key means, not the character it types. +const META_ALT: u32 = 0x0000_0032; +const META_CTRL: u32 = 0x0000_7000; +const META_META: u32 = 0x0007_0000; + +/// Logical pixels per wheel detent, as libinput reports one. +const WHEEL_STEP: f64 = 15.0; + +impl AppData +{ + /// Hand one input event to the runtime. Keys it has no use for go back to the system, which acts on the volume keys and the like. + pub( super ) fn android_input( &mut self, event: &InputEvent<'_> ) -> InputStatus + { + match event + { + InputEvent::MotionEvent( motion ) => + { + self.android_motion( motion ); + InputStatus::Handled + } + InputEvent::KeyEvent( key ) => self.android_key( key ), + _ => InputStatus::Unhandled, + } + } + + fn window_to_logical( &self, pointer: &Pointer<'_> ) -> ( f64, f64 ) + { + let ( sx, sy ) = self.platform.px_per_logical; + ( pointer.x() as f64 / sx, pointer.y() as f64 / sy ) + } + + fn android_motion( &mut self, motion: &MotionEvent<'_> ) + { + let pointer = motion.pointer_at_index( motion.pointer_index() ); + if pointer.tool_type() == ToolType::Mouse + { + self.android_mouse( motion, &pointer ); + return; + } + let time = ( motion.event_time() / 1_000_000 ) as u32; + match motion.action() + { + MotionAction::Down | MotionAction::PointerDown => + { + let position = self.window_to_logical( &pointer ); + self.touch_input( &TouchInput { id: pointer.pointer_id(), time, phase: TouchPhase::Down { focus: SurfaceFocus::Main, position } } ); + } + MotionAction::Move => + { + for pointer in motion.pointers() + { + let position = self.window_to_logical( &pointer ); + self.touch_input( &TouchInput { id: pointer.pointer_id(), time, phase: TouchPhase::Motion { position } } ); + } + } + MotionAction::Up | MotionAction::PointerUp => + { + self.touch_input( &TouchInput { id: pointer.pointer_id(), time, phase: TouchPhase::Up } ); + } + MotionAction::Cancel => + { + self.reset_touch_state(); + } + _ => {} + } + } + + /// A press reaches the runtime as `ButtonPress`, which names the button; the `Down` that comes with it does not. + fn android_mouse( &mut self, motion: &MotionEvent<'_>, pointer: &Pointer<'_> ) + { + let kind = match motion.action() + { + MotionAction::HoverEnter => PointerKind::Enter, + MotionAction::HoverExit => PointerKind::Leave, + MotionAction::HoverMove | MotionAction::Move => PointerKind::Motion, + MotionAction::ButtonPress => PointerKind::Press( button( motion.action_button() ) ), + MotionAction::ButtonRelease => PointerKind::Release( button( motion.action_button() ) ), + // Android counts the vertical wheel upwards. + MotionAction::Scroll => PointerKind::Axis + { + horizontal: wheel( pointer.axis_value( MotionAxis::Hscroll ) ), + vertical: wheel( -pointer.axis_value( MotionAxis::Vscroll ) ), + source: AxisSource::Wheel, + }, + _ => return, + }; + let position = self.window_to_logical( pointer ); + self.pointer_input( &PointerInput { focus: SurfaceFocus::Main, position, kind } ); + } + + fn android_key( &mut self, event: &KeyEvent<'_> ) -> InputStatus + { + let meta = event.meta_state(); + let ( ctrl, shift, alt ) = ( meta.ctrl_on(), meta.shift_on(), meta.alt_on() ); + if ( ctrl, shift, alt ) != ( self.ctrl_pressed, self.shift_pressed, self.alt_pressed ) + { + self.modifiers_changed( ctrl, shift, alt ); + } + + let code = event.key_code(); + let typed = self.typed_char( event.device_id(), code, meta ); + let Some( keysym ) = keys::keysym( code ).or( typed.map( Keysym::from_char ) ) else { return InputStatus::Unhandled }; + let text = typed.filter( |c| !ctrl && !c.is_control() ).map( String::from ); + let key = KeyInput { keysym, keycode: u32::from( code ), text }; + match event.action() + { + KeyAction::Down if event.repeat_count() > 0 => self.key_repeated( key ), + KeyAction::Down => self.key_press( key ), + KeyAction::Up => self.key_release( &key ), + _ => return InputStatus::Unhandled, + } + InputStatus::Handled + } + + /// The character `code` types on `device` with only the modifiers that pick a character, Shift and Caps Lock. + fn typed_char( &mut self, device: i32, code: Keycode, meta: MetaState ) -> Option + { + let map = match self.platform.key_maps.entry( device ) + { + Entry::Occupied( e ) => e.into_mut(), + Entry::Vacant( e ) => e.insert( self.platform.activity.device_key_character_map( device ).ok()? ), + }; + match map.get( code, MetaState( meta.0 & !( META_ALT | META_CTRL | META_META ) ) ) + { + Ok( KeyMapChar::Unicode( c ) ) => Some( c ), + _ => None, + } + } +} + +fn button( button: Button ) -> PointerButton +{ + match button + { + Button::Primary => PointerButton::Left, + Button::Secondary => PointerButton::Right, + Button::Tertiary => PointerButton::Middle, + Button::Back => PointerButton::Back, + _ => PointerButton::Other, + } +} + +fn wheel( notches: f32 ) -> Axis +{ + Axis { distance: notches as f64 * WHEEL_STEP, notches } +} diff --git a/src/platform/android/java.rs b/src/platform/android/java.rs new file mode 100644 index 0000000..ad81d57 --- /dev/null +++ b/src/platform/android/java.rs @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! What only the Java side of Android offers, through JNI on the activity: the system clipboard, the window's insets, a service that keeps the app running, sending the app to the background and closing it. + +use android_activity::AndroidApp; +use jni::objects::{ JObject, JString, JValue }; +use jni::{ JNIEnv, JavaVM }; + +/// Room for the local references one call makes. +const LOCAL_FRAME: i32 = 16; + +/// Run `f` with the activity, its local references freed afterwards and a Java exception cleared. `None` when JNI or `f` failed. +fn with_activity( app: &AndroidApp, f: impl FnOnce( &mut JNIEnv, &JObject ) -> jni::errors::Result ) -> Option +{ + // SAFETY: android-activity hands out the process's JavaVM and a global reference to the activity, both alive while the app runs. + let vm = unsafe { JavaVM::from_raw( app.vm_as_ptr().cast() ) }.ok()?; + let activity = unsafe { JObject::from_raw( app.activity_as_ptr().cast() ) }; + let mut env = vm.attach_current_thread().ok()?; + env.with_local_frame( LOCAL_FRAME, |env| + { + let result = f( env, &activity ); + if result.is_err() { let _ = env.exception_clear(); } + result + } ).ok() +} + +fn clipboard_manager<'local>( env: &mut JNIEnv<'local>, activity: &JObject ) -> jni::errors::Result> +{ + let name = env.new_string( "clipboard" )?; + env.call_method( activity, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;", &[ JValue::Object( &name ) ] )?.l() +} + +/// Offer `text` to other apps as the clipboard. +pub( super ) fn set_clipboard( app: &AndroidApp, text: &str ) +{ + with_activity( app, |env, activity| + { + let manager = clipboard_manager( env, activity )?; + let label = env.new_string( "ltk" )?; + let text = env.new_string( text )?; + let clip = env.call_static_method + ( + "android/content/ClipData", "newPlainText", + "(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;", + &[ JValue::Object( &label ), JValue::Object( &text ) ], + )?.l()?; + env.call_method( &manager, "setPrimaryClip", "(Landroid/content/ClipData;)V", &[ JValue::Object( &clip ) ] )?; + Ok( () ) + } ); +} + +/// The clipboard's text. Android lets only the app with the focus read it. +pub( super ) fn clipboard_text( app: &AndroidApp ) -> Option +{ + with_activity( app, |env, activity| + { + let manager = clipboard_manager( env, activity )?; + let clip = env.call_method( &manager, "getPrimaryClip", "()Landroid/content/ClipData;", &[] )?.l()?; + if clip.is_null() { return Ok( None ); } + if env.call_method( &clip, "getItemCount", "()I", &[] )?.i()? == 0 { return Ok( None ); } + let item = env.call_method( &clip, "getItemAt", "(I)Landroid/content/ClipData$Item;", &[ JValue::Int( 0 ) ] )?.l()?; + let text = env.call_method( &item, "coerceToText", "(Landroid/content/Context;)Ljava/lang/CharSequence;", &[ JValue::Object( activity ) ] )?.l()?; + if text.is_null() { return Ok( None ); } + let text = JString::from( env.call_method( &text, "toString", "()Ljava/lang/String;", &[] )?.l()? ); + let string = String::from( env.get_string( &text )? ); + Ok( Some( string ) ) + } ).flatten() +} + +/// The window's edges the view keeps clear of, in window pixels, as `[ left, top, right, bottom ]`: on top the status bar and any cutout, elsewhere only bars that take taps, so the view runs under a gesture bar. `None` before Android 11, which cannot tell them apart, or while the view has no insets. +pub( super ) fn window_insets( app: &AndroidApp ) -> Option<[ i32; 4 ]> +{ + if app.config().sdk_version() < 30 { return None; } + with_activity( app, |env, activity| + { + let window = env.call_method( activity, "getWindow", "()Landroid/view/Window;", &[] )?.l()?; + let decor = env.call_method( &window, "getDecorView", "()Landroid/view/View;", &[] )?.l()?; + let insets = env.call_method( &decor, "getRootWindowInsets", "()Landroid/view/WindowInsets;", &[] )?.l()?; + if insets.is_null() { return Ok( None ); } + let cutout = inset_type( env, "displayCutout" )?; + let bars = inset_type( env, "statusBars" )?; + let taps = inset_type( env, "tappableElement" )?; + let top = edges( env, &insets, bars | cutout )?; + let rest = edges( env, &insets, taps | cutout )?; + Ok( Some( [ rest[ 0 ], top[ 1 ], rest[ 2 ], rest[ 3 ] ] ) ) + } ).flatten() +} + +fn inset_type( env: &mut JNIEnv, name: &str ) -> jni::errors::Result +{ + env.call_static_method( "android/view/WindowInsets$Type", name, "()I", &[] )?.i() +} + +/// `insets.getInsets( types )` as `[ left, top, right, bottom ]`. +fn edges( env: &mut JNIEnv, insets: &JObject, types: i32 ) -> jni::errors::Result<[ i32; 4 ]> +{ + let edges = env.call_method( insets, "getInsets", "(I)Landroid/graphics/Insets;", &[ JValue::Int( types ) ] )?.l()?; + Ok( + [ + env.get_field( &edges, "left", "I" )?.i()?, + env.get_field( &edges, "top", "I" )?.i()?, + env.get_field( &edges, "right", "I" )?.i()?, + env.get_field( &edges, "bottom", "I" )?.i()?, + ] ) +} + +/// The service `scripts/android-apk.sh` adds with `KEEP_ALIVE=1`. +const KEEP_ALIVE: &str = "net.liberux.ltk.KeepAlive"; + +/// Start the keep-alive service with `text` on its notification, or stop it with `None`. Without the service in the APK, nothing happens. +pub( super ) fn keep_alive( app: &AndroidApp, text: Option<&str> ) +{ + with_activity( app, |env, activity| + { + let package = env.call_method( activity, "getPackageName", "()Ljava/lang/String;", &[] )?.l()?; + let class = env.new_string( KEEP_ALIVE )?; + let intent = env.new_object( "android/content/Intent", "()V", &[] )?; + env.call_method( &intent, "setClassName", "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;", &[ JValue::Object( &package ), JValue::Object( &class ) ] )?; + match text + { + Some( text ) => + { + let key = env.new_string( "text" )?; + let value = env.new_string( text )?; + env.call_method( &intent, "putExtra", "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;", &[ JValue::Object( &key ), JValue::Object( &value ) ] )?; + env.call_method( activity, "startForegroundService", "(Landroid/content/Intent;)Landroid/content/ComponentName;", &[ JValue::Object( &intent ) ] )?; + } + None => + { + env.call_method( activity, "stopService", "(Landroid/content/Intent;)Z", &[ JValue::Object( &intent ) ] )?; + } + } + Ok( () ) + } ); +} + +/// Close the activity and take its task out of the recent apps, as closing it there does. +pub( super ) fn finish_and_remove_task( app: &AndroidApp ) +{ + with_activity( app, |env, activity| + { + env.call_method( activity, "finishAndRemoveTask", "()V", &[] )?; + Ok( () ) + } ); +} + +/// Send the app's task to the background, as the home button does; the app keeps running. +pub( super ) fn move_task_to_back( app: &AndroidApp ) +{ + with_activity( app, |env, activity| + { + env.call_method( activity, "moveTaskToBack", "(Z)Z", &[ JValue::Bool( 1 ) ] )?; + Ok( () ) + } ); +} diff --git a/src/platform/android/keys.rs b/src/platform/android/keys.rs new file mode 100644 index 0000000..e69469d --- /dev/null +++ b/src/platform/android/keys.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use android_activity::input::Keycode; + +use crate::Keysym; + +/// The keysym of a key that types no character: navigation, editing, function and modifier keys, and back. `None` for the others, whose keysym is the character they type. +pub( super ) fn keysym( code: Keycode ) -> Option +{ + Some( match code + { + Keycode::DpadLeft => Keysym::Left, + Keycode::DpadRight => Keysym::Right, + Keycode::DpadUp => Keysym::Up, + Keycode::DpadDown => Keysym::Down, + Keycode::Enter | Keycode::DpadCenter => Keysym::Return, + Keycode::NumpadEnter => Keysym::KP_Enter, + Keycode::Del => Keysym::BackSpace, + Keycode::ForwardDel => Keysym::Delete, + Keycode::Tab => Keysym::Tab, + Keycode::Escape => Keysym::Escape, + Keycode::MoveHome => Keysym::Home, + Keycode::MoveEnd => Keysym::End, + Keycode::PageUp => Keysym::Page_Up, + Keycode::PageDown => Keysym::Page_Down, + Keycode::Insert => Keysym::Insert, + Keycode::F1 => Keysym::F1, + Keycode::F2 => Keysym::F2, + Keycode::F3 => Keysym::F3, + Keycode::F4 => Keysym::F4, + Keycode::F5 => Keysym::F5, + Keycode::F6 => Keysym::F6, + Keycode::F7 => Keysym::F7, + Keycode::F8 => Keysym::F8, + Keycode::F9 => Keysym::F9, + Keycode::F10 => Keysym::F10, + Keycode::F11 => Keysym::F11, + Keycode::F12 => Keysym::F12, + Keycode::Back => Keysym::XF86_Back, + Keycode::Forward => Keysym::XF86_Forward, + Keycode::Menu => Keysym::Menu, + Keycode::Cut => Keysym::XF86_Cut, + Keycode::Copy => Keysym::XF86_Copy, + Keycode::Paste => Keysym::XF86_Paste, + Keycode::ShiftLeft => Keysym::Shift_L, + Keycode::ShiftRight => Keysym::Shift_R, + Keycode::CtrlLeft => Keysym::Control_L, + Keycode::CtrlRight => Keysym::Control_R, + Keycode::AltLeft => Keysym::Alt_L, + Keycode::AltRight => Keysym::Alt_R, + Keycode::MetaLeft => Keysym::Super_L, + Keycode::MetaRight => Keysym::Super_R, + Keycode::CapsLock => Keysym::Caps_Lock, + Keycode::NumLock => Keysym::Num_Lock, + Keycode::ScrollLock => Keysym::Scroll_Lock, + Keycode::Sysrq => Keysym::Print, + _ => return None, + } ) +} diff --git a/src/platform/android/log.rs b/src/platform/android/log.rs new file mode 100644 index 0000000..c2e0003 --- /dev/null +++ b/src/platform/android/log.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Standard output and error into logcat. An Android process's go nowhere, and ltk reports through `eprintln!`, as the default panic hook does. + +use std::ffi::{ c_char, c_int, CString }; +use std::io::{ BufRead, BufReader }; +use std::os::fd::FromRawFd; + +#[ link( name = "log" ) ] +extern "C" +{ + fn __android_log_write( priority: c_int, tag: *const c_char, text: *const c_char ) -> c_int; +} + +/// `ANDROID_LOG_INFO`. +const INFO: c_int = 4; + +/// Send each line the process writes to its standard output or error to logcat, tagged with the process's name, which on Android is its package's. +pub( super ) fn redirect() +{ + let name = std::fs::read( "/proc/self/cmdline" ).ok() + .and_then( |cmdline| cmdline.split( |&b| b == 0 ).next().map( <[u8]>::to_vec ) ) + .filter( |name| !name.is_empty() ) + .unwrap_or_else( || b"ltk".to_vec() ); + let Ok( tag ) = CString::new( name ) else { return }; + + let mut fds = [ 0 as c_int; 2 ]; + // SAFETY: `fds` has room for the two descriptors `pipe` writes. + if unsafe { libc::pipe( fds.as_mut_ptr() ) } != 0 { return; } + // SAFETY: duplicates a descriptor this function just opened onto the standard ones, then drops its own copy. + unsafe + { + libc::dup2( fds[ 1 ], libc::STDOUT_FILENO ); + libc::dup2( fds[ 1 ], libc::STDERR_FILENO ); + libc::close( fds[ 1 ] ); + } + // SAFETY: the read end was just opened and nothing else owns it. + let reader = unsafe { std::fs::File::from_raw_fd( fds[ 0 ] ) }; + let _ = std::thread::Builder::new() + .name( "ltk-logcat".into() ) + .spawn( move || + { + for line in BufReader::new( reader ).lines().map_while( Result::ok ) + { + let Ok( text ) = CString::new( line ) else { continue }; + // SAFETY: both strings are NUL-terminated and outlive the call. + unsafe { __android_log_write( INFO, tag.as_ptr(), text.as_ptr() ); } + } + } ); +} diff --git a/src/platform/android/mod.rs b/src/platform/android/mod.rs new file mode 100644 index 0000000..485e4b0 --- /dev/null +++ b/src/platform/android/mod.rs @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The Android backend: `ltk::run` inside a NativeActivity, through `android-activity`. +//! +//! An app's `android_main` hands its `AndroidApp` to [`set_android_app`], which also unpacks the resources the APK carries, and then calls `ltk::run` as on any other platform. +//! +//! | File | What it does for the runtime | +//! | --- | --- | +//! | `assets.rs` | The themes and fonts under the APK's `assets/ltk/`, unpacked into internal storage as the app's resource directory | +//! | `log.rs` | Standard output and error, panics included, into logcat | +//! | `java.rs` | The system clipboard, the window's insets, the keep-alive service, sending the app to the background and closing it, through JNI | +//! | `state.rs` | [`AndroidPlatform`]: the activity, the EGL context, the window's scale and what the runtime asked of the system | +//! | `surface.rs` | [`AndroidSurfaceState`]: the `ANativeWindow` and its EGL surface; sizing the main surface to the window as it comes, changes and goes | +//! | `present.rs` | Putting a painted canvas on screen through `eglSwapBuffers`, or a locked window buffer in software | +//! | `input.rs`, `keys.rs` | Motion and key events turned into [`crate::input::event`], Android's keycodes into keysyms | +//! | `run.rs` | `ltk::run`: the looper, the activity's lifecycle and the loop around the runtime's steps | +//! +//! The activity has one surface, the main one, filling its window. Overlays, secondary surfaces and subsurfaces are not created. The clipboard is the system's, read again whenever the app gains the focus; the primary selection stays within the process, and the activity's saved state is not used yet. + +mod assets; +mod input; +mod java; +mod keys; +mod log; +mod present; +pub( crate ) mod run; +mod state; +mod surface; + +pub use android_activity::AndroidApp; +pub use run::{ keep_running, set_android_app }; +pub( crate ) use state::AndroidPlatform; +pub( crate ) use surface::AndroidSurfaceState; + +use android_activity::WindowManagerFlags; + +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 AndroidPlatform +{ + type Surface = AndroidSurfaceState; + type Output = (); + + fn is_toplevel( &self, _main: &AndroidSurfaceState ) -> bool + { + false + } + + fn back_leaves( &self, _main: &AndroidSurfaceState ) -> bool + { + true + } + + fn begin_window_move( &self, _main: &AndroidSurfaceState ) {} + + fn begin_window_resize( &self, _main: &AndroidSurfaceState, _edge: ResizeEdge ) {} + + fn minimize_window( &self, _main: &AndroidSurfaceState ) + { + java::move_task_to_back( &self.activity ); + } + + fn set_fullscreen( &self, _main: &AndroidSurfaceState, fullscreen: bool ) -> bool + { + let ( add, remove ) = if fullscreen + { + ( WindowManagerFlags::FULLSCREEN, WindowManagerFlags::empty() ) + } else { + ( WindowManagerFlags::empty(), WindowManagerFlags::FULLSCREEN ) + }; + self.activity.set_window_flags( add, remove ); + true + } + + fn request_frame( &self, surface: &AndroidSurfaceState, _focus: SurfaceFocus ) -> bool + { + if surface.window.is_none() { return false; } + self.frame_requested.set( true ); + true + } + + fn precise_damage( &self, surface: &AndroidSurfaceState ) -> bool + { + 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 + { + present::prepare_canvas( self, surface, canvas, pw, ph, scale ) + } + + fn present( &self, surface: &mut AndroidSurfaceState, canvas: &mut Canvas, frame: &Presentation<'_> ) -> bool + { + present::present( self, surface, canvas, frame ) + } + + fn publish_clipboard( &mut self, text: &str ) + { + java::set_clipboard( &self.activity, text ); + } + + fn publish_primary_selection( &mut self, _text: &str ) {} + + fn set_cursor_shape( &mut self, _shape: CursorShape ) -> bool + { + false + } + + 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 ) {} +} diff --git a/src/platform/android/present.rs b/src/platform/android/present.rs new file mode 100644 index 0000000..60b880f --- /dev/null +++ b/src/platform/android/present.rs @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Putting a painted canvas on screen: through `eglSwapBuffers` for the GPU renderer, through a locked `ANativeWindow` buffer for the software one. + +use std::sync::Arc; + +use crate::app::App; +use crate::draw::fit_canvas; +use crate::egl_context::egl_damage; +use crate::event_loop::perf::RasterTrace; +use crate::platform::Presentation; +use crate::render::Canvas; + +use super::{ AndroidPlatform, AndroidSurfaceState }; + +/// The GPU path when both the process and the window have EGL, software otherwise. +pub( super ) fn prepare_canvas +( + platform: &AndroidPlatform, + surface: &mut AndroidSurfaceState, + canvas: &mut Option, + pw: u32, + ph: u32, + scale: u32, +) -> bool +{ + if let ( Some( ctx ), Some( es ) ) = ( platform.egl_context.as_ref(), surface.egl_surface.as_ref() ) + { + if ctx.make_current( es ).is_err() { return false; } + surface.raster_trace = RasterTrace::begin(); + fit_canvas( canvas, pw, ph, scale, || Canvas::new_gles( Arc::clone( ctx.gl() ), ctx.version, pw, ph ) ); + return true; + } + if surface.window.is_none() { return false; } + fit_canvas( canvas, pw, ph, scale, || Canvas::new( pw, ph ) ); + true +} + +pub( super ) fn present +( + platform: &AndroidPlatform, + surface: &mut AndroidSurfaceState, + canvas: &mut Canvas, + frame: &Presentation<'_>, +) -> bool +{ + let ( pw, ph ) = canvas.size(); + let shown = match platform.egl_context.as_ref().zip( surface.egl_surface.as_ref() ) + { + Some( ( ctx, es ) ) => + { + canvas.present(); + if let Some( t ) = surface.raster_trace.take() + { + t.end( ctx.gl(), if frame.damage.is_empty() { "full" } else { "partial" }, pw, ph ); + } + ctx.swap_buffers_with_damage( es, &egl_damage( frame.damage, pw, ph ) ).is_ok() + } + None => present_software( surface, canvas ), + }; + if shown && frame.frame.is_some() { platform.presented.set( true ); } + shown +} + +/// Copy the canvas into the window's next buffer, whose lines may be longer than the canvas is wide, and post it. +fn present_software( surface: &mut AndroidSurfaceState, canvas: &Canvas ) -> bool +{ + let ( pw, ph ) = canvas.size(); + let line = pw as usize * 4; + surface.pixels.resize( line * ph as usize, 0 ); + // The window's buffers are R8G8B8A8, the canvas's own byte order. + canvas.write_to_wayland_buf( &mut surface.pixels, false ); + + let Some( ref window ) = surface.window else { return false }; + let Ok( mut buffer ) = window.lock( None ) else { return false }; + let Some( lines ) = buffer.lines() else { return false }; + for ( dst, src ) in lines.zip( surface.pixels.chunks_exact( line ) ) + { + let n = dst.len().min( src.len() ); + // SAFETY: `dst` is a line of the locked buffer with at least `n` writable bytes, `src` has at least `n`. + unsafe { std::ptr::copy_nonoverlapping( src.as_ptr(), dst.as_mut_ptr().cast::(), n ); } + } + true +} diff --git a/src/platform/android/run.rs b/src/platform/android/run.rs new file mode 100644 index 0000000..6a6eafd --- /dev/null +++ b/src/platform/android/run.rs @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! `ltk::run` on Android. +//! +//! The activity's thread sleeps in `AndroidApp::poll_events`, on its `ALooper`. calloop's sources (the app's message channel, the text-scale watcher, the timers) still live on a calloop loop, whose epoll descriptor is added to the looper with a callback that wakes it, after which the loop dispatches calloop without waiting. calloop's timers raise no descriptor, so the looper's timeout is bounded by every timer the runtime may have armed. + +use std::os::fd::AsFd; +use std::sync::{ Arc, Mutex, Once }; +use std::time::Duration; + +use android_activity::{ AndroidApp, MainEvent, PollEvent }; +use calloop::EventLoop; +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 super::{ AndroidPlatform, AndroidSurfaceState }; + +/// How long a frame the runtime asked for without drawing waits, one refresh at 60 Hz. +const FRAME: Duration = Duration::from_millis( 16 ); + +/// How often the looper wakes while a held button repeats on a calloop timer. +const REPEAT_TICK: Duration = Duration::from_millis( 10 ); + +/// The latest activity: when one finishes the process lives on, and Android starts the next one in it with a new `android_main`. +static ACTIVITY: Mutex> = Mutex::new( None ); +static PROCESS: Once = Once::new(); + +/// Hand ltk the `AndroidApp` that `android_main` receives. The first call in the process also sets `HOME`, `SSL_CERT_DIR` and `LANG` as desktop code expects them, sends standard output and error to logcat, and unpacks the themes and fonts the APK carries under `assets/ltk/`. Call it first, before the app reads its theme or preferences and before `ltk::run`, which otherwise fails with [`RunError::NoAndroidApp`]. +pub fn set_android_app( app: AndroidApp ) +{ + PROCESS.call_once( || + { + set_environment( &app ); + super::log::redirect(); + super::assets::install( &app ); + } ); + *ACTIVITY.lock().unwrap_or_else( |e| e.into_inner() ) = Some( app ); +} + +/// Keep the app running in the background with `Some( text )` on an ongoing notification, as Android otherwise freezes it soon after it leaves the screen, or let it be frozen again with `None`. Needs the APK built with `KEEP_ALIVE=1` (`scripts/android-apk.sh`); callable from any thread, and while the app is on screen, since Android refuses to start it from the background. +pub fn keep_running( text: Option<&str> ) +{ + let activity = ACTIVITY.lock().unwrap_or_else( |e| e.into_inner() ).clone(); + if let Some( activity ) = activity { super::java::keep_alive( &activity, text ); } +} + +/// Android's CA certificates, updated through the Conscrypt APEX since Android 14, and the system's. +const CERT_DIRS: &str = "/apex/com.android.conscrypt/cacerts:/system/etc/security/cacerts"; + +/// `HOME` is the app's internal storage, where an Android app keeps its files; unless set, `SSL_CERT_DIR` names Android's CA certificates, which OpenSSL-style lookups such as rustls-native-certs do not know, and `LANG` the device's language. Before any thread of ltk's starts, since another thread reading the environment meanwhile is unsound. +fn set_environment( app: &AndroidApp ) +{ + if let Some( dir ) = app.internal_data_path() { std::env::set_var( "HOME", dir ); } + if std::env::var_os( "SSL_CERT_DIR" ).is_none() { std::env::set_var( "SSL_CERT_DIR", CERT_DIRS ); } + if std::env::var_os( "LANG" ).is_some() { return; } + let config = app.config(); + let Some( language ) = config.language() else { return }; + let locale = match config.country() + { + Some( country ) => format!( "{language}_{country}.UTF-8" ), + None => format!( "{language}.UTF-8" ), + }; + std::env::set_var( "LANG", locale ); +} + +pub( crate ) fn run( app: A ) +{ + if let Err( e ) = try_run( app ) + { + panic!( "ltk::run failed during init: {e}" ); + } +} + +pub( crate ) fn try_run( app: A ) -> Result<(), RunError> +{ + let activity = ACTIVITY.lock().unwrap_or_else( |e| e.into_inner() ).clone().ok_or( RunError::NoAndroidApp )?; + let mut event_loop: EventLoop> = EventLoop::try_new() + .map_err( |e| RunError::EventLoop( format!( "EventLoop::try_new: {e}" ) ) )?; + wake_on_sources( &event_loop, &activity )?; + + let egl_context = match EglContext::new_android() + { + Ok( ctx ) => Some( Arc::new( ctx ) ), + Err( reason ) => + { + log_software_fallback( &reason ); + None + } + }; + crate::render::set_software_render( egl_context.is_none() ); + + 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 ); + data.install_sources()?; + + while !data.exit_requested + { + // The window must be let go of inside the callback, before the system destroys it; input is read after. + let mut input = false; + activity.poll_events( timeout( &data ), |event| + { + if let PollEvent::Main( event ) = event { input |= data.android_lifecycle( event ); } + } ); + if let Err( e ) = event_loop.dispatch( Some( Duration::ZERO ), &mut data ) + { + panic!( "dispatch: {e:?}" ); + } + if input { data.read_android_input(); } + if data.platform.frame_requested.take() { data.frame_presented( SurfaceFocus::Main, false ); } + + data.before_draw(); + let drawn = data.draw_due(); + if data.platform.presented.take() { data.frame_presented( SurfaceFocus::Main, false ); } + data.after_draw( drawn ); + } + + // The app asked to close: `ltk::run` returns once the system has destroyed the activity, which leaves no window behind. + if !data.platform.destroyed { super::java::finish_and_remove_task( &activity ); } + while !data.platform.destroyed + { + activity.poll_events( None, |event| + { + if let PollEvent::Main( event ) = event { data.android_lifecycle( event ); } + } ); + } + Ok( () ) +} + +/// Wake the looper when a calloop source has an event, so `poll_events` returns and the loop dispatches it. +fn wake_on_sources( event_loop: &EventLoop<'static, AppData>, activity: &AndroidApp ) -> Result<(), RunError> +{ + let looper = ForeignLooper::for_thread() + .ok_or_else( || RunError::EventLoop( "no ALooper on the activity's thread".to_string() ) )?; + let waker = activity.create_waker(); + looper.add_fd_with_callback( event_loop.as_fd(), FdEvent::INPUT, move |_, _| + { + waker.wake(); + true + } ) + .map_err( |e| RunError::EventLoop( format!( "ALooper_addFd: {e:?}" ) ) ) +} + +/// The longest the looper may sleep: not at all while the main surface waits to be drawn, a refresh while a frame is owed, and otherwise until the runtime's next deadline or calloop timer. +fn timeout( data: &AppData ) -> Option +{ + let main = &data.main; + if main.configured && main.needs_redraw && !main.frame_pending { return Some( Duration::ZERO ); } + [ + data.next_wakeup(), + data.platform.frame_requested.get().then_some( FRAME ), + data.button_repeat.is_some().then_some( REPEAT_TICK ), + data.app.poll_interval(), + ] + .into_iter() + .flatten() + .min() +} + +impl AppData +{ + /// Follow the activity's lifecycle. Returns whether input is waiting to be read. + fn android_lifecycle( &mut self, event: MainEvent<'_> ) -> bool + { + match event + { + MainEvent::InputAvailable => return true, + MainEvent::InitWindow { .. } => self.attach_window(), + MainEvent::TerminateWindow { .. } => self.detach_window(), + MainEvent::WindowResized { .. } + | MainEvent::ContentRectChanged { .. } + | MainEvent::ConfigChanged { .. } => self.fit_window(), + MainEvent::RedrawNeeded { .. } => self.main.request_redraw(), + MainEvent::GainedFocus => + { + // Another app may have copied meanwhile, and Android lets only the focused app read it. + if let Some( text ) = super::java::clipboard_text( &self.platform.activity ) { self.clipboard = text; } + self.keyboard_enter( SurfaceFocus::Main ); + } + MainEvent::LostFocus => self.keyboard_leave(), + MainEvent::Destroy => + { + self.platform.destroyed = true; + self.exit_requested = true; + } + _ => {} + } + false + } + + /// Read every waiting input event; reading also lets the looper report the next ones. + fn read_android_input( &mut self ) + { + let activity = self.platform.activity.clone(); + let Ok( mut events ) = activity.input_events_iter() else { return }; + while events.next( |event| self.android_input( event ) ) {} + } +} diff --git a/src/platform/android/state.rs b/src/platform/android/state.rs new file mode 100644 index 0000000..d37f59a --- /dev/null +++ b/src/platform/android/state.rs @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use std::cell::Cell; +use std::collections::HashMap; +use std::marker::PhantomData; +use std::sync::Arc; + +use android_activity::AndroidApp; +use android_activity::input::KeyCharacterMap; + +use crate::app::App; +use crate::egl_context::EglContext; + +/// What the runtime's requests go through on Android: the activity, the EGL context and what the loop must report back to the runtime. +pub( crate ) struct AndroidPlatform +{ + pub activity: AndroidApp, + /// `None` when EGL failed to initialise or `LTK_FORCE_SOFTWARE=1`; the window is then drawn through a locked buffer. + pub egl_context: Option>, + /// Window pixels per logical pixel on each axis, which input positions are divided by. The buffers hold the logical size times the scale, which the system stretches by less than a pixel to fill the window. + pub px_per_logical: ( f64, f64 ), + /// Text input was asked for and not ended; ltk's own keyboard serves it, never the system's. + pub text_input: bool, + /// A frame reached the screen; `eglSwapBuffers` has already waited for the display, so the loop reports it at once. + pub presented: Cell, + /// The runtime asked for a frame without drawing one; the loop reports it a refresh later. + pub frame_requested: Cell, + /// Each input device's character map, read once through JNI. + pub key_maps: HashMap, + pub destroyed: bool, + _app: PhantomData A>, +} + +impl AndroidPlatform +{ + pub( crate ) fn new( activity: AndroidApp, egl_context: Option> ) -> Self + { + Self + { + activity, + egl_context, + px_per_logical: ( 1.0, 1.0 ), + text_input: false, + presented: Cell::new( false ), + frame_requested: Cell::new( false ), + key_maps: HashMap::new(), + destroyed: false, + _app: PhantomData, + } + } +} diff --git a/src/platform/android/surface.rs b/src/platform/android/surface.rs new file mode 100644 index 0000000..1261702 --- /dev/null +++ b/src/platform/android/surface.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use android_activity::Rect; +use ndk::hardware_buffer_format::HardwareBufferFormat; +use ndk::native_window::NativeWindow; + +use crate::app::App; +use crate::egl_context::EglSurface; +use crate::event_loop::AppData; +use crate::event_loop::surface::Insets; +use crate::event_loop::perf::RasterTrace; + +/// The main surface's Android half: the activity's window while it has one. +#[ derive( Default ) ] +pub( crate ) struct AndroidSurfaceState +{ + /// Declared before `window` so it is destroyed first: EGL draws into the window. + pub egl_surface: Option, + pub window: Option, + pub raster_trace: Option, + /// The software renderer's frame, copied line by line into the window's buffer, whose lines may be longer. + pub pixels: Vec, +} + +/// The edges of a `window`-pixel window outside the activity's content rect, as `[ left, top, right, bottom ]`. None before Android has reported the rect, which starts empty. +fn content_edges( content: Rect, window: ( u32, u32 ) ) -> [ i32; 4 ] +{ + if content.right <= content.left || content.bottom <= content.top { return [ 0; 4 ]; } + [ content.left, content.top, window.0 as i32 - content.right, window.1 as i32 - content.bottom ] +} + +/// Window-pixel edges in logical pixels. +fn logical_insets( [ left, top, right, bottom ]: [ i32; 4 ], px_per_logical: ( f64, f64 ) ) -> Insets +{ + let ( sx, sy ) = ( px_per_logical.0 as f32, px_per_logical.1 as f32 ); + Insets + { + top: top.max( 0 ) as f32 / sy, + right: right.max( 0 ) as f32 / sx, + bottom: bottom.max( 0 ) as f32 / sy, + left: left.max( 0 ) as f32 / sx, + } +} + +/// The buffer scale for a display of `dpi`: its density in multiples of Android's 160 dpi baseline, rounded, at least 1. +fn density_scale( dpi: Option ) -> u32 +{ + dpi.map_or( 1, |dpi| ( ( dpi as f32 / 160.0 ).round() as u32 ).max( 1 ) ) +} + +impl AppData +{ + /// The activity has a window: draw into it through EGL when there is a context, in software otherwise. + pub( super ) fn attach_window( &mut self ) + { + let Some( window ) = self.platform.activity.native_window() else { return }; + self.main.platform.window = Some( window ); + self.fit_window(); + let ( Some( ctx ), Some( window ) ) = ( self.platform.egl_context.as_ref(), self.main.platform.window.as_ref() ) else { return }; + // SAFETY: the window is held in `main.platform.window`, which outlives the EGL surface declared before it. + match unsafe { ctx.create_native_surface( window.ptr().as_ptr().cast() ) } + { + Ok( es ) => self.main.platform.egl_surface = Some( es ), + Err( e ) => eprintln!( "[ltk] {e} — drawing the window in software" ), + } + } + + /// Size the main surface to the window at the display's density, its buffers to match, and keep the view out of what the system bars and the keyboard cover. + pub( super ) fn fit_window( &mut self ) + { + let Some( ref window ) = self.main.platform.window else { return }; + let ( ww, wh ) = ( window.width().max( 1 ) as u32, window.height().max( 1 ) as u32 ); + let scale = density_scale( self.platform.activity.config().density() ); + let ( w, h ) = ( ( ww / scale ).max( 1 ), ( wh / scale ).max( 1 ) ); + let _ = window.set_buffers_geometry( ( w * scale ) as i32, ( h * scale ) as i32, Some( HardwareBufferFormat::R8G8B8A8_UNORM ) ); + self.platform.px_per_logical = ( ww as f64 / w as f64, wh as f64 / h as f64 ); + let edges = super::java::window_insets( &self.platform.activity ) + .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 + { + self.main.scale_factor = scale as i32; + if let Some( ref mut canvas ) = self.main.canvas { canvas.set_dpi_scale( scale as f32 ); } + } + self.main.set_size( w, h ); + self.on_main_resized( w, h ); + } + + /// The window is going away: let go of it before the system destroys it, and draw nothing until the next one. + pub( super ) fn detach_window( &mut self ) + { + if let Some( ref ctx ) = self.platform.egl_context { ctx.release_current(); } + self.main.platform.egl_surface = None; + self.main.platform.window = None; + self.main.configured = false; + self.main.frame_pending = false; + } +} diff --git a/src/platform/mock.rs b/src/platform/mock.rs new file mode 100644 index 0000000..d77791d --- /dev/null +++ b/src/platform/mock.rs @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! A platform for the runtime's tests: it talks to no windowing system and logs what the runtime asks of it. + +use std::cell::RefCell; + +use crate::draw::fit_canvas; +use crate::event_loop::SurfaceFocus; +use crate::event_loop::cursor_shape::ResizeEdge; +use crate::osk::Purpose; +use crate::render::Canvas; +use crate::types::{ CursorShape, Rect }; + +use super::{ Platform, Presentation }; + +/// One request the runtime made of the platform. +#[ derive( Debug, Clone, PartialEq ) ] +pub( crate ) enum Request +{ + Present { frame: Option, damage: Vec, size: ( u32, u32 ) }, + Frame( SurfaceFocus ), + Cursor( CursorShape ), + TextInput { secure: bool, purpose: Purpose }, + TextInputOff, + TextInputRenewed { secure: bool, purpose: Purpose }, + Clipboard( String ), + PrimarySelection( String ), + Move, + Resize( ResizeEdge ), + Minimize, + Fullscreen( bool ), +} + +#[ derive( Default ) ] +pub( crate ) struct MockPlatform +{ + log: RefCell>, + text_input: bool, + /// Whether the main surface is a window the user can move and resize. + pub toplevel: bool, + /// Make every presentation fail, as when the system has no buffer to give. + pub fail_present: bool, +} + +impl MockPlatform +{ + /// The requests made since the last call. + pub( crate ) fn take_requests( &self ) -> Vec + { + std::mem::take( &mut *self.log.borrow_mut() ) + } + + fn record( &self, request: Request ) + { + self.log.borrow_mut().push( request ); + } +} + +impl Platform for MockPlatform +{ + type Surface = (); + type Output = (); + + fn is_toplevel( &self, _main: &() ) -> bool + { + self.toplevel + } + + fn back_leaves( &self, _main: &() ) -> bool + { + self.toplevel + } + + fn begin_window_move( &self, _main: &() ) + { + self.record( Request::Move ); + } + + fn begin_window_resize( &self, _main: &(), edge: ResizeEdge ) + { + self.record( Request::Resize( edge ) ); + } + + fn minimize_window( &self, _main: &() ) + { + self.record( Request::Minimize ); + } + + fn set_fullscreen( &self, _main: &(), fullscreen: bool ) -> bool + { + self.record( Request::Fullscreen( fullscreen ) ); + self.toplevel + } + + fn request_frame( &self, _surface: &(), focus: SurfaceFocus ) -> bool + { + self.record( Request::Frame( focus ) ); + true + } + + fn precise_damage( &self, _surface: &() ) -> bool + { + true + } + + fn prepare_canvas( &self, _surface: &mut (), canvas: &mut Option, pw: u32, ph: u32, scale: u32 ) -> bool + { + fit_canvas( canvas, pw, ph, scale, || Canvas::new( pw, ph ) ); + true + } + + fn present( &self, _surface: &mut (), canvas: &mut Canvas, frame: &Presentation<'_> ) -> bool + { + if self.fail_present { return false; } + self.record( Request::Present { frame: frame.frame, damage: frame.damage.to_vec(), size: canvas.size() } ); + true + } + + fn set_cursor_shape( &mut self, shape: CursorShape ) -> bool + { + self.record( Request::Cursor( shape ) ); + true + } + + fn text_input_active( &self ) -> bool + { + self.text_input + } + + fn activate_text_input( &mut self, secure: bool, purpose: Purpose ) + { + self.text_input = true; + self.record( Request::TextInput { secure, purpose } ); + } + + fn deactivate_text_input( &mut self ) + { + self.text_input = false; + self.record( Request::TextInputOff ); + } + + fn reenable_text_input( &self, secure: bool, purpose: Purpose ) + { + if self.text_input { self.record( Request::TextInputRenewed { secure, purpose } ); } + } + + fn publish_clipboard( &mut self, text: &str ) + { + self.record( Request::Clipboard( text.to_string() ) ); + } + + fn publish_primary_selection( &mut self, text: &str ) + { + self.record( Request::PrimarySelection( text.to_string() ) ); + } +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs new file mode 100644 index 0000000..34d398b --- /dev/null +++ b/src/platform/mod.rs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Platform backends: what ties the runtime to a windowing system. +//! +//! The runtime (`event_loop/`, `input/`, `draw/`) holds the app, the widget tree, focus, gestures, text editing and every `App` callback, and is the same on every platform. A backend feeds it input through the entry points of [`crate::input::event`], keeps its own half of every surface in `SurfaceState::platform`, and carries out the requests of [`Platform`]. +//! +//! 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. + +#[ cfg( feature = "android" ) ] +pub( crate ) mod android; +#[ cfg( feature = "wayland" ) ] +pub( crate ) mod wayland; +#[ cfg( test ) ] +pub( crate ) mod mock; + +#[ cfg( feature = "android" ) ] +pub( crate ) use android::run::{ run, try_run }; +#[ cfg( feature = "wayland" ) ] +pub( crate ) use wayland::run::{ run, try_run }; + +use crate::event_loop::SurfaceFocus; +use crate::event_loop::cursor_shape::ResizeEdge; +use crate::osk::Purpose; +use crate::render::Canvas; +use crate::types::{ Color, CursorShape, Rect }; + +/// The backend the runtime runs on, held by `AppData::platform`. +#[ cfg( feature = "wayland" ) ] +pub( crate ) type NativePlatform = wayland::WaylandPlatform; +#[ cfg( feature = "android" ) ] +pub( crate ) type NativePlatform = android::AndroidPlatform; + +/// 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; + +/// What [`Platform::present`] needs to know about a painted frame. Rects are in physical pixels, top-left origin. +pub( crate ) struct Presentation<'a> +{ + /// What changed since the last frame; empty for the whole surface. + pub damage: &'a [Rect], + /// The surface is opaque where `bg` is, and elsewhere over `opaque_region`. + pub bg: Color, + 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. + pub width: u32, + pub height: u32, + pub scale: u32, + /// 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, +} + +/// What the runtime asks of the windowing system. `AppData::platform` implements it. +/// +/// Drawing a surface takes three calls: [`Self::prepare_canvas`], then the runtime paints the widget tree on the canvas, then [`Self::present`]. The canvas is the surface's own and keeps its pixels between frames, so a partial repaint only touches the damaged rects, and presenting adds no copy beyond the one into the system's buffer that a software canvas always needs. +pub( crate ) trait Platform: 'static +{ + /// The backend's half of a surface, held by `SurfaceState::platform`. + type Surface; + + /// A display that one of `AppData::secondaries` covers. + type Output; + + /// Whether a full repaint of `surface` should report the rects that changed rather than the whole surface. + 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; + + /// 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; + + /// Offer `text`, which the runtime also keeps in `AppData::clipboard`, to other applications as the clipboard. + fn publish_clipboard( &mut self, text: &str ); + + /// Offer `text`, which the runtime also keeps in `AppData::primary_selection`, to other applications as the selection the middle button pastes; no-op where there is none. + fn publish_primary_selection( &mut self, text: &str ); + + /// Whether `main` is an application window the user can move, resize and minimise, rather than a shell or lock surface. + fn is_toplevel( &self, main: &Self::Surface ) -> bool; + + /// Whether back with nothing left to go back to may minimise or close `main`, as [`crate::app::BackAtRoot`] says: an application's window or activity, not a shell or lock surface. + fn back_leaves( &self, main: &Self::Surface ) -> bool; + + /// Let the system move the window with the pointer whose left button was just pressed. + fn begin_window_move( &self, main: &Self::Surface ); + + /// Let the system resize the window from `edge` with the pointer whose left button was just pressed. + fn begin_window_resize( &self, main: &Self::Surface, edge: ResizeEdge ); + + fn minimize_window( &self, main: &Self::Surface ); + + /// `false` when `main` is not a window, so the runtime asks again later. + fn set_fullscreen( &self, main: &Self::Surface, fullscreen: bool ) -> bool; + + /// Ask for a frame of `surface`, reported as one of `focus`, without presenting a new buffer; `false` while the surface does not exist yet. + fn request_frame( &self, surface: &Self::Surface, focus: SurfaceFocus ) -> bool; + + /// `false` when the system lets the app set no cursor shape. + fn set_cursor_shape( &mut self, shape: CursorShape ) -> bool; + + /// Whether the input method is serving a text field or the app. + fn text_input_active( &self ) -> bool; + + /// Ask the input method for text, or renew the request so a keyboard the system folded comes back. + fn activate_text_input( &mut self, secure: bool, purpose: Purpose ); + + fn deactivate_text_input( &mut self ); + + /// Send an active request again with a new purpose; no-op when none is active. + fn reenable_text_input( &self, secure: bool, purpose: Purpose ); +} diff --git a/src/platform/wayland/cursor.rs b/src/platform/wayland/cursor.rs new file mode 100644 index 0000000..da02612 --- /dev/null +++ b/src/platform/wayland/cursor.rs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape; + +use crate::app::App; +use crate::types::CursorShape; + +use super::WaylandPlatform; + +pub( super ) fn set_shape( platform: &WaylandPlatform, shape: CursorShape ) -> bool +{ + let Some( device ) = platform.cursor_shape_device.as_ref() else { return false }; + device.set_shape( platform.last_pointer_enter_serial, wp_shape( shape ) ); + true +} + +fn wp_shape( shape: CursorShape ) -> Shape +{ + use CursorShape as C; + match shape + { + C::Default => Shape::Default, + C::ContextMenu => Shape::ContextMenu, + C::Help => Shape::Help, + C::Pointer => Shape::Pointer, + C::Progress => Shape::Progress, + C::Wait => Shape::Wait, + C::Cell => Shape::Cell, + C::Crosshair => Shape::Crosshair, + C::Text => Shape::Text, + C::VerticalText => Shape::VerticalText, + C::Alias => Shape::Alias, + C::Copy => Shape::Copy, + C::Move => Shape::Move, + C::NoDrop => Shape::NoDrop, + C::NotAllowed => Shape::NotAllowed, + C::Grab => Shape::Grab, + C::Grabbing => Shape::Grabbing, + C::EResize => Shape::EResize, + C::NResize => Shape::NResize, + C::NeResize => Shape::NeResize, + C::NwResize => Shape::NwResize, + C::SResize => Shape::SResize, + C::SeResize => Shape::SeResize, + C::SwResize => Shape::SwResize, + C::WResize => Shape::WResize, + C::EwResize => Shape::EwResize, + C::NsResize => Shape::NsResize, + C::NeswResize => Shape::NeswResize, + C::NwseResize => Shape::NwseResize, + C::ColResize => Shape::ColResize, + C::RowResize => Shape::RowResize, + C::AllScroll => Shape::AllScroll, + C::ZoomIn => Shape::ZoomIn, + C::ZoomOut => Shape::ZoomOut, + } +} diff --git a/src/event_loop/data_device.rs b/src/platform/wayland/data_device.rs similarity index 80% rename from src/event_loop/data_device.rs rename to src/platform/wayland/data_device.rs index 1dc4887..b558787 100644 --- a/src/event_loop/data_device.rs +++ b/src/platform/wayland/data_device.rs @@ -8,7 +8,7 @@ //! Wayland selection so that: //! //! * **Outbound (Ctrl+C / Cut)** — after the local clipboard is -//! populated, [`AppData::publish_clipboard_selection`] creates a +//! populated, [`publish_clipboard`] creates a //! [`CopyPasteSource`] offering `text/plain;charset=utf-8` and //! installs it as the seat's selection. When a peer pastes, //! [`DataSourceHandler::send`] writes the cached string into the @@ -50,7 +50,9 @@ use smithay_client_toolkit::reexports::client:: }; use crate::app::App; -use super::app_data::AppData; +use crate::event_loop::AppData; + +use super::WaylandPlatform; /// MIME type we advertise on outbound copies and request on inbound /// pastes. `text/plain;charset=utf-8` is the de-facto cross-toolkit @@ -77,24 +79,14 @@ pub( crate ) fn drop_inbox() -> ( std::sync::mpsc::Sender, std::syn std::sync::mpsc::channel() } -impl AppData +/// Offer the clipboard as the seat's selection so other applications can paste it; `AppData::clipboard` holds its text. No-op when the compositor does not advertise `wl_data_device_manager` or when no seat has appeared yet. +pub( crate ) fn publish_clipboard( platform: &mut WaylandPlatform ) { - /// Publish the current `self.clipboard` value as the seat's - /// selection so other applications can paste it. Called by the - /// copy / cut handlers after they populate the local buffer. - /// No-op when the compositor does not advertise - /// `wl_data_device_manager` or when no seat has appeared yet. - pub( crate ) fn publish_clipboard_selection( &mut self ) - { - let ( Some( ref ddm ), Some( ref dd ) ) = ( self.data_device_manager.as_ref(), self.data_device.as_ref() ) else { return }; - let source = ddm.create_copy_paste_source( &self.qh, CLIPBOARD_MIMES.iter().copied() ); - source.set_selection( dd, self.last_input_serial ); - // Hold the source alive: the compositor only keeps the - // selection while the `WlDataSource` is not dropped. We also - // need to hang on to it so `DataSourceHandler::send` can find - // the cached text via `self.clipboard`. - self.clipboard_source = Some( source ); - } + let ( Some( ddm ), Some( dd ) ) = ( platform.data_device_manager.as_ref(), platform.data_device.as_ref() ) else { return }; + let source = ddm.create_copy_paste_source( &platform.qh, CLIPBOARD_MIMES.iter().copied() ); + source.set_selection( dd, platform.last_input_serial ); + // The compositor keeps the selection only while the source lives, and `DataSourceHandler::send` serves pastes through it. + platform.clipboard_source = Some( source ); } impl DataDeviceHandler for AppData @@ -115,24 +107,24 @@ impl DataDeviceHandler for AppData let accepted = DND_MIMES.iter().copied() .find( |c| mimes.iter().any( |m| m == *c ) ) .map( |s| s.to_string() ); - offer.accept_mime_type( self.last_input_serial, accepted.clone() ); + offer.accept_mime_type( self.platform.last_input_serial, accepted.clone() ); offer.set_actions( smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction::Copy, smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction::Copy ); - self.drop_position = Some( ( x, y ) ); - self.drop_mime = accepted; + self.platform.drop_position = Some( ( x, y ) ); + self.platform.drop_mime = accepted; self.app.on_drop_motion( x as f32, y as f32 ); } fn leave( &mut self, _c: &Connection, _qh: &QueueHandle, _d: &WlDataDevice ) { - if self.drop_position.is_some() { self.app.on_drop_leave(); } - self.drop_position = None; - self.drop_mime = None; + if self.platform.drop_position.is_some() { self.app.on_drop_leave(); } + self.platform.drop_position = None; + self.platform.drop_mime = None; } fn motion( &mut self, _c: &Connection, _qh: &QueueHandle, _d: &WlDataDevice, x: f64, y: f64 ) { - self.drop_position = Some( ( x, y ) ); + self.platform.drop_position = Some( ( x, y ) ); self.app.on_drop_motion( x as f32, y as f32 ); } @@ -140,11 +132,11 @@ impl DataDeviceHandler for AppData { let Some( data ) = d.data::() else { return }; let Some( offer ) = data.drag_offer() else { return }; - let Some( mime ) = self.drop_mime.clone() else { offer.finish(); return }; - let ( x, y ) = self.drop_position.unwrap_or( ( 0.0, 0.0 ) ); + let Some( mime ) = self.platform.drop_mime.clone() else { offer.finish(); return }; + let ( x, y ) = self.platform.drop_position.unwrap_or( ( 0.0, 0.0 ) ); let Ok( pipe ) = offer.receive( mime.clone() ) else { offer.finish(); return }; offer.finish(); - let tx = self.drop_inbox_tx.clone(); + let tx = self.platform.drop_inbox_tx.clone(); std::thread::spawn( move || { use std::io::Read; @@ -154,8 +146,8 @@ impl DataDeviceHandler for AppData let _ = bounded.read_to_string( &mut buf ); let _ = tx.send( DropPayload { mime, text: buf, x, y } ); } ); - self.drop_position = None; - self.drop_mime = None; + self.platform.drop_position = None; + self.platform.drop_mime = None; } /// A new selection arrived from another client. Negotiate @@ -170,7 +162,7 @@ impl DataDeviceHandler for AppData let Some( offer ) = data.selection_offer() else { return }; let Some( mime ) = offer.with_mime_types( pick_text_mime ) else { return }; let Ok( pipe ) = offer.receive( mime ) else { return }; - read_selection( pipe, self.clipboard_inbox_tx.clone() ); + read_selection( pipe, self.platform.clipboard_inbox_tx.clone() ); } } @@ -219,7 +211,7 @@ impl DataSourceHandler for AppData // the compositor dropping the previous selection) the peer // might still ask for its data; we just write nothing rather // than leak the new clipboard contents through an old source. - let is_active = self.clipboard_source.as_ref() + let is_active = self.platform.clipboard_source.as_ref() .map( |s| s.inner() == source ) .unwrap_or( false ); if !is_active { return; } @@ -231,9 +223,9 @@ impl DataSourceHandler for AppData // Compositor dropped our source (another client claimed the // selection). Drop the cached handle so a future copy creates // a fresh one. - if self.clipboard_source.as_ref().map( |s| s.inner() == source ).unwrap_or( false ) + if self.platform.clipboard_source.as_ref().map( |s| s.inner() == source ).unwrap_or( false ) { - self.clipboard_source = None; + self.platform.clipboard_source = None; } } @@ -251,11 +243,3 @@ pub( super ) fn pick_text_mime( mimes: &[ String ] ) -> Option .find( |c| mimes.iter().any( |m| m == *c ) ) .map( |s| s.to_string() ) } - -/// Buffered channel pair used to ferry inbound-selection bytes from -/// the read-pipe worker thread to the event loop. The receiver is -/// drained once per iteration inside the main run loop. -pub( crate ) fn clipboard_inbox() -> ( mpsc::Sender, mpsc::Receiver ) -{ - mpsc::channel() -} diff --git a/src/event_loop/handlers.rs b/src/platform/wayland/handlers.rs similarity index 78% rename from src/event_loop/handlers.rs rename to src/platform/wayland/handlers.rs index 937bca4..a28bd56 100644 --- a/src/event_loop/handlers.rs +++ b/src/platform/wayland/handlers.rs @@ -43,13 +43,15 @@ use wayland_protocols::wp::text_input::zv3::client:: }; use crate::app::{ App, ToplevelEvent }; -use super::app_data::AppData; +use crate::event_loop::{ AppData, SurfaceFocus, SurfaceState }; + +use super::{ SurfaceKind, WaylandSurfaceState }; /// Adopt a new buffer scale on `ss`: resize whichever rendering target is /// active and request a redraw. Returns the new physical size, or `None` /// when the factor is unchanged. fn apply_surface_scale( - ss: &mut super::SurfaceState, + ss: &mut SurfaceState, shm: &Shm, surface: &WlSurface, new_factor: i32, @@ -65,15 +67,15 @@ fn apply_surface_scale( let pw = ss.width * new_factor as u32; let ph = ss.height * new_factor as u32; // The two targets are mutually exclusive so only one branch runs. - if let Some( ref es ) = ss.egl_surface + if let Some( ref es ) = ss.platform.egl_surface { es.resize( pw as i32, ph as i32 ); // Canvas FBO reallocation is deferred to the draw path: it needs // `eglMakeCurrent` first. - } else if ss.pool.is_some() { + } else if ss.platform.pool.is_some() { // Before the first configure neither path is chosen yet; creating a // pool here would lock the surface to SHM. - ss.pool = Some( + ss.platform.pool = Some( smithay_client_toolkit::shm::slot::SlotPool::new( ( pw * ph * 4 ) as usize, shm, ).expect( "pool" ), @@ -95,20 +97,20 @@ impl CompositorHandler for AppData let Some( focus ) = self.focus_for_surface( surface ) else { // Secondary per-output surfaces live outside the focus map. - let shm = &self.shm; + let shm = &self.platform.shm; if let Some( ( _, ss ) ) = self.secondaries.iter_mut() - .find( |( _, ss )| ss.surface.try_wl_surface() == Some( surface ) ) + .find( |( _, ss )| ss.platform.surface.try_wl_surface() == Some( surface ) ) { apply_surface_scale( ss, shm, surface, new_factor ); } return; }; let ( pw, ph ) = { - let shm = &self.shm; + let shm = &self.platform.shm; let ss = match focus { - super::SurfaceFocus::Main => &mut self.main, - super::SurfaceFocus::Overlay( id ) => match self.overlays.get_mut( &id ) + SurfaceFocus::Main => &mut self.main, + SurfaceFocus::Overlay( id ) => match self.overlays.get_mut( &id ) { Some( s ) => s, None => return, @@ -129,7 +131,7 @@ impl CompositorHandler for AppData // app-side state keyed off those pixels is now stale. Only fire // for the main surface — overlay resizes don't go through // `App::on_resize`. - if matches!( focus, super::SurfaceFocus::Main ) + if matches!( focus, SurfaceFocus::Main ) { crate::types::set_viewport_size( pw, ph ); crate::types::set_density( new_factor as f32 ); @@ -156,7 +158,7 @@ impl CompositorHandler for AppData fn surface_enter( &mut self, _: &Connection, _: &QueueHandle, surface: &WlSurface, output: &WlOutput ) { - if self.main.surface.try_wl_surface() == Some( surface ) + if self.main.platform.surface.try_wl_surface() == Some( surface ) { self.publish_output_size( Some( output ) ); } @@ -176,19 +178,19 @@ impl LayerShellHandler for AppData { if self.is_secondary_surface( layer.wl_surface() ) { - self.secondaries.retain( |( _, ss )| ss.surface.try_wl_surface() != Some( layer.wl_surface() ) ); + self.secondaries.retain( |( _, ss )| ss.platform.surface.try_wl_surface() != Some( layer.wl_surface() ) ); return; } match self.focus_for_surface( layer.wl_surface() ) { - Some( super::SurfaceFocus::Main ) | None => + Some( SurfaceFocus::Main ) | None => { if self.app.on_close_requested() { self.exit_requested = true; } } - Some( super::SurfaceFocus::Overlay( id ) ) => + Some( SurfaceFocus::Overlay( id ) ) => { // Compositor asked us to destroy this overlay. Drop it // synchronously *and* rewrite every per-device focus that @@ -219,23 +221,23 @@ impl LayerShellHandler for AppData // surface instead. for ( _, ss ) in self.secondaries.iter_mut() { - if ss.surface.try_wl_surface() == Some( layer.wl_surface() ) + if ss.platform.surface.try_wl_surface() == Some( layer.wl_surface() ) { - ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h ); + ss.on_configure( &self.platform.shm, self.platform.egl_context.as_ref(), w, h ); return; } } match self.focus_for_surface( layer.wl_surface() ) { - Some( super::SurfaceFocus::Main ) | None => + Some( SurfaceFocus::Main ) | None => { self.on_configure( w, h ); } - Some( super::SurfaceFocus::Overlay( id ) ) => + Some( SurfaceFocus::Overlay( id ) ) => { if let Some( ss ) = self.overlays.get_mut( &id ) { - ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h ); + ss.on_configure( &self.platform.shm, self.platform.egl_context.as_ref(), w, h ); } } } @@ -251,7 +253,7 @@ impl WindowHandler for AppData window: &Window, ) { - if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( window.wl_surface() ) + if let Some( SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( window.wl_surface() ) { if let Some( msg ) = self.overlay_dismiss_msg( id ) { @@ -274,33 +276,33 @@ impl WindowHandler for AppData _serial: u32, ) { - if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( window.wl_surface() ) + 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 ( rw, rh ) = ss.last_requested_size; + 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 ); let h = configure.new_size.1.map( |v| v.get() ).unwrap_or( fallback.1 ); window.xdg_surface().set_window_geometry( 0, 0, w as i32, h as i32 ); - ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h ); + ss.on_configure( &self.platform.shm, self.platform.egl_context.as_ref(), w, h ); return; } // Mutter ignores set_fullscreen sent before the surface is // mapped, so reapply on the first configure. - if self.pending_fullscreen + if self.platform.pending_fullscreen { window.set_fullscreen( None ); - self.pending_fullscreen = false; + self.platform.pending_fullscreen = false; } - if self.pending_size_hint_unpin + if self.platform.pending_size_hint_unpin { window.set_min_size( None ); window.set_max_size( None ); - self.pending_size_hint_unpin = false; + self.platform.pending_size_hint_unpin = false; } let tb = if configure.decoration_mode == DecorationMode::Server || configure.is_fullscreen() { 0.0 } - else { self.main.titlebar_base }; + else { self.main.platform.titlebar_base }; if self.main.titlebar_height != tb { self.main.titlebar_height = tb; @@ -308,7 +310,7 @@ impl WindowHandler for AppData self.view_dirty = true; } let fitted = self.fit_size_hint( window, configure.suggested_bounds ); - let ( hint_w, hint_h ) = self.fitted_size_hint.or( self.app.window_size_hint() ).unwrap_or( ( 800, 600 ) ); + let ( hint_w, hint_h ) = self.platform.fitted_size_hint.or( self.app.window_size_hint() ).unwrap_or( ( 800, 600 ) ); let ( w, h ) = if fitted { ( hint_w, hint_h ) @@ -327,7 +329,7 @@ impl ShmHandler for AppData { fn shm_state( &mut self ) -> &mut Shm { - &mut self.shm + &mut self.platform.shm } } @@ -335,7 +337,7 @@ impl OutputHandler for AppData { fn output_state( &mut self ) -> &mut OutputState { - &mut self.output_state + &mut self.platform.output_state } fn new_output( &mut self, _: &Connection, qh: &QueueHandle, output: WlOutput ) @@ -343,26 +345,26 @@ impl OutputHandler for AppData self.publish_output_size( None ); // If we were waiting for an output to assign the layer surface to, create it now. // Same for any overlays that were created before an output existed. - if let Some( ref layer_shell ) = self.layer_shell + if let Some( ref layer_shell ) = self.platform.layer_shell { - if self.main.surface.materialize( &self.compositor_state, layer_shell, qh, &output ) + if self.main.platform.surface.materialize( &self.platform.compositor_state, layer_shell, qh, &output ) { - self.main_output = Some( output.clone() ); + self.platform.main_output = Some( output.clone() ); } for ss in self.overlays.values_mut() { - ss.surface.materialize( &self.compositor_state, layer_shell, qh, &output ); + ss.platform.surface.materialize( &self.platform.compositor_state, layer_shell, qh, &output ); } // A background shell covers every output: each one beyond the // main's gets its own background layer surface, painted with // `App::secondary_view` — the wallpaper without any chrome. if self.app.shell_mode() == crate::app::ShellMode::Layer( crate::app::Layer::Background ) - && self.main_output.is_some() - && self.main_output.as_ref() != Some( &output ) + && self.platform.main_output.is_some() + && self.platform.main_output.as_ref() != Some( &output ) && !self.secondaries.iter().any( |( o, _ )| o == &output ) { use smithay_client_toolkit::shell::wlr_layer; - let surface = self.compositor_state.create_surface( qh ); + let surface = self.platform.compositor_state.create_surface( qh ); let layer_surface = layer_shell.create_layer_surface( qh, surface, @@ -380,23 +382,23 @@ impl OutputHandler for AppData layer_surface.commit(); self.secondaries.push( ( output.clone(), - super::SurfaceState::new( super::SurfaceKind::Layer( layer_surface ), 0.0, String::new() ), + SurfaceState::new( WaylandSurfaceState::from( SurfaceKind::Layer( layer_surface ) ), 0.0, String::new() ), ) ); } } // An output hotplugged while the session is locked would be blanked by // the compositor until it gets a lock surface of its own. - if let Some( ref lock ) = self.session_lock + if let Some( ref lock ) = self.platform.session_lock { - if matches!( self.main.surface, super::SurfaceKind::Lock( .. ) ) - && self.main_output.as_ref() != Some( &output ) + if matches!( self.main.platform.surface, SurfaceKind::Lock( .. ) ) + && self.platform.main_output.as_ref() != Some( &output ) && !self.secondaries.iter().any( |( o, _ )| o == &output ) { - let surface = self.compositor_state.create_surface( qh ); + let surface = self.platform.compositor_state.create_surface( qh ); let lock_surface = lock.create_lock_surface( surface, &output, qh ); self.secondaries.push( ( output, - super::SurfaceState::new( super::SurfaceKind::Lock( lock_surface ), 0.0, String::new() ), + SurfaceState::new( WaylandSurfaceState::from( SurfaceKind::Lock( lock_surface ) ), 0.0, String::new() ), ) ); } } @@ -410,9 +412,9 @@ impl OutputHandler for AppData fn output_destroyed( &mut self, _: &Connection, _: &QueueHandle, output: WlOutput ) { self.secondaries.retain( |( o, _ )| o != &output ); - if self.main_output.as_ref() == Some( &output ) + if self.platform.main_output.as_ref() == Some( &output ) { - self.main_output = None; + self.platform.main_output = None; } self.publish_output_size( None ); } @@ -422,7 +424,7 @@ impl SeatHandler for AppData { fn seat_state( &mut self ) -> &mut SeatState { - &mut self.seat_state + &mut self.platform.seat_state } fn new_seat( @@ -435,11 +437,11 @@ impl SeatHandler for AppData // device-bound clipboard semantics by construction because // `wl_data_device_manager.get_data_device` takes the seat as // argument and the compositor manages routing. - if self.data_device.is_none() + if self.platform.data_device.is_none() { - if let Some( ref ddm ) = self.data_device_manager + if let Some( ref ddm ) = self.platform.data_device_manager { - self.data_device = Some( ddm.get_data_device( qh, &seat ) ); + self.platform.data_device = Some( ddm.get_data_device( qh, &seat ) ); } } self.bind_primary_selection( qh, &seat ); @@ -455,30 +457,30 @@ impl SeatHandler for AppData { match capability { - Capability::Keyboard if self.keyboard.is_none() => + Capability::Keyboard if self.platform.keyboard.is_none() => { - self.keyboard = Some( - self.seat_state + self.platform.keyboard = Some( + self.platform.seat_state .get_keyboard( qh, &seat, None ) .expect( "keyboard" ), ); } - Capability::Pointer if self.pointer.is_none() => + Capability::Pointer if self.platform.pointer.is_none() => { - let pointer = self.seat_state + let pointer = self.platform.seat_state .get_pointer( qh, &seat ) .expect( "pointer" ); // Create a per-pointer cursor-shape device when the // compositor advertises wp_cursor_shape_v1. The device // outlives the WlPointer we just created and is what // `set_shape(serial, shape)` is called on. - if let Some( ref mgr ) = self.cursor_shape_manager + if let Some( ref mgr ) = self.platform.cursor_shape_manager { - self.cursor_shape_device = Some( mgr.get_shape_device( &pointer, qh ) ); + self.platform.cursor_shape_device = Some( mgr.get_shape_device( &pointer, qh ) ); } - self.pointer = Some( pointer ); + self.platform.pointer = Some( pointer ); } - Capability::Touch if self.touch.is_none() => + Capability::Touch if self.platform.touch.is_none() => { // Touch coming back (resume on devices that power the // touchscreen down at suspend): clear anything the removal @@ -487,8 +489,8 @@ impl SeatHandler for AppData { eprintln!( "[ltk] touch capability re-added — cleared touch state stranded across the gap" ); } - self.touch = Some( - self.seat_state + self.platform.touch = Some( + self.platform.seat_state .get_touch( qh, &seat ) .expect( "touch" ), ); @@ -507,11 +509,11 @@ impl SeatHandler for AppData { match capability { - Capability::Keyboard => { self.keyboard = None; } - Capability::Pointer => { self.pointer = None; } + Capability::Keyboard => { self.platform.keyboard = None; } + Capability::Pointer => { self.platform.pointer = None; } Capability::Touch => { - self.touch = None; + self.platform.touch = None; // The touchscreen is gone before any pending up / cancel for // the last touch could arrive; drop the stale gesture state // now rather than stranding it until the next clean release. @@ -540,11 +542,11 @@ impl PopupHandler for AppData ) { let ( w, h ) = ( config.width.max( 1 ) as u32, config.height.max( 1 ) as u32 ); - if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( popup.wl_surface() ) + if let Some( SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( popup.wl_surface() ) { if let Some( ss ) = self.overlays.get_mut( &id ) { - ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h ); + ss.on_configure( &self.platform.shm, self.platform.egl_context.as_ref(), w, h ); } } } @@ -556,7 +558,7 @@ impl PopupHandler for AppData popup: &Popup, ) { - if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( popup.wl_surface() ) + if let Some( SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( popup.wl_surface() ) { if let Some( msg ) = self.overlay_dismiss_msg( id ) { @@ -578,25 +580,25 @@ impl SessionLockHandler for AppData // Every output needs its own lock surface — the compositor blanks any // output left without one. The first hosts the app's main surface; // the rest render `App::secondary_view`. - let outputs: Vec = self.output_state.outputs().collect(); + let outputs: Vec = self.platform.output_state.outputs().collect(); let mut outputs = outputs.into_iter(); if let Some( output ) = outputs.next() { - let surface = self.compositor_state.create_surface( qh ); + let surface = self.platform.compositor_state.create_surface( qh ); let lock_surface = session_lock.create_lock_surface( surface, &output, qh ); - self.main.surface = super::SurfaceKind::Lock( lock_surface ); - self.main_output = Some( output ); + self.main.platform.surface = SurfaceKind::Lock( lock_surface ); + self.platform.main_output = Some( output ); } for output in outputs { - let surface = self.compositor_state.create_surface( qh ); + let surface = self.platform.compositor_state.create_surface( qh ); let lock_surface = session_lock.create_lock_surface( surface, &output, qh ); self.secondaries.push( ( output, - super::SurfaceState::new( super::SurfaceKind::Lock( lock_surface ), 0.0, String::new() ), + SurfaceState::new( WaylandSurfaceState::from( SurfaceKind::Lock( lock_surface ) ), 0.0, String::new() ), ) ); } - self.session_lock = Some( session_lock ); + self.platform.session_lock = Some( session_lock ); } fn finished( &mut self, _conn: &Connection, _qh: &QueueHandle, _session_lock: SessionLock ) @@ -617,16 +619,16 @@ impl SessionLockHandler for AppData let ( w, h ) = configure.new_size; let ( w, h ) = ( w.max( 1 ), h.max( 1 ) ); let wl = surface.wl_surface(); - if self.main.surface.try_wl_surface() == Some( wl ) + if self.main.platform.surface.try_wl_surface() == Some( wl ) { self.on_configure( w, h ); return; } for ( _, ss ) in self.secondaries.iter_mut() { - if ss.surface.try_wl_surface() == Some( wl ) + if ss.platform.surface.try_wl_surface() == Some( wl ) { - ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h ); + ss.on_configure( &self.platform.shm, self.platform.egl_context.as_ref(), w, h ); return; } } @@ -673,9 +675,9 @@ impl smithay_client_toolkit::activation::ActivationHandler for AppData( &wl, token ); + activation.activate::( wl, token ); } } } @@ -684,7 +686,7 @@ impl ProvidesRegistryState for AppData { fn registry( &mut self ) -> &mut RegistryState { - &mut self.registry_state + &mut self.platform.registry_state } registry_handlers![ OutputState, SeatState ]; } @@ -735,7 +737,7 @@ impl ForeignToplevelListHandler for AppData { fn foreign_toplevel_list_state( &mut self ) -> &mut ForeignToplevelList { - &mut self.foreign_toplevel_list + &mut self.platform.foreign_toplevel_list } fn new_toplevel( @@ -745,7 +747,7 @@ impl ForeignToplevelListHandler for AppData handle: ExtForeignToplevelHandleV1, ) { - let event = toplevel_opened( &self.foreign_toplevel_list, &handle ); + let event = toplevel_opened( &self.platform.foreign_toplevel_list, &handle ); if let Some( msg ) = self.app.on_toplevel_event( event ) { self.pending_msgs.push( msg ); @@ -766,7 +768,7 @@ impl ForeignToplevelListHandler for AppData // can treat repeated opens as idempotent and pay nothing here, // but the app_id-can-change-mid-life case still produces a // correct sequence (old refcount drops, new one bumps). - let event = toplevel_opened( &self.foreign_toplevel_list, &handle ); + let event = toplevel_opened( &self.platform.foreign_toplevel_list, &handle ); if let Some( msg ) = self.app.on_toplevel_event( event ) { self.pending_msgs.push( msg ); @@ -816,80 +818,25 @@ impl Dispatch for AppData // While the app is animating we re-arm the surface for redraw inline so the // loop keeps ticking at the compositor's pace without needing a fixed-period // timer. -impl Dispatch for AppData +impl Dispatch for AppData { fn event( state: &mut Self, _proxy: &WlCallback, _event: wl_callback::Event, - focus: &super::SurfaceFocus, + focus: &SurfaceFocus, _conn: &Connection, _qh: &QueueHandle, ) { - let is_animating = state.app.is_animating(); - match *focus + // The main surface is taken to host subsurfaces, as it always was: the subsurface reconcile re-arms its callback. + let hosts_subsurfaces = match *focus { - super::SurfaceFocus::Main => - { - state.main.frame_pending = false; - if is_animating - { - if state.app.subsurface_motion_only() - { - // The main buffer is unchanged; the subsurface - // reconcile applies the latest move on this tick and - // re-arms the callback. - state.main.frame_tick_due = true; - } - else - { - // Perf guardrails: run the opt-in diagnostics and apply - // the software animation-rate cap (~30 Hz). A throttled - // frame keeps the vsync cadence with a bare callback but - // skips the re-raster. - let software = crate::render::is_software_render(); - let cap = state.app.cap_software_animation(); - if state.perf.animated_frame( software, cap ) - { - state.view_dirty = true; - state.main.request_redraw(); - } - else if let Some( wl ) = state.main.surface.try_wl_surface().cloned() - { - let _ = wl.frame( &state.qh, super::SurfaceFocus::Main ); - wl.commit(); - state.main.frame_pending = true; - } - } - } - else - { - state.perf.animation_stopped(); - } - } - super::SurfaceFocus::Overlay( id ) => - { - let hosts_subsurface = state.subsurfaces.values() - .any( |s| s.parent() == crate::app::SubsurfaceParent::Overlay( id ) ); - if let Some( ss ) = state.overlays.get_mut( &id ) - { - ss.frame_pending = false; - if is_animating - { - if hosts_subsurface && state.app.subsurface_motion_only() - { - ss.frame_tick_due = true; - } - else - { - state.overlays_dirty = true; - ss.request_redraw(); - } - } - } - } - } + SurfaceFocus::Main => true, + SurfaceFocus::Overlay( id ) => state.platform.subsurfaces.values() + .any( |s| s.parent() == crate::app::SubsurfaceParent::Overlay( id ) ), + }; + state.frame_presented( *focus, hosts_subsurfaces ); } } diff --git a/src/platform/wayland/input.rs b/src/platform/wayland/input.rs new file mode 100644 index 0000000..54a971d --- /dev/null +++ b/src/platform/wayland/input.rs @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The Wayland backend's input: `wl_keyboard`, `wl_pointer` and `wl_touch` turned into the events of [`crate::input::event`]. +//! +//! Each handler records what only later Wayland requests read (the serials a window move or resize, a selection, a popup grab and the cursor shape quote back to the compositor), resolves the `wl_surface` to a [`SurfaceFocus`], drops pointer and touch events on the secondary per-output surfaces, whose coordinates would hit-test the main surface's widgets, and forwards the rest to the runtime's entry points. No input logic lives here, so this file is the template for another platform's backend. + +use smithay_client_toolkit::seat::keyboard:: +{ + KeyboardHandler, KeyEvent, Keysym, Modifiers, RawModifiers, RepeatInfo, +}; +use smithay_client_toolkit::seat::pointer:: +{ + AxisScroll, PointerEvent, PointerEventKind, PointerHandler, + BTN_BACK, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, BTN_SIDE, +}; +use smithay_client_toolkit::seat::touch::TouchHandler; +use smithay_client_toolkit::reexports::client:: +{ + protocol:: + { + wl_keyboard::WlKeyboard, + wl_pointer::{ self, WlPointer }, + wl_surface::WlSurface, + wl_touch::WlTouch, + }, + Connection, QueueHandle, +}; + +use crate::app::App; +use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::input::event::{ Axis, AxisSource, KeyInput, PointerButton, PointerInput, PointerKind, TouchInput, TouchPhase }; + +fn key_input( event: KeyEvent ) -> KeyInput +{ + KeyInput { keysym: event.keysym, keycode: event.raw_code, text: event.utf8 } +} + +fn pointer_button( code: u32 ) -> PointerButton +{ + match code + { + BTN_LEFT => PointerButton::Left, + BTN_RIGHT => PointerButton::Right, + BTN_MIDDLE => PointerButton::Middle, + BTN_SIDE | BTN_BACK => PointerButton::Back, + _ => PointerButton::Other, + } +} + +/// High-resolution detents when the compositor sends them, the older whole ones otherwise. +fn axis( scroll: AxisScroll ) -> Axis +{ + let notches = if scroll.value120 != 0 + { + scroll.value120 as f32 / 120.0 + } else { + scroll.discrete as f32 + }; + Axis { distance: scroll.absolute, notches } +} + +fn axis_source( source: Option ) -> AxisSource +{ + match source + { + Some( wl_pointer::AxisSource::Wheel ) => AxisSource::Wheel, + Some( wl_pointer::AxisSource::Finger | wl_pointer::AxisSource::Continuous ) => AxisSource::Continuous, + _ => AxisSource::Other, + } +} + +impl KeyboardHandler for AppData +{ + fn enter( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &WlKeyboard, + surface: &WlSurface, + _serial: u32, + _raw: &[u32], + _keysyms: &[Keysym], + ) + { + let focus = self.focus_for_surface( surface ).unwrap_or( SurfaceFocus::Main ); + self.keyboard_enter( focus ); + } + + fn leave( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &WlKeyboard, + _surface: &WlSurface, + _serial: u32, + ) + { + self.keyboard_leave(); + } + + fn press_key( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &WlKeyboard, + serial: u32, + event: KeyEvent, + ) + { + self.platform.last_input_serial = serial; + self.key_press( key_input( event ) ); + } + + fn release_key( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &WlKeyboard, + _serial: u32, + event: KeyEvent, + ) + { + self.key_release( &key_input( event ) ); + } + + fn update_modifiers( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &WlKeyboard, + _serial: u32, + modifiers: Modifiers, + _raw_modifiers: RawModifiers, + _layout: u32, + ) + { + self.modifiers_changed( modifiers.ctrl, modifiers.shift, modifiers.alt ); + } + + fn update_repeat_info( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &WlKeyboard, + info: RepeatInfo, + ) + { + match info + { + RepeatInfo::Repeat { rate, delay } => self.set_repeat_info( rate.get(), delay ), + RepeatInfo::Disable => self.set_repeat_info( 0, 0 ), + } + } + + fn repeat_key( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &WlKeyboard, + _serial: u32, + event: KeyEvent, + ) + { + self.key_repeated( key_input( event ) ); + } +} + +impl PointerHandler for AppData +{ + fn pointer_frame( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _pointer: &WlPointer, + events: &[PointerEvent], + ) + { + for event in events + { + if self.is_secondary_surface( &event.surface ) { continue; } + let focus = self.focus_for_surface( &event.surface ).unwrap_or( SurfaceFocus::Main ); + let kind = match event.kind + { + PointerEventKind::Enter { serial } => + { + self.platform.last_pointer_enter_serial = serial; + PointerKind::Enter + } + PointerEventKind::Leave { .. } => PointerKind::Leave, + PointerEventKind::Motion { .. } => PointerKind::Motion, + PointerEventKind::Press { button, serial, .. } => + { + let button = pointer_button( button ); + if matches!( button, PointerButton::Left | PointerButton::Middle ) + { + self.platform.last_pointer_serial = serial; + self.platform.last_input_serial = serial; + } + PointerKind::Press( button ) + } + PointerEventKind::Release { button, .. } => PointerKind::Release( pointer_button( button ) ), + PointerEventKind::Axis { horizontal, vertical, source, .. } => PointerKind::Axis + { + horizontal: axis( horizontal ), + vertical: axis( vertical ), + source: axis_source( source ), + }, + }; + self.pointer_input( &PointerInput { focus, position: event.position, kind } ); + } + } +} + +impl TouchHandler for AppData +{ + fn down( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _touch: &WlTouch, + serial: u32, + time: u32, + surface: WlSurface, + id: i32, + position: ( f64, f64 ), + ) + { + self.platform.last_input_serial = serial; + if self.is_secondary_surface( &surface ) { return; } + let focus = self.focus_for_surface( &surface ).unwrap_or( SurfaceFocus::Main ); + self.touch_input( &TouchInput { id, time, phase: TouchPhase::Down { focus, position } } ); + } + + fn up( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _touch: &WlTouch, + _serial: u32, + time: u32, + id: i32, + ) + { + self.touch_input( &TouchInput { id, time, phase: TouchPhase::Up } ); + } + + fn motion( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _touch: &WlTouch, + time: u32, + id: i32, + position: ( f64, f64 ), + ) + { + self.touch_input( &TouchInput { id, time, phase: TouchPhase::Motion { position } } ); + } + + fn shape( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _touch: &WlTouch, + _id: i32, + _major: f64, + _minor: f64, + ) {} + + fn orientation( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _touch: &WlTouch, + _id: i32, + _orientation: f64, + ) {} + + fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle, _touch: &WlTouch ) + { + self.reset_touch_state(); + } +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + #[ test ] + fn evdev_buttons_are_named() + { + assert_eq!( pointer_button( 0x110 ), PointerButton::Left ); + assert_eq!( pointer_button( 0x111 ), PointerButton::Right ); + assert_eq!( pointer_button( 0x112 ), PointerButton::Middle ); + assert_eq!( pointer_button( 0x113 ), PointerButton::Back ); + assert_eq!( pointer_button( 0x116 ), PointerButton::Back ); + assert_eq!( pointer_button( 0x114 ), PointerButton::Other ); + } + + #[ test ] + fn high_resolution_detents_win() + { + let scroll = AxisScroll { absolute: 15.0, discrete: 1, value120: 60, ..AxisScroll::default() }; + assert_eq!( axis( scroll ), Axis { distance: 15.0, notches: 0.5 } ); + } + + #[ test ] + fn whole_detents_from_an_older_compositor() + { + let scroll = AxisScroll { absolute: -30.0, discrete: -2, ..AxisScroll::default() }; + assert_eq!( axis( scroll ), Axis { distance: -30.0, notches: -2.0 } ); + } + + #[ test ] + fn a_touchpad_scrolls_without_detents() + { + let scroll = AxisScroll { absolute: 3.5, ..AxisScroll::default() }; + assert_eq!( axis( scroll ), Axis { distance: 3.5, notches: 0.0 } ); + } + + #[ test ] + fn axis_sources_are_named() + { + assert_eq!( axis_source( Some( wl_pointer::AxisSource::Wheel ) ), AxisSource::Wheel ); + assert_eq!( axis_source( Some( wl_pointer::AxisSource::Finger ) ), AxisSource::Continuous ); + assert_eq!( axis_source( Some( wl_pointer::AxisSource::Continuous ) ), AxisSource::Continuous ); + assert_eq!( axis_source( Some( wl_pointer::AxisSource::WheelTilt ) ), AxisSource::Other ); + assert_eq!( axis_source( None ), AxisSource::Other ); + } + + #[ test ] + fn a_key_event_keeps_its_code_and_text() + { + let event = KeyEvent { time: 7, raw_code: 30, keysym: Keysym::a, utf8: Some( "a".into() ) }; + assert_eq!( key_input( event ), KeyInput { keysym: Keysym::a, keycode: 30, text: Some( "a".into() ) } ); + } +} diff --git a/src/platform/wayland/mod.rs b/src/platform/wayland/mod.rs new file mode 100644 index 0000000..4477bce --- /dev/null +++ b/src/platform/wayland/mod.rs @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The Wayland backend. +//! +//! | File | What it does for the runtime | +//! | --- | --- | +//! | `state.rs` | [`WaylandPlatform`], the protocol state and the EGL context `AppData::platform` holds | +//! | `surface.rs` | [`WaylandSurfaceState`], each surface's role object, SHM pool or EGL window surface, held by `SurfaceState::platform`; configuring it | +//! | `handlers.rs` | The compositor, shell, output, seat, session-lock, foreign-toplevel, frame-callback and text-input handlers | +//! | `data_device.rs`, `primary_selection.rs` | The clipboard and the primary selection shared with other clients, and drops from them | +//! | `present.rs` | Putting a painted canvas on screen through an SHM buffer or `eglSwapBuffers`, with its damage, opaque and input regions and frame callback | +//! | `input.rs` | `wl_keyboard`, `wl_pointer` and `wl_touch` turned into [`crate::input::event`] | +//! | `window.rs` | Interactive move and resize, minimise, fullscreen and a bare frame request on the main xdg-toplevel | +//! | `cursor.rs` | The cursor's shape through `wp_cursor_shape_v1` | +//! | `text_input.rs` | The input method through `zwp_text_input_v3` | +//! | `run.rs` | `ltk::run`: connecting, binding the globals, creating the main surface and the loop that dispatches Wayland events around the runtime's steps | +//! | `overlays_reconcile.rs` | Creating and destroying the layer surfaces, popups and windows of [`App::overlays`] as the list changes | +//! | `subsurface.rs` | The input-transparent `wl_subsurface`s of [`App::subsurfaces`], each drawn and presented on its own | +//! | `session.rs` | `xdg-session-management-v1`, the periodic save of [`App::save_state`] and the signal source that ends the loop on `SIGTERM` | +//! | `output_probe.rs`, `window_fit.rs` | The size of the first output before the first commit, and a size hint shrunk to fit it | + +mod cursor; +pub( crate ) mod data_device; +mod handlers; +mod input; +mod output_probe; +mod overlays_reconcile; +pub( crate ) mod present; +pub( crate ) mod primary_selection; +pub( crate ) mod run; +mod session; +mod state; +mod subsurface; +mod surface; +mod text_input; +mod window; +mod window_fit; + +pub( crate ) use state::WaylandPlatform; +pub( crate ) use surface::{ LayerConfig, SurfaceKind, WaylandSurfaceState }; + +use smithay_client_toolkit::reexports::client::protocol::wl_output::WlOutput; + +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 WaylandPlatform +{ + type Surface = WaylandSurfaceState; + type Output = WlOutput; + + fn is_toplevel( &self, main: &WaylandSurfaceState ) -> bool + { + window::window( main ).is_some() + } + + fn back_leaves( &self, main: &WaylandSurfaceState ) -> bool + { + self.is_toplevel( main ) + } + + fn begin_window_move( &self, main: &WaylandSurfaceState ) + { + window::begin_move( self, main ); + } + + fn begin_window_resize( &self, main: &WaylandSurfaceState, edge: ResizeEdge ) + { + window::begin_resize( self, main, edge ); + } + + fn minimize_window( &self, main: &WaylandSurfaceState ) + { + if let Some( window ) = window::window( main ) { window.set_minimized(); } + } + + fn set_fullscreen( &self, main: &WaylandSurfaceState, fullscreen: bool ) -> bool + { + window::set_fullscreen( main, fullscreen ) + } + + fn request_frame( &self, surface: &WaylandSurfaceState, focus: SurfaceFocus ) -> bool + { + window::request_frame( self, surface, focus ) + } + + fn precise_damage( &self, surface: &WaylandSurfaceState ) -> bool + { + 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 + { + present::prepare_canvas( self, surface, canvas, pw, ph, scale ) + } + + fn present( &self, surface: &mut WaylandSurfaceState, canvas: &mut Canvas, frame: &Presentation<'_> ) -> bool + { + present::present( self, surface, canvas, frame ) + } + + fn publish_clipboard( &mut self, _text: &str ) + { + data_device::publish_clipboard( self ); + } + + fn publish_primary_selection( &mut self, _text: &str ) + { + primary_selection::publish( self ); + } + + fn set_cursor_shape( &mut self, shape: CursorShape ) -> bool + { + cursor::set_shape( self, shape ) + } + + fn text_input_active( &self ) -> bool + { + self.text_input.is_some() + } + + fn activate_text_input( &mut self, secure: bool, purpose: Purpose ) + { + text_input::activate( self, secure, purpose ); + } + + fn deactivate_text_input( &mut self ) + { + text_input::deactivate( self ); + } + + fn reenable_text_input( &self, secure: bool, purpose: Purpose ) + { + text_input::reenable( self, secure, purpose ); + } +} diff --git a/src/event_loop/output_probe.rs b/src/platform/wayland/output_probe.rs similarity index 100% rename from src/event_loop/output_probe.rs rename to src/platform/wayland/output_probe.rs diff --git a/src/event_loop/overlays_reconcile.rs b/src/platform/wayland/overlays_reconcile.rs similarity index 86% rename from src/event_loop/overlays_reconcile.rs rename to src/platform/wayland/overlays_reconcile.rs index c70290e..8e55e8a 100644 --- a/src/event_loop/overlays_reconcile.rs +++ b/src/platform/wayland/overlays_reconcile.rs @@ -25,9 +25,11 @@ use wayland_protocols::xdg::shell::client::xdg_positioner:: }; use crate::app::App; +use crate::event_loop::{ AppData, SurfaceFocus, SurfaceState }; +use crate::event_loop::tooltip::tooltip_id; use crate::types::{ Length, Rect }; -use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState }; -use super::tooltip::tooltip_id; + +use super::{ LayerConfig, SurfaceKind, WaylandSurfaceState }; /// Logical pixels between a tooltip popup and its widget. const TOOLTIP_GAP: i32 = 6; @@ -65,7 +67,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) { if matches!( canvas, crate::render::Canvas::Gles( _ ) ) { - data.canvas_pool.push( canvas ); + data.platform.canvas_pool.push( canvas ); } } if ss.gesture.long_press_fired @@ -105,17 +107,17 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) // the shared borrows of `layer_shell` / `xdg_shell` / `compositor_state` // / `output_state` / `qh` can coexist with the mutable borrow of // `overlays` inside the loop. - let layer_shell_opt = data.layer_shell.as_ref(); - let xdg_shell_opt = data.xdg_shell.as_ref(); - let output_opt = data.output_state.outputs().next(); - let cs = &data.compositor_state; - let qh = &data.qh; - let grab_seat = data.seat_state.seats().next(); - let grab_serial = data.last_input_serial; + let layer_shell_opt = data.platform.layer_shell.as_ref(); + let xdg_shell_opt = data.platform.xdg_shell.as_ref(); + let output_opt = data.platform.output_state.outputs().next(); + let cs = &data.platform.compositor_state; + let qh = &data.platform.qh; + let grab_seat = data.platform.seat_state.seats().next(); + let grab_serial = data.platform.last_input_serial; // Snapshot the parent xdg_surface (only an xdg toplevel can host // xdg-popups; layer-shell parents would need a different code path // via `LayerSurface::get_popup`, which we do not currently support). - let parent_xdg = match data.main.surface + let parent_xdg = match data.main.platform.surface { SurfaceKind::Window( ref w ) => Some( w.xdg_surface().clone() ), _ => None, @@ -159,8 +161,8 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) let main_widget_rects = &data.main.frame.widget_rects; let app = &data.app; let overlays_m = &mut data.overlays; - let canvas_pool = &mut data.canvas_pool; - let has_egl = data.egl_context.is_some(); + let canvas_pool = &mut data.platform.canvas_pool; + let has_egl = data.platform.egl_context.is_some(); for spec in &specs { let resolved_size = resolve_size( &spec.size ); @@ -176,20 +178,20 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) // new dimensions and drives the redraw. Popups don't grow / // shrink mid-life — close and reopen instead. let resolved_zone = to_logical_zone( spec.exclusive_zone ); - if resolved_size != ss.last_requested_size || resolved_zone != ss.last_requested_zone + if resolved_size != ss.platform.last_requested_size || resolved_zone != ss.platform.last_requested_zone { - if let SurfaceKind::Layer( ref layer_surface ) = ss.surface + if let SurfaceKind::Layer( ref layer_surface ) = ss.platform.surface { - if resolved_size != ss.last_requested_size + if resolved_size != ss.platform.last_requested_size { let ( lw, lh ) = to_logical_size( resolved_size ); layer_surface.set_size( lw, lh ); - ss.last_requested_size = resolved_size; + ss.platform.last_requested_size = resolved_size; } - if resolved_zone != ss.last_requested_zone + if resolved_zone != ss.platform.last_requested_zone { layer_surface.set_exclusive_zone( resolved_zone ); - ss.last_requested_zone = resolved_zone; + ss.platform.last_requested_zone = resolved_zone; } layer_surface.commit(); } @@ -198,7 +200,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) // floating-point jitter would otherwise call `reposition` // every frame, which several compositors react to by // dropping the popup grab. - if let SurfaceKind::Popup( ref popup ) = ss.surface + if let SurfaceKind::Popup( ref popup ) = ss.platform.surface { if let ( Some( anchor_id ), Some( xdg_shell ) ) = ( spec.anchor_widget_id, xdg_shell_opt ) { @@ -216,7 +218,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) ) }; let new_q = to_logical( anchor_rect ); - let moved = ss.last_popup_anchor.map( |r| to_logical( r ) != new_q ).unwrap_or( true ); + let moved = ss.platform.last_popup_anchor.map( |r| to_logical( r ) != new_q ).unwrap_or( true ); if moved { if let Ok( positioner ) = XdgPositioner::new( xdg_shell ) @@ -232,9 +234,9 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) positioner.set_constraint_adjustment( ConstraintAdjustment::FlipY | ConstraintAdjustment::SlideX, ); - ss.popup_reposition_token = ss.popup_reposition_token.wrapping_add( 1 ); - popup.reposition( &positioner, ss.popup_reposition_token ); - ss.last_popup_anchor = Some( anchor_rect ); + ss.platform.popup_reposition_token = ss.platform.popup_reposition_token.wrapping_add( 1 ); + popup.reposition( &positioner, ss.platform.popup_reposition_token ); + ss.platform.last_popup_anchor = Some( anchor_rect ); } } } @@ -264,8 +266,8 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) let Ok( surface ) = Surface::new( cs, qh ) else { continue }; let Ok( popup ) = Popup::from_surface( Some( parent ), &positioner, qh, surface, xdg_shell ) else { continue }; popup.wl_surface().commit(); - let mut ss = SurfaceState::::new( SurfaceKind::Popup( popup ), 0.0, String::new() ); - ss.last_requested_size = resolved_size; + let mut ss = SurfaceState::new( WaylandSurfaceState::from( SurfaceKind::Popup( popup ) ), 0.0, String::new() ); + ss.platform.last_requested_size = resolved_size; overlays_m.insert( spec.id, ss ); continue; } @@ -350,9 +352,9 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) popup.xdg_popup().grab( seat, grab_serial ); } popup.wl_surface().commit(); - let mut ss = SurfaceState::::new( SurfaceKind::Popup( popup ), 0.0, String::new() ); - ss.last_requested_size = resolved_size; - ss.last_popup_anchor = Some( anchor_rect ); + let mut ss = SurfaceState::new( WaylandSurfaceState::from( SurfaceKind::Popup( popup ) ), 0.0, String::new() ); + ss.platform.last_requested_size = resolved_size; + ss.platform.last_popup_anchor = Some( anchor_rect ); overlays_m.insert( spec.id, ss ); continue; } @@ -374,9 +376,9 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) window.set_min_size( Some( ( w, h ) ) ); window.set_max_size( Some( ( w, h ) ) ); window.commit(); - let mut ss = SurfaceState::::new( SurfaceKind::Window( window ), 0.0, String::new() ); + let mut ss = SurfaceState::new( WaylandSurfaceState::from( SurfaceKind::Window( window ) ), 0.0, String::new() ); ss.scale_factor = parent_scale_i; - ss.last_requested_size = resolved_size; + ss.platform.last_requested_size = resolved_size; overlays_m.insert( spec.id, ss ); continue; } @@ -400,14 +402,14 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) { surface.materialize( cs, layer_shell, qh, output ); } - let mut ss = SurfaceState::::new( surface, 0.0, String::new() ); + let mut ss = SurfaceState::new( WaylandSurfaceState::from( surface ), 0.0, String::new() ); // Inherit the parent's scale so the first configure allocates a // HiDPI buffer and lays out at the right size from frame one, // instead of rendering at scale 1 until `scale_factor_changed` // lands a frame or two later. ss.scale_factor = parent_scale_i; - ss.last_requested_size = resolved_size; - ss.last_requested_zone = to_logical_zone( spec.exclusive_zone ); + ss.platform.last_requested_size = resolved_size; + ss.platform.last_requested_zone = to_logical_zone( spec.exclusive_zone ); ss.layer_anchor = Some( spec.anchor ); if has_egl { ss.canvas = canvas_pool.pop(); } overlays_m.insert( spec.id, ss ); diff --git a/src/platform/wayland/present.rs b/src/platform/wayland/present.rs new file mode 100644 index 0000000..ff6cd83 --- /dev/null +++ b/src/platform/wayland/present.rs @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Putting a painted canvas on screen: through a `wl_shm` buffer for the software renderer, through `eglSwapBuffers` for the GPU one, together with the damage, opaque and input regions and the frame callback of the commit. + +use std::sync::Arc; + +use smithay_client_toolkit::compositor::{ CompositorState, Region }; +use smithay_client_toolkit::reexports::client::protocol::{ wl_shm, wl_surface::WlSurface }; +use smithay_client_toolkit::shm::Shm; + +use crate::app::App; +use crate::draw::fit_canvas; +use crate::egl_context::egl_damage; +use crate::event_loop::perf::RasterTrace; +use crate::platform::Presentation; +use crate::render::Canvas; +use crate::types::{ Color, Rect }; + +use super::{ WaylandPlatform, WaylandSurfaceState }; + +/// Pick the best wl_shm format for our RGBA-premultiplied pixmap. +/// Abgr8888 matches tiny-skia's byteorder on little-endian systems (plain +/// memcpy). Falls back to mandatory Argb8888 when not advertised. +/// Returns `(format, swap_rb)`. +pub( crate ) fn pick_shm_format( shm: &Shm ) -> ( wl_shm::Format, bool ) +{ + if shm.formats().contains( &wl_shm::Format::Abgr8888 ) + { + ( wl_shm::Format::Abgr8888, false ) + } else { + ( wl_shm::Format::Argb8888, true ) + } +} + +/// The GPU path when both the process and the surface have EGL, the SHM pool otherwise. +pub( super ) fn prepare_canvas +( + platform: &WaylandPlatform, + surface: &mut WaylandSurfaceState, + canvas: &mut Option, + pw: u32, + ph: u32, + scale: u32, +) -> bool +{ + if let ( Some( ctx ), Some( es ) ) = ( platform.egl_context.as_ref(), surface.egl_surface.as_ref() ) + { + if ctx.make_current( es ).is_err() { return false; } + surface.raster_trace = RasterTrace::begin(); + fit_canvas( canvas, pw, ph, scale, || Canvas::new_gles( Arc::clone( ctx.gl() ), ctx.version, pw, ph ) ); + return true; + } + if surface.pool.is_none() { return false; } + fit_canvas( canvas, pw, ph, scale, || Canvas::new( pw, ph ) ); + true +} + +pub( super ) fn present +( + platform: &WaylandPlatform, + surface: &mut WaylandSurfaceState, + canvas: &mut Canvas, + frame: &Presentation<'_>, +) -> bool +{ + let ( pw, ph ) = canvas.size(); + let Some( ( ctx, es ) ) = platform.egl_context.as_ref().zip( surface.egl_surface.as_ref() ) else + { + return present_shm( platform, surface, canvas, frame ); + }; + canvas.present(); + if let Some( t ) = surface.raster_trace.take() + { + t.end( ctx.gl(), if frame.damage.is_empty() { "full" } else { "partial" }, pw, ph ); + } + describe( platform, surface.surface.wl_surface(), &mut surface.opaque_full, frame ); + // Attaches, damages and commits in one go. + let _ = ctx.swap_buffers_with_damage( es, &egl_damage( frame.damage, pw, ph ) ); + true +} + +fn present_shm +( + platform: &WaylandPlatform, + surface: &mut WaylandSurfaceState, + canvas: &mut Canvas, + frame: &Presentation<'_>, +) -> bool +{ + let ( pw, ph ) = canvas.size(); + let ( format, swap_rb ) = pick_shm_format( &platform.shm ); + let Some( pool ) = surface.pool.as_mut() else { return false }; + let Ok( ( buffer, pixels ) ) = pool.create_buffer( pw as i32, ph as i32, ( pw * 4 ) as i32, format ) else { return false }; + canvas.write_to_wayland_buf( pixels, swap_rb ); + + let wl_surface = surface.surface.wl_surface(); + buffer.attach_to( wl_surface ).expect( "attach" ); + if frame.damage.is_empty() + { + wl_surface.damage_buffer( 0, 0, pw as i32, ph as i32 ); + } else { + for r in frame.damage + { + wl_surface.damage_buffer( r.x as i32, r.y as i32, r.width as i32, r.height as i32 ); + } + } + describe( platform, wl_surface, &mut surface.opaque_full, frame ); + wl_surface.commit(); + true +} + +/// 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 ); + if let Some( focus ) = frame.frame + { + let _ = wl_surface.frame( &platform.qh, focus ); + } +} + +/// The opaque part of a `width × height` logical surface at `scale`, in physical rects: all of it when `bg` is opaque, else `region`. +fn opaque_rects( bg: Color, region: Option<&[Rect]>, width: u32, height: u32, scale: u32 ) -> Vec +{ + let s = scale.max( 1 ) as f32; + if bg.a >= 1.0 + { + vec![ Rect { x: 0.0, y: 0.0, width: width as f32 * s, height: height as f32 * s } ] + } else { + region.map( |r| r.to_vec() ).unwrap_or_default() + } +} + +/// Declare the opaque part of the surface so the compositor can skip blending it and repainting what lies underneath. `applied` remembers the rects last sent. +pub( crate ) fn sync_opaque_region( + wl_surface: &WlSurface, + compositor: &CompositorState, + applied: &mut Option>, + bg: Color, + region: Option<&[Rect]>, + width: u32, + height: u32, + scale: u32, +) +{ + let rects = opaque_rects( bg, region, width, height, scale ); + if applied.as_ref() == Some( &rects ) { return; } + if rects.is_empty() + { + wl_surface.set_opaque_region( None ); + } else if let Ok( wl_region ) = Region::new( compositor ) { + let s = scale.max( 1 ) as f32; + for r in &rects + { + wl_region.add( + ( r.x / s ).round() as i32, + ( r.y / s ).round() as i32, + ( r.width / s ).round() as i32, + ( r.height / s ).round() as i32, + ); + } + wl_surface.set_opaque_region( Some( wl_region.wl_region() ) ); + } + *applied = Some( rects ); +} + +/// Apply or clear the surface's input region (set by +/// `App::input_region`). `None` restores the default of "receive +/// input everywhere"; `Some(&[])` makes the surface pointer-through. +pub( crate ) fn apply_input_region( + wl_surface: &WlSurface, + compositor: &CompositorState, + input_region: Option<&[Rect]>, + scale: u32, +) +{ + if let Some( regions ) = input_region + { + if let Ok( region ) = Region::new( compositor ) + { + // `Rect`s are physical (layout space); `wl_region` is logical. + let s = scale.max( 1 ) as f32; + for r in regions + { + region.add( + ( r.x / s ).round() as i32, + ( r.y / s ).round() as i32, + ( r.width / s ).round() as i32, + ( r.height / s ).round() as i32, + ); + } + wl_surface.set_input_region( Some( region.wl_region() ) ); + } + } else { + wl_surface.set_input_region( None ); + } +} + +#[ cfg( test ) ] +mod tests +{ + use super::opaque_rects; + use crate::types::{ Color, Rect }; + + fn rect( x: f32, y: f32, width: f32, height: f32 ) -> Rect + { + Rect { x, y, width, height } + } + + #[ test ] + fn an_opaque_background_covers_the_physical_surface() + { + let region = [ rect( 0.0, 0.0, 1.0, 1.0 ) ]; + assert_eq!( opaque_rects( Color::rgb( 0.0, 0.0, 0.0 ), Some( ®ion ), 400, 300, 2 ), vec![ rect( 0.0, 0.0, 800.0, 600.0 ) ] ); + } + + #[ test ] + fn a_translucent_background_takes_the_app_region() + { + let region = [ rect( 0.0, 0.0, 100.0, 50.0 ) ]; + assert_eq!( opaque_rects( Color::rgba( 0.0, 0.0, 0.0, 0.5 ), Some( ®ion ), 400, 300, 1 ), region.to_vec() ); + assert!( opaque_rects( Color::rgba( 0.0, 0.0, 0.0, 0.0 ), None, 400, 300, 1 ).is_empty() ); + } +} diff --git a/src/event_loop/primary_selection.rs b/src/platform/wayland/primary_selection.rs similarity index 57% rename from src/event_loop/primary_selection.rs rename to src/platform/wayland/primary_selection.rs index dfa4f2f..6bf9866 100644 --- a/src/event_loop/primary_selection.rs +++ b/src/platform/wayland/primary_selection.rs @@ -23,7 +23,8 @@ use smithay_client_toolkit::reexports::protocols::wp::primary_selection::zv1::cl }; use crate::app::App; -use super::app_data::AppData; +use crate::event_loop::AppData; +use super::WaylandPlatform; use super::data_device::{ pick_text_mime, read_selection, write_selection, CLIPBOARD_MIMES }; pub( crate ) struct PrimarySelection @@ -33,9 +34,6 @@ pub( crate ) struct PrimarySelection device: Option, /// Ours while we own the selection; dropping it gives the selection up. source: Option, - pub text: String, - /// The selection of the focused text field when it was last looked at. - field: Option, inbox_tx: mpsc::Sender, inbox_rx: mpsc::Receiver, } @@ -45,55 +43,38 @@ impl PrimarySelection pub( crate ) fn new( manager: Option ) -> Self { let ( inbox_tx, inbox_rx ) = mpsc::channel(); - Self { manager, device: None, source: None, text: String::new(), field: None, inbox_tx, inbox_rx } + Self { manager, device: None, source: None, inbox_tx, inbox_rx } } - /// Take what the reader threads brought from other applications. - pub( crate ) fn drain( &mut self ) + /// The latest selection the reader threads brought from other applications, if any arrived. + pub( crate ) fn drain( &mut self ) -> Option { - while let Ok( text ) = self.inbox_rx.try_recv() - { - self.text = text; - } + self.inbox_rx.try_iter().last() } } +/// Offer the primary selection to other applications; `AppData::primary_selection` holds its text. +pub( crate ) fn publish( platform: &mut WaylandPlatform ) +{ + let ( Some( manager ), Some( device ) ) = ( &platform.primary.manager, &platform.primary.device ) else { return }; + let source = manager.create_selection_source( &platform.qh, CLIPBOARD_MIMES.iter().copied() ); + source.set_selection( device, platform.last_input_serial ); + platform.primary.source = Some( source ); +} + impl AppData { pub( crate ) fn bind_primary_selection( &mut self, qh: &QueueHandle, seat: &WlSeat ) { - if self.primary.device.is_some() { return; } - let Some( manager ) = &self.primary.manager else { return }; - self.primary.device = Some( manager.get_selection_device( qh, seat ) ); - } - - /// Make `text` the primary selection, for other applications as well when the compositor allows. - pub( crate ) fn publish_primary_selection( &mut self, text: String ) - { - self.primary.text = text; - let ( Some( manager ), Some( device ) ) = ( &self.primary.manager, &self.primary.device ) else { return }; - let source = manager.create_selection_source( &self.qh, CLIPBOARD_MIMES.iter().copied() ); - source.set_selection( device, self.last_input_serial ); - self.primary.source = Some( source ); - } - - /// Publish a new selection in the focused text field once the press that makes it ends; a password field's never leaves it. - pub( crate ) fn sync_text_field( &mut self ) - { - let focus = self.keyboard_focus; - if self.try_surface( focus ).is_none_or( |ss| ss.gesture.pressed_idx.is_some() ) { return; } - let secure = self.surface( focus ).focused_idx - .and_then( |idx| self.text_input_geometry( focus, idx ) ) - .is_some_and( |( _, _, _, secure, _, _ )| secure ); - let selected = if secure { None } else { self.focused_selection_text( focus ) }; - if selected == self.primary.field { return; } - self.primary.field = selected.clone(); - if let Some( text ) = selected { self.publish_primary_selection( text ); } + let primary = &mut self.platform.primary; + if primary.device.is_some() { return; } + let Some( manager ) = &primary.manager else { return }; + primary.device = Some( manager.get_selection_device( qh, seat ) ); } fn owns_primary( &self, source: &ZwpPrimarySelectionSourceV1 ) -> bool { - self.primary.source.as_ref().is_some_and( |own| own.inner() == source ) + self.platform.primary.source.as_ref().is_some_and( |own| own.inner() == source ) } } @@ -104,7 +85,7 @@ impl PrimarySelectionDeviceHandler for AppData let Some( offer ) = device.data::().and_then( |data| data.selection_offer() ) else { return }; let Some( mime ) = offer.with_mime_types( pick_text_mime ) else { return }; let Ok( pipe ) = offer.receive( mime ) else { return }; - read_selection( pipe, self.primary.inbox_tx.clone() ); + read_selection( pipe, self.platform.primary.inbox_tx.clone() ); } } @@ -112,11 +93,11 @@ impl PrimarySelectionSourceHandler for AppData { fn send_request( &mut self, _conn: &Connection, _qh: &QueueHandle, source: &ZwpPrimarySelectionSourceV1, _mime: String, pipe: WritePipe ) { - if self.owns_primary( source ) { write_selection( pipe, &self.primary.text ); } + if self.owns_primary( source ) { write_selection( pipe, &self.primary_selection ); } } fn cancelled( &mut self, _conn: &Connection, _qh: &QueueHandle, source: &ZwpPrimarySelectionSourceV1 ) { - if self.owns_primary( source ) { self.primary.source = None; } + if self.owns_primary( source ) { self.platform.primary.source = None; } } } diff --git a/src/platform/wayland/run.rs b/src/platform/wayland/run.rs new file mode 100644 index 0000000..1c5fae5 --- /dev/null +++ b/src/platform/wayland/run.rs @@ -0,0 +1,499 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use smithay_client_toolkit:: +{ + compositor::CompositorState, + output::OutputState, + registry::RegistryState, + seat::SeatState, + shell:: + { + WaylandSurface, + wlr_layer::LayerShell, + xdg:: + { + XdgShell, + window::WindowDecorations, + }, + }, + shm::Shm, + subcompositor::SubcompositorState, +}; +use smithay_client_toolkit::reexports::client::Connection; +use smithay_client_toolkit::reexports::calloop::EventLoop; +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 super::{ LayerConfig, SurfaceKind, WaylandPlatform, WaylandSurfaceState }; +use super::overlays_reconcile::reconcile_overlays; +use super::subsurface::reconcile_subsurfaces; + +/// Run the application, panicking on init failure. Thin wrapper over +/// [`try_run`] kept for backwards-compatibility — embedders that need +/// to recover from a missing compositor or a stripped-down driver +/// should call [`try_run`] instead. +pub( crate ) fn run( app: A ) +{ + if let Err( e ) = try_run( app ) + { + panic!( "ltk::run failed during init: {e}" ); + } +} + +/// Run the application, returning a typed error on init failure. +/// The dispatch loop's runtime errors still panic — they are non- +/// recoverable once the surface is on screen, and the surface state +/// machine cannot be unwound cleanly from this entry point. +pub( crate ) fn try_run( mut app: A ) -> Result<(), RunError> +{ + let conn = Connection::connect_to_env() + .map_err( |e| RunError::NoWaylandConnection( format!( "{e}" ) ) )?; + let ( globals, queue ) = + smithay_client_toolkit::reexports::client::globals::registry_queue_init( &conn ) + .map_err( |e| RunError::RegistryInit( format!( "{e:?}" ) ) )?; + let qh = queue.handle(); + + let mut event_loop: EventLoop> = EventLoop::try_new() + .map_err( |e| RunError::EventLoop( format!( "EventLoop::try_new: {e}" ) ) )?; + WaylandSource::new( conn.clone(), queue ) + .insert( event_loop.handle() ) + .map_err( |e| RunError::EventLoop( format!( "WaylandSource::insert: {e:?}" ) ) )?; + + super::session::install_signal_source( &event_loop.handle() )?; + + let compositor = CompositorState::bind( &globals, &qh ) + .map_err( |e| RunError::MissingProtocol { name: "wl_compositor", detail: format!( "{e:?}" ) } )?; + let shm = Shm::bind( &globals, &qh ) + .map_err( |e| RunError::MissingProtocol { name: "wl_shm", detail: format!( "{e:?}" ) } )?; + + // Optional: compositors lacking wl_subcompositor leave this None and + // App::subsurfaces() silently degrades to no subsurfaces. + let subcompositor = SubcompositorState::bind( compositor.wl_compositor().clone(), &globals, &qh ).ok(); + + // Try to bring EGL up. On failure (no libEGL, no compatible config, + // LTK_FORCE_SOFTWARE=1, ES2/ES3 context creation refused…) we log the + // reason and every surface falls back to the SHM path. + let egl_context = match crate::egl_context::EglContext::new( &conn ) + { + // Shared by the surfaces of this thread only; `Arc` is what the surfaces and subsurfaces hold. + #[ allow( clippy::arc_with_non_send_sync ) ] + Ok( ctx ) => Some( std::sync::Arc::new( ctx ) ), + Err( reason ) => + { + crate::egl_context::log_software_fallback( &reason ); + None + } + }; + crate::render::set_software_render( egl_context.is_none() ); + + // Bind layer-shell up front. Both the main surface (when requested via + // ShellMode::Layer) and every overlay returned by App::overlays() share + // this single binding. + let layer_shell_opt: Option = LayerShell::bind( &globals, &qh ).ok(); + + // Backwards compatibility: window_config() overrides shell_mode() + let force_window = app.window_config() + .map( |( t, id )| ( t.to_string(), id.to_string() ) ); + + let session_enabled = force_window.is_some() || matches!( app.shell_mode(), crate::app::ShellMode::Window ); + let mut session = if session_enabled + { + super::session::SessionRuntime::bootstrap( &mut app ) + } else { + super::session::SessionRuntime::disabled() + }; + if session_enabled + { + session.bind( &globals, &qh ); + } + let app_id = app.app_id().to_string(); + if let Some( ( _, ref cfg_id ) ) = force_window + { + if cfg_id != &app_id + { + eprintln!( "ltk: window_config app_id {cfg_id:?} differs from App::app_id {app_id:?}; App::app_id wins" ); + } + } + + let bind_xdg = |globals: &smithay_client_toolkit::reexports::client::globals::GlobalList, qh: &smithay_client_toolkit::reexports::client::QueueHandle>| + -> Result + { + XdgShell::bind( globals, qh ) + .map_err( |e| RunError::MissingProtocol { name: "xdg_wm_base", detail: format!( "{e:?}" ) } ) + }; + + // Skipped when going fullscreen: Mutter rejects the fullscreen + // request if a min_size constrains it. + // Fitted before the first commit, a hint too big for the screen never reaches a compositor that would rescale the window to honour it. + let prefitted_size_hint = app.window_size_hint() + .filter( |_| !app.start_fullscreen() ) + .and_then( |hint| super::output_probe::output_logical_size( &conn, &globals ).map( |output| super::window_fit::fit_within( hint, super::window_fit::output_share( output ) ) ) ); + let apply_size_hint = |window: &smithay_client_toolkit::shell::xdg::window::Window| + { + if app.start_fullscreen() { return; } + match prefitted_size_hint.or( app.window_size_hint() ) + { + Some( ( w, h ) ) => + { + window.set_min_size( Some( ( w, h ) ) ); + window.set_max_size( Some( ( w, h ) ) ); + } + None => + { + window.set_min_size( Some( ( 360, 480 ) ) ); + } + } + }; + + let apply_fullscreen = |window: &smithay_client_toolkit::shell::xdg::window::Window| + { + if app.start_fullscreen() + { + window.set_fullscreen( None ); + } + }; + + // `restore_toplevel` must precede the first commit, so the session + // attach sits right before `commit()`. + let make_window = |xdg: &XdgShell, title: &str, session: &mut super::session::SessionRuntime, attach: bool| + { + let surface = compositor.create_surface( &qh ); + let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh ); + window.set_title( title ); + window.set_app_id( app_id.as_str() ); + apply_size_hint( &window ); + apply_fullscreen( &window ); + if attach + { + session.attach_toplevel( &window, &qh ); + } + window.commit(); + window + }; + + let ( surface_kind, xdg_shell ) = if let Some( ( ref title, _ ) ) = force_window + { + let xdg = bind_xdg( &globals, &qh )?; + let window = make_window( &xdg, title.as_str(), &mut session, true ); + ( SurfaceKind::Window( window ), Some( xdg ) ) + } else { + // Use shell_mode() to determine surface type + use crate::app::ShellMode; + match app.shell_mode() + { + ShellMode::SessionLock => { + // Lock surface is created in SessionLockHandler::locked once the + // compositor grants the lock; until then a placeholder. + ( SurfaceKind::PendingLock, None ) + } + ShellMode::Window => { + let xdg = bind_xdg( &globals, &qh )?; + let window = make_window( &xdg, "ltk", &mut session, true ); + ( SurfaceKind::Window( window ), Some( xdg ) ) + } + ShellMode::Layer( layer ) => { + if layer_shell_opt.is_some() + { + // Defer surface creation until new_output fires: if we create the layer + // surface before the compositor has any output ready (e.g. sway startup), + // the compositor cannot assign it and logs an error. + let cfg = LayerConfig { + layer: layer.to_wlr_layer(), + exclusive_zone: app.exclusive_zone(), + anchor: app.layer_anchor(), + size: app.layer_size(), + keyboard_exclusive: app.keyboard_exclusive(), + namespace: "ltk-sctk", + }; + ( SurfaceKind::Pending( cfg ), None ) + } else { + eprintln!( "ltk: wlr-layer-shell not available, falling back to xdg window" ); + let xdg = bind_xdg( &globals, &qh )?; + let window = make_window( &xdg, "ltk", &mut session, false ); + ( SurfaceKind::Window( window ), Some( xdg ) ) + } + } + } + }; + // Layer-shell apps still get xdg_wm_base when the compositor has it, so + // a `Layer::Window` overlay can be created later. + let xdg_shell = xdg_shell.or_else( || XdgShell::bind( &globals, &qh ).ok() ); + + // Bind the session-lock manager and request the lock. We don't keep the + // `SessionLockState` (the manager) around: it has no `Drop`, so the + // manager object persists in the connection, and the lock lifecycle runs + // entirely off the returned `SessionLock` + its surfaces. + let session_lock = + if force_window.is_none() && matches!( app.shell_mode(), crate::app::ShellMode::SessionLock ) + { + smithay_client_toolkit::session_lock::SessionLockState::new( &globals, &qh ) + .lock( &qh ) + .ok() + } else { + None + }; + + let text_input_manager: Option = globals.bind( &qh, 1..=1, () ).ok(); + let builtin_osk = text_input_manager.is_none() || std::env::var_os( "LTK_BUILTIN_OSK" ).is_some_and( |v| v == "1" ); + // wl_data_device_manager: optional. Required for cross-application + // copy/paste; absence means the clipboard stays process-local. + let data_device_manager = + smithay_client_toolkit::data_device_manager::DataDeviceManagerState::bind( &globals, &qh ).ok(); + let primary = super::primary_selection::PrimarySelection::new( + smithay_client_toolkit::primary_selection::PrimarySelectionManagerState::bind( &globals, &qh ).ok() ); + let ( clipboard_inbox_tx, clipboard_inbox_rx ) = std::sync::mpsc::channel(); + let ( drop_inbox_tx, drop_inbox_rx ) = super::data_device::drop_inbox(); + // AT-SPI2 / accessibility. `try_new` returns `None` when the + // platform adapter cannot be created (no daemon on the bus, + // headless CI, etc.) — the runtime then runs with no + // accessibility tree, which is the previous behaviour. + let a11y = crate::a11y::A11yState::try_new( &app_id, &app_id ); + // xdg-activation-v1: optional. Compositors that don't carry the + // global leave `activation_state` as `None` and the inbound / + // outbound activation paths silently degrade to no-ops. + let activation_state = + smithay_client_toolkit::activation::ActivationState::bind( &globals, &qh ).ok(); + // `$XDG_ACTIVATION_TOKEN` is the cross-desktop convention for an + // incoming activation token (the launcher that spawned us set it + // in the new process's environment). Honoured exactly once, after + // the main surface receives its first configure — that is the + // earliest moment we can call `xdg_activation_v1.activate`. + let activation_token_pending = std::env::var( "XDG_ACTIVATION_TOKEN" ).ok().filter( |t| !t.is_empty() ); + // Avoid the token leaking into any child process the app spawns — + // it is single-use and would be invalid the second time anyway. + if activation_token_pending.is_some() + { + // SAFETY: removing an env var is sound only when no other thread + // is reading the environment concurrently. We are still in the + // init phase before `set_channel_sender`, so the app has had + // no opportunity to spawn worker threads yet. + unsafe { std::env::remove_var( "XDG_ACTIVATION_TOKEN" ); } + } + + // `ext-foreign-toplevel-list-v1`. SCTK's `ForeignToplevelList` handles the + // bind + dispatch routing; absence of the global is fine, the inner + // `GlobalProxy` then yields no toplevels and `App::on_toplevel_event` never + // fires. The list is built unconditionally so apps that override the + // callback always see a consistent stream when the compositor does carry + // the protocol. + let foreign_toplevel_list = smithay_client_toolkit::foreign_toplevel_list::ForeignToplevelList::new( &globals, &qh ); + + let titlebar_height = if force_window.is_some() { 36.0 } else { 0.0 }; + let titlebar_title = force_window.map( |( t, _ )| t ).unwrap_or_default(); + + let pending_size_hint_unpin = !app.start_fullscreen() && app.window_size_hint().is_some() && app.window_resizable(); + let pending_size_hint_fit = !app.start_fullscreen() && app.window_size_hint().is_some() && prefitted_size_hint.is_none(); + + let platform = WaylandPlatform + { + registry_state: RegistryState::new( &globals ), + seat_state: SeatState::new( &globals, &qh ), + output_state: OutputState::new( &globals, &qh ), + compositor_state: compositor, + subcompositor, + shm, + egl_context, + session_lock, + xdg_shell, + layer_shell: layer_shell_opt, + keyboard: None, + pointer: None, + touch: None, + cursor_shape_manager: + smithay_client_toolkit::seat::pointer::cursor_shape::CursorShapeManager::bind( &globals, &qh ).ok(), + cursor_shape_device: None, + last_pointer_enter_serial: 0, + last_pointer_serial: 0, + last_input_serial: 0, + text_input_manager, + text_input: None, + activation_state, + activation_token_pending, + session, + data_device_manager, + data_device: None, + clipboard_source: None, + primary, + clipboard_inbox_tx, + clipboard_inbox_rx, + drop_position: None, + drop_mime: None, + drop_inbox_tx, + drop_inbox_rx, + pending_fullscreen: app.start_fullscreen(), + pending_size_hint_unpin, + pending_size_hint_fit, + fitted_size_hint: prefitted_size_hint, + canvas_pool: Vec::new(), + foreign_toplevel_list, + main_output: None, + subsurfaces: std::collections::HashMap::new(), + subsurface_gles_canvas: None, + qh: qh.clone(), + }; + + 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 ); + data.install_sources()?; + + if data.platform.session.store.is_some() + { + super::session::install_save_timer( &event_loop.handle() )?; + } + + while !data.exit_requested + { + // Sleep until a Wayland event, a calloop timer or channel, or a + // deadline of the runtime's. Pacing is driven by `wl_surface.frame` + // callbacks, so an idle / off-screen / VRR display blocks + // indefinitely instead of polling at a fixed rate. + let timeout = data.next_wakeup(); + match event_loop.dispatch( timeout, &mut data ) + { + Ok( () ) => {}, + Err( calloop::Error::IoError( ref e ) ) + if matches!( e.kind(), std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset ) => + { + // On a fatal `wl_display.error` the compositor closes the + // socket; the backend has the typed error stashed but the + // dispatch result only carries the resulting `BrokenPipe`. + if let Some( pe ) = conn.protocol_error() + { + eprintln!( + "ltk: wayland protocol error — interface={} object_id={} code={}: {}", + pe.object_interface, pe.object_id, pe.code, pe.message + ); + } + else + { + eprintln!( "ltk: wayland connection lost ({e}); exiting" ); + } + data.exit_requested = true; + continue; + } + Err( calloop::Error::OtherError( ref e ) ) => + { + // wayland-client surfaces the closed-socket condition as an + // OtherError wrapping a WaylandError::Io(BrokenPipe) — one + // level deeper than a direct io::Error. Walk the source() + // chain so we catch it regardless of how many wrapper types + // sit between calloop and the raw io::Error. + let mut src: Option<&dyn std::error::Error> = Some( e.as_ref() ); + let mut is_closed = false; + while let Some( err ) = src + { + if let Some( io ) = err.downcast_ref::() + { + if matches!( io.kind(), + std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset ) + { + is_closed = true; + break; + } + } + src = err.source(); + } + if is_closed + { + eprintln!( "ltk: wayland connection lost; exiting" ); + data.exit_requested = true; + continue; + } + panic!( "dispatch: {e:?}" ); + } + Err( e ) => panic!( "dispatch: {e:?}" ), + } + + // Latest writer wins; payloads are capped at 16 MiB by the worker that reads them. + while let Ok( text ) = data.platform.clipboard_inbox_rx.try_recv() + { + data.clipboard = text; + } + if let Some( text ) = data.platform.primary.drain() + { + data.primary_selection = text; + } + while let Ok( p ) = data.platform.drop_inbox_rx.try_recv() + { + data.app.on_drop_received( p.x as f32, p.y as f32, &p.mime, &p.text ); + } + request_activation_tokens( &mut data ); + + data.before_draw(); + + // For a session-lock surface, unlock first: exiting without + // unlocking leaves the compositor's lock in place by design. + if data.app.requested_exit() + { + if let Some( lock ) = data.platform.session_lock.take() + { + lock.unlock(); + let _ = conn.roundtrip(); + } + } + + // Drop any overlays whose id disappeared and create new ones for ids + // that just appeared. Specs are re-queried next frame for drawing. + reconcile_overlays( &mut data ); + + // Subsurfaces must map with the main surface's first buffer, not a frame later. + if !data.first_frame_committed + { + reconcile_subsurfaces( &mut data ); + } + + // The compositor decides our cadence: when no surface is due we just + // loop back to `dispatch( None )` and sleep. + let drawn = data.draw_due(); + + // Every iteration, decoupled from the main redraw cadence: a finger + // drag emits motion events faster than frame callbacks clear + // `frame_pending`, so gating this on a main draw would pin the + // subsurface between frames. A position-only change is just + // set_position + a bare parent commit; no-ops when nothing moved. + reconcile_subsurfaces( &mut data ); + + data.after_draw( drawn ); + } + + data.platform.session.on_exit( &data.app ); + + Ok( () ) +} + +/// Issue the xdg-activation tokens the app asked for. The tag rides in +/// `RequestData::app_id` and comes back through +/// `ActivationHandler::new_token` as the token. When the compositor never +/// advertised the global, answer with an empty token so the caller still +/// proceeds (falling back to its own matching). +fn request_activation_tokens( data: &mut AppData ) +{ + for tag in data.app.take_activation_requests() + { + match data.platform.activation_state + { + Some( ref activation ) => + { + let req = smithay_client_toolkit::activation::RequestData { + app_id: Some( tag ), + seat_and_serial: None, + surface: data.main.platform.surface.try_wl_surface().cloned(), + }; + activation.request_token::>( &data.platform.qh, req ); + } + None => + { + if let Some( msg ) = data.app.on_activation_token( tag, String::new() ) + { + data.pending_msgs.push( msg ); + } + } + } + } +} diff --git a/src/event_loop/session.rs b/src/platform/wayland/session.rs similarity index 97% rename from src/event_loop/session.rs rename to src/platform/wayland/session.rs index 2535d8b..3b0d342 100644 --- a/src/event_loop/session.rs +++ b/src/platform/wayland/session.rs @@ -15,6 +15,7 @@ use smithay_client_toolkit::reexports::client::{ Connection, Dispatch, QueueHand use smithay_client_toolkit::shell::xdg::window::Window; use crate::app::App; +use crate::event_loop::{ AppData, RunError }; use crate::protocol::xdg_session_management_v1:: { xdg_session_manager_v1::{ self, XdgSessionManagerV1 }, @@ -23,9 +24,6 @@ use crate::protocol::xdg_session_management_v1:: }; use crate::session_state::{ RestoreReason, Startup, StateStore }; -use super::error::RunError; -use super::AppData; - pub( crate ) const TOPLEVEL_NAME: &str = "main"; pub( crate ) const SAVE_INTERVAL: Duration = Duration::from_secs( 30 ); pub( crate ) const RESTORE_ENV: &str = "LTK_SESSION_RESTORE"; @@ -229,7 +227,7 @@ pub( crate ) fn install_save_timer( handle: &LoopHandle<'static, AppData handle .insert_source( Timer::from_duration( SAVE_INTERVAL ), |_, _, data: &mut AppData| { - data.session.periodic_save( &data.app ); + data.platform.session.periodic_save( &data.app ); TimeoutAction::ToDuration( SAVE_INTERVAL ) } ) .map_err( |e| RunError::EventLoop( format!( "save timer insert_source: {e:?}" ) ) )?; @@ -265,13 +263,13 @@ impl Dispatch for AppData { xdg_session_v1::Event::Created { session_id } => { - if let Some( store ) = &mut state.session.store + if let Some( store ) = &mut state.platform.session.store { store.set_session_id( session_id ); } } xdg_session_v1::Event::Restored => {} - xdg_session_v1::Event::Replaced => state.session.on_replaced(), + xdg_session_v1::Event::Replaced => state.platform.session.on_replaced(), } } } diff --git a/src/platform/wayland/state.rs b/src/platform/wayland/state.rs new file mode 100644 index 0000000..5dc9a44 --- /dev/null +++ b/src/platform/wayland/state.rs @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use smithay_client_toolkit:: +{ + activation::ActivationState, + compositor::CompositorState, + data_device_manager::{ DataDeviceManagerState, data_device::DataDevice, data_source::CopyPasteSource }, + foreign_toplevel_list::ForeignToplevelList, + output::OutputState, + registry::RegistryState, + seat::{ SeatState, pointer::cursor_shape::CursorShapeManager }, + shell::{ wlr_layer::LayerShell, xdg::XdgShell }, + shm::Shm, + session_lock::SessionLock, + subcompositor::SubcompositorState, +}; +use smithay_client_toolkit::reexports::client:: +{ + protocol::{ wl_keyboard::WlKeyboard, wl_output::WlOutput, wl_pointer::WlPointer, wl_touch::WlTouch }, + QueueHandle, +}; +use smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1; +use wayland_protocols::wp::text_input::zv3::client:: +{ + zwp_text_input_manager_v3::ZwpTextInputManagerV3, + zwp_text_input_v3::ZwpTextInputV3, +}; + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::app::App; +use crate::egl_context::EglContext; +use crate::app::SubsurfaceId; +use crate::event_loop::AppData; +use crate::render::Canvas; + +use super::data_device::DropPayload; +use super::primary_selection::PrimarySelection; +use super::session::SessionRuntime; +use super::subsurface::SubsurfaceSlot; + +/// The Wayland protocol state the runtime's requests go through: globals, seat devices, the serials later requests quote and the queue new objects are created on. +pub( crate ) struct WaylandPlatform +{ + pub registry_state: RegistryState, + pub seat_state: SeatState, + pub output_state: OutputState, + pub compositor_state: CompositorState, + /// `None` when the compositor does not advertise `wl_subcompositor`. + pub subcompositor: Option, + pub shm: Shm, + /// Process-wide EGL context. `None` when EGL failed to initialise or `LTK_FORCE_SOFTWARE=1`; every surface then draws through the SHM pool. + pub egl_context: Option>, + pub session_lock: Option, + #[ allow( dead_code ) ] + pub xdg_shell: Option, + /// Shared by the main surface and every overlay; `None` when the compositor does not advertise the protocol. + pub layer_shell: Option, + pub keyboard: Option, + pub pointer: Option, + pub touch: Option, + /// `None` when the compositor does not advertise `wp_cursor_shape_v1`; the compositor's default cursor then stays. + pub cursor_shape_manager: Option, + /// Present once the compositor supports cursor shapes and the seat has a pointer. + pub cursor_shape_device: Option, + /// Last `wl_pointer.enter` serial, which every `set_shape` must quote. + pub last_pointer_enter_serial: u32, + /// Last left or middle press, which an interactive move or resize quotes. + pub last_pointer_serial: u32, + /// Last serial of any input event, which `xdg_popup.grab` and setting a selection quote. + pub last_input_serial: u32, + pub text_input_manager: Option, + pub text_input: Option, + /// `xdg-activation-v1`, used on the first configure to honour `XDG_ACTIVATION_TOKEN` and for [`App::request_activation_token`]. + pub activation_state: Option, + /// Honoured once, after the first configure of the main surface. + pub activation_token_pending: Option, + /// `xdg-session-management-v1` proxies and the on-disk store; the store is `None` when persistence is off (layer or lock surfaces, unusable app id, concurrent instance, or after `replaced`). + pub session: SessionRuntime, + /// `None` when the compositor does not advertise `wl_data_device_manager`; copy and paste then stay within the process. + pub data_device_manager: Option, + /// Created with the first seat that has a keyboard or a pointer. + pub data_device: Option, + /// The selection we publish, held so each paste from another client can be served the clipboard text. + pub clipboard_source: Option, + pub primary: PrimarySelection, + /// Selections other clients set, read by a worker thread and drained into `AppData::clipboard` once per iteration. + pub clipboard_inbox_tx: std::sync::mpsc::Sender, + pub clipboard_inbox_rx: std::sync::mpsc::Receiver, + /// A drag from another client: where it is over our surface, in logical coordinates, and the type negotiated on `enter`. Cleared on `leave` and after the drop. + pub drop_position: Option<( f64, f64 )>, + pub drop_mime: Option, + /// What other clients drop on our surfaces, read by a worker thread. + pub drop_inbox_tx: std::sync::mpsc::Sender, + pub drop_inbox_rx: std::sync::mpsc::Receiver, + /// First-configure latch for `App::start_fullscreen`: Mutter ignores a fullscreen request sent before the window is mapped. + pub pending_fullscreen: bool, + /// First-configure latch: clears `max_size` once the compositor has honoured the pinned initial size. + pub pending_size_hint_unpin: bool, + /// First-configure latch: check the size hint against the screen. + pub pending_size_hint_fit: bool, + /// The size hint once shrunk to fit the screen; replaces the app's. + pub fitted_size_hint: Option<( u32, u32 )>, + /// GLES canvases of destroyed overlays, reused by the next overlay. + pub canvas_pool: Vec, + /// `ext-foreign-toplevel-list-v1`; yields no toplevels when the compositor does not advertise it. + pub foreign_toplevel_list: ForeignToplevelList, + /// Output the main surface was created on, so a later `new_output` for the same output does not add a duplicate secondary surface. + pub main_output: Option, + /// Input-transparent child surfaces keyed by their stable [`SubsurfaceId`], reconciled each frame from [`App::subsurfaces`]. + pub subsurfaces: HashMap, + /// One GLES canvas for every subsurface raster, so a subsurface dropped and re-created (a lazily emitted sliding panel) does not recompile the shader programs, hundreds of ms on a mobile GPU, each time it reappears. + pub subsurface_gles_canvas: Option, + pub qh: QueueHandle>, +} diff --git a/src/event_loop/subsurface.rs b/src/platform/wayland/subsurface.rs similarity index 89% rename from src/event_loop/subsurface.rs rename to src/platform/wayland/subsurface.rs index d5c2848..a24f367 100644 --- a/src/event_loop/subsurface.rs +++ b/src/platform/wayland/subsurface.rs @@ -11,6 +11,7 @@ //! slide costs a compositor recomposite, not a client re-raster. use std::collections::HashMap; +use std::collections::hash_map::Entry; use std::sync::Arc; use smithay_client_toolkit::compositor::CompositorState; @@ -23,14 +24,14 @@ use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface; use crate::app::{ App, SubsurfaceParent }; use crate::egl_context::{ EglContext, EglSurface }; use crate::draw::DrawCtx; -use crate::draw::chrome::apply_input_region; use crate::draw::layout_and_draw; +use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::event_loop::perf::RasterTrace; use crate::render::Canvas; use crate::types::Rect; use crate::widget::Element; -use super::frame::pick_shm_format; -use super::AppData; +use super::present::{ apply_input_region, pick_shm_format }; /// Convert a layout (physical) position to the logical position the /// compositor expects for `wl_subsurface.set_position`. @@ -85,12 +86,12 @@ impl SubsurfaceSlot } } -fn focus_of( parent: SubsurfaceParent ) -> super::SurfaceFocus +fn focus_of( parent: SubsurfaceParent ) -> SurfaceFocus { match parent { - SubsurfaceParent::Main => super::SurfaceFocus::Main, - SubsurfaceParent::Overlay( id ) => super::SurfaceFocus::Overlay( id ), + SubsurfaceParent::Main => SurfaceFocus::Main, + SubsurfaceParent::Overlay( id ) => SurfaceFocus::Overlay( id ), } } @@ -114,7 +115,7 @@ fn arm_parent_frame( data: &mut AppData, parent: SubsurfaceParent, wl }; ss.frame_tick_due = false; if ss.frame_pending { return; } - let _ = wl.frame( &data.qh, focus_of( parent ) ); + let _ = wl.frame( &data.platform.qh, focus_of( parent ) ); ss.frame_pending = true; } @@ -129,8 +130,8 @@ fn keep_parent_cadence( data: &mut AppData, parent: SubsurfaceParent, }; if !std::mem::take( &mut ss.frame_tick_due ) { return; } if ss.frame_pending || !animating { return; } - let Some( wl ) = ss.surface.try_wl_surface().cloned() else { return }; - let _ = wl.frame( &data.qh, focus_of( parent ) ); + let Some( wl ) = ss.platform.surface.try_wl_surface().cloned() else { return }; + let _ = wl.frame( &data.platform.qh, focus_of( parent ) ); wl.commit(); ss.frame_pending = true; } @@ -149,7 +150,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.surface.try_wl_surface()?.clone(); + let surface = ss.platform.surface.try_wl_surface()?.clone(); let scale = ss.scale_factor.max( 1 ) as u32; Some( ( surface, ss.width * scale, ss.height * scale, scale ) ) } @@ -171,10 +172,10 @@ fn mark_parent_dirty( list: &mut Vec<( SubsurfaceParent, WlSurface )>, parent: S /// the parent surface is configured. pub( crate ) fn reconcile_subsurfaces( data: &mut AppData ) { - if data.subcompositor.is_none() { return; } + if data.platform.subcompositor.is_none() { return; } if !data.main.configured { return; } - let ( format, swap_rb ) = pick_shm_format( &data.shm ); + let ( format, swap_rb ) = pick_shm_format( &data.platform.shm ); let specs: Vec> = data.app.subsurfaces(); // Parents touched this frame; each committed once at the end so the @@ -185,16 +186,16 @@ pub( crate ) fn reconcile_subsurfaces( data: &mut AppData ) // Drop subsurfaces whose id disappeared from the spec list. let present: std::collections::HashSet = specs.iter().map( |s| s.id ).collect(); - let stale: Vec = data.subsurfaces.keys() + let stale: Vec = data.platform.subsurfaces.keys() .copied() .filter( |id| !present.contains( id ) ) .collect(); for id in stale { - if let Some( slot ) = data.subsurfaces.remove( &id ) + if let Some( slot ) = data.platform.subsurfaces.remove( &id ) { let parent = slot.parent; - slot.destroy( data.egl_context.as_ref() ); + slot.destroy( data.platform.egl_context.as_ref() ); if let Some( ( wl, _, _, _ ) ) = resolve_parent( data, parent ) { mark_parent_dirty( &mut dirty_parents, parent, &wl ); @@ -207,18 +208,18 @@ pub( crate ) fn reconcile_subsurfaces( data: &mut AppData ) let Some( ( parent_wl, pw, ph, scale ) ) = resolve_parent( data, spec.parent ) else { continue }; // Create on first sight. - if !data.subsurfaces.contains_key( &spec.id ) + if let Entry::Vacant( vacant ) = data.platform.subsurfaces.entry( spec.id ) { let ( subsurface, surface ) = { - let sc = data.subcompositor.as_ref().unwrap(); - sc.create_subsurface( parent_wl.clone(), &data.qh ) + let sc = data.platform.subcompositor.as_ref().unwrap(); + sc.create_subsurface( parent_wl.clone(), &data.platform.qh ) }; // Independent buffer commits; placement still applies on the // parent commit we issue below. subsurface.set_desync(); let ( lx, ly ) = to_logical( spec.x, spec.y, scale ); subsurface.set_position( lx, ly ); - data.subsurfaces.insert( spec.id, SubsurfaceSlot { + vacant.insert( SubsurfaceSlot { subsurface, surface, pool: None, @@ -237,12 +238,12 @@ pub( crate ) fn reconcile_subsurfaces( data: &mut AppData ) let parent_pending = parent_frame_pending( data, spec.parent ); // Tracked on the slot (not the canvas) because the GLES canvas lives - // in `data.subsurface_gles_canvas`, shared across all subsurfaces. - let size_changed = data.subsurfaces.get( &spec.id ) + // in `data.platform.subsurface_gles_canvas`, shared across all subsurfaces. + let size_changed = data.platform.subsurfaces.get( &spec.id ) .map( |s| s.last_size != ( pw, ph ) ) .unwrap_or( true ); let needs_raster = { - let slot = data.subsurfaces.get( &spec.id ).unwrap(); + let slot = data.platform.subsurfaces.get( &spec.id ).unwrap(); !slot.rastered || size_changed || slot.last_version != spec.content_version }; @@ -250,11 +251,11 @@ pub( crate ) fn reconcile_subsurfaces( data: &mut AppData ) { // GLES only when the spec asks for it (glass content); otherwise // the cheaper-to-create software rasteriser. - let egl = if spec.gpu { data.egl_context.as_ref() } else { None }; - let slot = data.subsurfaces.get_mut( &spec.id ).unwrap(); + let egl = if spec.gpu { data.platform.egl_context.as_ref() } else { None }; + let slot = data.platform.subsurfaces.get_mut( &spec.id ).unwrap(); render_slot::( - slot, egl, &mut data.subsurface_gles_canvas, - &data.shm, &data.compositor_state, &spec.view, + slot, egl, &mut data.platform.subsurface_gles_canvas, + &data.platform.shm, &data.platform.compositor_state, &spec.view, pw, ph, scale, format, swap_rb, size_changed, ); slot.rastered = true; @@ -267,7 +268,7 @@ pub( crate ) fn reconcile_subsurfaces( data: &mut AppData ) // A move waits for the parent's outstanding frame callback, so a drag // commits once per vblank with the latest position instead of once // per input sample. - let slot = data.subsurfaces.get_mut( &spec.id ).unwrap(); + let slot = data.platform.subsurfaces.get_mut( &spec.id ).unwrap(); if slot.last_pos != ( spec.x, spec.y ) && !parent_pending { let ( lx, ly ) = to_logical( spec.x, spec.y, scale ); @@ -399,7 +400,7 @@ fn render_slot_gpu( } let es = slot.egl_surface.as_ref().unwrap(); if egl_ctx.make_current( es ).is_err() { return; } - let trace = super::perf::RasterTrace::begin(); + let trace = RasterTrace::begin(); // The canvas (and its compiled shader programs) is shared across all // subsurface rasters, so a lazily re-created panel doesn't recompile. diff --git a/src/platform/wayland/surface.rs b/src/platform/wayland/surface.rs new file mode 100644 index 0000000..7646ccb --- /dev/null +++ b/src/platform/wayland/surface.rs @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use smithay_client_toolkit:: +{ + compositor::CompositorState, + shell:: + { + WaylandSurface as _, + wlr_layer::{ KeyboardInteractivity, Layer, LayerShell, LayerSurface }, + xdg::{ popup::Popup, window::Window }, + }, + shm::{ Shm, slot::SlotPool }, +}; +use smithay_client_toolkit::reexports::client:: +{ + protocol::{ wl_output::WlOutput, wl_surface::WlSurface }, + QueueHandle, +}; +use smithay_client_toolkit::session_lock::SessionLockSurface; +use std::sync::Arc; + +use crate::app::App; +use crate::egl_context::{ EglContext, EglSurface }; +use crate::event_loop::{ AppData, SurfaceFocus, SurfaceState }; +use crate::event_loop::perf::RasterTrace; +use crate::types::Rect; + +/// Configuration for a layer-shell surface, used both for the main surface +/// (when the app uses [`crate::app::ShellMode::Layer`]) and for each overlay +/// returned by [`crate::app::App::overlays`]. +#[ derive( Clone ) ] +pub( crate ) struct LayerConfig +{ + pub layer: Layer, + pub exclusive_zone: i32, + pub anchor: crate::app::Anchor, + pub size: ( u32, u32 ), + pub keyboard_exclusive: bool, + /// Wayland surface role namespace, sent to the compositor for debugging. + pub namespace: &'static str, +} + +pub( crate ) enum SurfaceKind +{ + /// Layer shell surface, fully created and committed. + Layer( LayerSurface ), + /// XDG window fallback (no layer shell compositor support). + Window( Window ), + /// Layer shell is available but we are waiting for an output to be + /// advertised before creating the Wayland surface. + Pending( LayerConfig ), + /// XDG popup, child of the main window. Used for combo dropdowns, + /// context menus, tooltips. The compositor positions it relative + /// to an anchor rect specified at creation time and may flip it + /// (drop-up vs drop-down) when constrained. + Popup( Popup ), + /// `ext-session-lock-v1` lock surface, created in `SessionLockHandler::locked`. + Lock( SessionLockSurface ), + /// Session lock requested; waiting for the compositor's `locked` event + /// before the lock surface exists. + PendingLock, +} + +impl SurfaceKind +{ + pub( crate ) fn wl_surface( &self ) -> &WlSurface + { + match self + { + SurfaceKind::Layer( l ) => l.wl_surface(), + SurfaceKind::Window( w ) => w.wl_surface(), + SurfaceKind::Popup( p ) => p.wl_surface(), + SurfaceKind::Lock( s ) => s.wl_surface(), + // Unreachable: draw_frame is only called when configured == true, + // which only becomes true after on_configure, which requires a real surface. + SurfaceKind::Pending( .. ) | SurfaceKind::PendingLock => unreachable!( "surface not yet created" ), + } + } + + /// Same as [`wl_surface`] but returns `None` for a [`SurfaceKind::Pending`] + /// surface instead of panicking. Used by event dispatch to look up which + /// [`SurfaceState`] an incoming Wayland event belongs to without risk of + /// crashing during startup. + pub( crate ) fn try_wl_surface( &self ) -> Option<&WlSurface> + { + match self + { + SurfaceKind::Layer( l ) => Some( l.wl_surface() ), + SurfaceKind::Window( w ) => Some( w.wl_surface() ), + SurfaceKind::Popup( p ) => Some( p.wl_surface() ), + SurfaceKind::Lock( s ) => Some( s.wl_surface() ), + SurfaceKind::Pending( .. ) | SurfaceKind::PendingLock => None, + } + } + + /// Create the actual layer surface on `output` using `layer_shell`. Returns + /// true if the surface was created (i.e., was `Pending` before). + pub( crate ) fn materialize( + &mut self, + compositor: &CompositorState, + layer_shell: &LayerShell, + qh: &QueueHandle>, + output: &WlOutput, + ) -> bool + { + if let SurfaceKind::Pending( ref cfg ) = *self + { + let surface = compositor.create_surface( qh ); + let layer_surface = layer_shell.create_layer_surface( + qh, + surface, + cfg.layer, + Some( cfg.namespace ), + Some( output ), + ); + layer_surface.set_exclusive_zone( cfg.exclusive_zone ); + layer_surface.set_anchor( cfg.anchor.to_wlr_anchor() ); + let interactivity = if cfg.keyboard_exclusive + { + KeyboardInteractivity::Exclusive + } else { + KeyboardInteractivity::OnDemand + }; + layer_surface.set_keyboard_interactivity( interactivity ); + layer_surface.set_size( cfg.size.0, cfg.size.1 ); + layer_surface.commit(); + *self = SurfaceKind::Layer( layer_surface ); + true + } else { + false + } + } +} + +/// The Wayland half of a surface: its role object and what the compositor was last told about it. +pub( crate ) struct WaylandSurfaceState +{ + pub surface: SurfaceKind, + /// SHM buffers for the software path; mutually exclusive with `egl_surface`, and the path is locked after the first configure. + pub pool: Option, + /// EGL window surface for the GPU path, created on the first configure when an [`EglContext`] is available. + pub egl_surface: Option, + /// Last size sent with `layer_surface.set_size`, compared each reconcile against the overlay's size so overlays can resize after creation. `(0, 0)` for other surfaces and before the first configure. + pub last_requested_size: ( u32, u32 ), + /// Logical exclusive zone last sent with `set_exclusive_zone`, kept in step with the size so the compositor never reserves a band the overlay no longer paints. + pub last_requested_zone: i32, + /// Anchor rect the xdg-popup positioner was last configured with; `None` for other surfaces. + pub last_popup_anchor: Option, + /// Monotonic counter for `xdg_popup.reposition` tokens. + pub popup_reposition_token: u32, + /// Opaque rects (physical) last applied to the surface. + pub opaque_full: Option>, + /// `LTK_PERF_RASTER` timing of the GPU frame being drawn, from preparing the canvas to presenting it. + pub raster_trace: Option, + /// Title bar height before decoration negotiation; restored when the compositor hands decorations back to the client. + pub titlebar_base: f32, +} + +impl From for WaylandSurfaceState +{ + fn from( surface: SurfaceKind ) -> Self + { + Self + { + surface, + pool: None, + egl_surface: None, + last_requested_size: ( 0, 0 ), + last_requested_zone: 0, + last_popup_anchor: None, + popup_reposition_token: 0, + opaque_full: None, + raster_trace: None, + titlebar_base: 0.0, + } + } +} + +impl SurfaceState +{ + /// Configure the surface with a new logical size. Picks the rendering path + /// on the very first configure (GPU when `egl_context` is `Some` and the + /// EGL window can be created, SHM otherwise) and just resizes the existing + /// path on subsequent configures. The two paths are mutually exclusive + /// (`pool.is_some()` ⇔ SHM, `egl_surface.is_some()` ⇔ GPU) and locked + /// after the first configure — switching backends mid-life would require + /// tearing down the canvas as well. + pub( crate ) fn on_configure( + &mut self, + shm: &Shm, + egl_context: Option<&Arc>, + w: u32, + h: u32, + ) + { + let scale = self.scale_factor.max( 1 ) as u32; + let pw = w.saturating_mul( scale ); + let ph = h.saturating_mul( scale ); + let pool_bytes = (pw as u64) + .saturating_mul( ph as u64 ) + .saturating_mul( 4 ); + + const MAX_POOL: u64 = 256 * 1024 * 1024; + if pool_bytes > MAX_POOL || pw == 0 || ph == 0 + { + eprintln!( "[ltk] on_configure: size {w}×{h} (physical {pw}×{ph}) out of range — ignoring" ); + return; + } + let pool_bytes = pool_bytes as usize; + + let wl = &mut self.platform; + if wl.egl_surface.is_some() + { + // GPU path: resize the wl_egl_window so the next eglSwapBuffers + // publishes a buffer of the new size. The canvas FBO is reallocated + // by `Canvas::resize`, but only after we eglMakeCurrent — the draw + // path will do that, so we just record the pending size here. + if let Some( ref es ) = wl.egl_surface + { + es.resize( pw as i32, ph as i32 ); + } + } else if wl.pool.is_some() { + // SHM path: reallocate the pool to fit the new buffer size. + match SlotPool::new( pool_bytes, shm ) + { + Ok( pool ) => wl.pool = Some( pool ), + Err( e ) => + { + eprintln!( "[ltk] SlotPool resize failed: {e} — keeping previous allocation" ); + return; + } + } + } else { + // First configure — pick the path. Try GPU when the bootstrap + // succeeded; fall back to SHM if surface creation fails. + let mut chose_gpu = false; + if let Some( ctx ) = egl_context + { + match ctx.create_surface( wl.surface.wl_surface(), pw as i32, ph as i32 ) + { + Ok( es ) => + { + wl.egl_surface = Some( es ); + chose_gpu = true; + } + Err( e ) => + { + eprintln!( "[ltk] eglCreateWindowSurface failed: {e} — using SHM" ); + } + } + } + if !chose_gpu + { + match SlotPool::new( pool_bytes, shm ) + { + Ok( pool ) => wl.pool = Some( pool ), + Err( e ) => + { + eprintln!( "[ltk] SlotPool init failed: {e}" ); + return; + } + } + } + } + + wl.surface.wl_surface().set_buffer_scale( self.scale_factor.max( 1 ) ); + self.set_size( w, h ); + } +} + +impl AppData +{ + /// Configure the main surface size, (re)allocate its rendering target, and + /// request a redraw. Routes to the GPU or SHM path inside `SurfaceState` + /// according to whether `self.platform.egl_context` is available. + pub( crate ) fn on_configure( &mut self, w: u32, h: u32 ) + { + self.main.on_configure( &self.platform.shm, self.platform.egl_context.as_ref(), w, h ); + // First-configure latch: honour an incoming + // `XDG_ACTIVATION_TOKEN` exactly once, after the surface has + // been mapped (`xdg_activation_v1.activate` is only meaningful + // against a configured surface). Compositors that don't + // advertise the global leave `activation_state` as `None` and + // the call drops the token silently. + if let Some( token ) = self.platform.activation_token_pending.take() + { + if let ( Some( activation ), Some( wl ) ) = + ( self.platform.activation_state.as_ref(), self.main.platform.surface.try_wl_surface() ) + { + activation.activate::( wl, token ); + } + } + self.on_main_resized( w, h ); + } + + /// Find which [`SurfaceFocus`] owns the given `WlSurface`, or `None` if it + /// does not correspond to any tracked surface. Safe even when the main + /// surface is still `Pending` (returns `None`). + pub( crate ) fn focus_for_surface( &self, wl: &WlSurface ) -> Option + { + if self.main.platform.surface.try_wl_surface() == Some( wl ) + { + return Some( SurfaceFocus::Main ); + } + for ( id, ss ) in self.overlays.iter() + { + if ss.platform.surface.try_wl_surface() == Some( wl ) + { + return Some( SurfaceFocus::Overlay( *id ) ); + } + } + None + } + + /// Whether `wl` is one of the secondary per-output surfaces. Pointer and + /// touch events landing on them must be dropped rather than falling back + /// to `Main` — their coordinates would hit-test the main surface's + /// widgets. Keyboard keeps the `Main` fallback so typing works wherever + /// the compositor puts the focus. + pub( crate ) fn is_secondary_surface( &self, wl: &WlSurface ) -> bool + { + self.secondaries.iter().any( |( _, ss )| ss.platform.surface.try_wl_surface() == Some( wl ) ) + } +} diff --git a/src/platform/wayland/text_input.rs b/src/platform/wayland/text_input.rs new file mode 100644 index 0000000..85a4c47 --- /dev/null +++ b/src/platform/wayland/text_input.rs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_v3::{ ContentHint, ContentPurpose }; + +use crate::app::App; +use crate::osk::Purpose; + +use super::WaylandPlatform; + +pub( super ) fn activate( platform: &mut WaylandPlatform, secure: bool, purpose: Purpose ) +{ + let ( hint, purpose ) = content_type( secure, purpose ); + match ( &platform.text_input_manager, &platform.text_input ) + { + ( Some( manager ), None ) => + { + let Some( seat ) = platform.seat_state.seats().next() else + { + eprintln!( "ltk: activate_text_input: no seat available" ); + return; + }; + let ti = manager.get_text_input( &seat, &platform.qh, () ); + ti.enable(); + ti.set_content_type( hint, purpose ); + ti.commit(); + platform.text_input = Some( ti ); + } + ( None, _ ) => + eprintln!( "ltk: activate_text_input: no text_input_manager (compositor did not advertise zwp_text_input_manager_v3)" ), + ( Some( _ ), Some( ti ) ) => + { + ti.enable(); + ti.set_content_type( hint, purpose ); + ti.commit(); + } + } +} + +pub( super ) fn deactivate( platform: &mut WaylandPlatform ) +{ + if let Some( ti ) = platform.text_input.take() + { + ti.disable(); + ti.commit(); + ti.destroy(); + } +} + +pub( super ) fn reenable( platform: &WaylandPlatform, secure: bool, purpose: Purpose ) +{ + if let Some( ti ) = &platform.text_input + { + let ( hint, purpose ) = content_type( secure, purpose ); + ti.enable(); + ti.set_content_type( hint, purpose ); + ti.commit(); + } +} + +/// Secure fields are flagged `Password` with `SensitiveData | HiddenText` so the input method skips prediction, autocorrect and storing the value. +fn content_type( secure: bool, purpose: Purpose ) -> ( ContentHint, ContentPurpose ) +{ + if secure + { + return ( ContentHint::SensitiveData | ContentHint::HiddenText, ContentPurpose::Password ); + } + let purpose = match purpose + { + Purpose::Normal => ContentPurpose::Normal, + Purpose::Terminal => ContentPurpose::Terminal, + }; + ( ContentHint::None, purpose ) +} diff --git a/src/platform/wayland/window.rs b/src/platform/wayland/window.rs new file mode 100644 index 0000000..70e1686 --- /dev/null +++ b/src/platform/wayland/window.rs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel; +use smithay_client_toolkit::shell::xdg::window::Window; + +use crate::app::App; +use crate::event_loop::SurfaceFocus; +use crate::event_loop::cursor_shape::ResizeEdge; + +use super::{ SurfaceKind, WaylandPlatform, WaylandSurfaceState }; + +pub( super ) fn window( surface: &WaylandSurfaceState ) -> Option<&Window> +{ + match surface.surface + { + SurfaceKind::Window( ref window ) => Some( window ), + _ => None, + } +} + +pub( super ) fn begin_move( platform: &WaylandPlatform, main: &WaylandSurfaceState ) +{ + let Some( window ) = window( main ) else { return }; + let Some( seat ) = platform.seat_state.seats().next() else { return }; + window.move_( &seat, platform.last_pointer_serial ); +} + +pub( super ) fn begin_resize( platform: &WaylandPlatform, main: &WaylandSurfaceState, edge: ResizeEdge ) +{ + let Some( window ) = window( main ) else { return }; + let Some( seat ) = platform.seat_state.seats().next() else { return }; + window.resize( &seat, platform.last_pointer_serial, xdg_edge( edge ) ); +} + +pub( super ) fn set_fullscreen( main: &WaylandSurfaceState, fullscreen: bool ) -> bool +{ + let Some( window ) = window( main ) else { return false }; + if fullscreen + { + window.set_fullscreen( None ); + } else { + window.unset_fullscreen(); + } + true +} + +pub( super ) fn request_frame( platform: &WaylandPlatform, surface: &WaylandSurfaceState, focus: SurfaceFocus ) -> bool +{ + let Some( wl ) = surface.surface.try_wl_surface() else { return false }; + let _ = wl.frame( &platform.qh, focus ); + wl.commit(); + true +} + +fn xdg_edge( edge: ResizeEdge ) -> xdg_toplevel::ResizeEdge +{ + match edge + { + ResizeEdge::Top => xdg_toplevel::ResizeEdge::Top, + ResizeEdge::Bottom => xdg_toplevel::ResizeEdge::Bottom, + ResizeEdge::Left => xdg_toplevel::ResizeEdge::Left, + ResizeEdge::Right => xdg_toplevel::ResizeEdge::Right, + ResizeEdge::TopLeft => xdg_toplevel::ResizeEdge::TopLeft, + ResizeEdge::TopRight => xdg_toplevel::ResizeEdge::TopRight, + ResizeEdge::BottomLeft => xdg_toplevel::ResizeEdge::BottomLeft, + ResizeEdge::BottomRight => xdg_toplevel::ResizeEdge::BottomRight, + } +} diff --git a/src/event_loop/window_fit.rs b/src/platform/wayland/window_fit.rs similarity index 89% rename from src/event_loop/window_fit.rs rename to src/platform/wayland/window_fit.rs index 7751912..d3b24d6 100644 --- a/src/event_loop/window_fit.rs +++ b/src/platform/wayland/window_fit.rs @@ -7,8 +7,7 @@ use smithay_client_toolkit::reexports::client::protocol::wl_output::WlOutput; use smithay_client_toolkit::shell::xdg::window::Window; use crate::app::App; - -use super::AppData; +use crate::event_loop::AppData; /// Share of an output a window may take when the compositor sent no `configure_bounds`. pub( crate ) const OUTPUT_SHARE: f32 = 0.9; @@ -35,7 +34,7 @@ impl AppData /// On the first configure, shrink a size hint larger than the screen. Returns whether it did. pub( crate ) fn fit_size_hint( &mut self, window: &Window, suggested: Option<( u32, u32 )> ) -> bool { - if !std::mem::take( &mut self.pending_size_hint_fit ) { return false; } + if !std::mem::take( &mut self.platform.pending_size_hint_fit ) { return false; } let Some( hint ) = self.app.window_size_hint() else { return false }; let bounds = suggested.filter( |b| b.0 > 0 && b.1 > 0 ).or_else( || self.output_bounds() ); let Some( bounds ) = bounds else { return false }; @@ -43,7 +42,7 @@ impl AppData if fitted == hint { return false; } window.set_min_size( Some( fitted ) ); if !self.app.window_resizable() { window.set_max_size( Some( fitted ) ); } - self.fitted_size_hint = Some( fitted ); + self.platform.fitted_size_hint = Some( fitted ); true } @@ -55,9 +54,9 @@ impl AppData fn output_logical_size( &self, preferred: Option<&WlOutput> ) -> Option<( u32, u32 )> { let info = preferred - .or( self.main_output.as_ref() ) - .and_then( |o| self.output_state.info( o ) ) - .or_else( || self.output_state.outputs().find_map( |o| self.output_state.info( &o ) ) )?; + .or( self.platform.main_output.as_ref() ) + .and_then( |o| self.platform.output_state.info( o ) ) + .or_else( || self.platform.output_state.outputs().find_map( |o| self.platform.output_state.info( &o ) ) )?; let ( w, h ) = info.logical_size.or_else( || { let mode = info.modes.iter().find( |m| m.current )?; diff --git a/src/render/mod.rs b/src/render/mod.rs index 665721a..188c5c1 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -174,6 +174,8 @@ pub struct SoftwareCanvas /// All drawing methods are dispatched by `match self` — no `dyn` /// indirection, so the backend branch stays predictable and /// inlinable in the hot path. +// One canvas per surface: boxing the GLES variant would save little memory and add an indirection to every draw call. +#[ allow( clippy::large_enum_variant ) ] pub enum Canvas { /// CPU rasterisation via tiny-skia, written to a diff --git a/src/render/primitives.rs b/src/render/primitives.rs index 07d1d7a..d015f6c 100644 --- a/src/render/primitives.rs +++ b/src/render/primitives.rs @@ -72,8 +72,7 @@ impl SoftwareCanvas let adjusted_color = Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha ); paint.set_color( adjusted_color.to_tiny_skia() ); paint.anti_alias = true; - let mut stroke = Stroke::default(); - stroke.width = width; + let stroke = Stroke { width, ..Stroke::default() }; if !corners.is_zero() { if let Some( path ) = build_rounded_rect( ts_rect, corners ) @@ -96,8 +95,7 @@ impl SoftwareCanvas let adjusted_color = Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha ); paint.set_color( adjusted_color.to_tiny_skia() ); paint.anti_alias = true; - let mut stroke = Stroke::default(); - stroke.width = width; + let stroke = Stroke { width, ..Stroke::default() }; self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() ); } @@ -116,8 +114,7 @@ impl SoftwareCanvas let mut paint = Paint::default(); paint.set_color( Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha ).to_tiny_skia() ); paint.anti_alias = true; - let mut stroke = Stroke::default(); - stroke.width = width; + let stroke = Stroke { width, ..Stroke::default() }; self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() ); } } diff --git a/src/render/text.rs b/src/render/text.rs index 546a235..49d44ff 100644 --- a/src/render/text.rs +++ b/src/render/text.rs @@ -44,12 +44,11 @@ impl SoftwareCanvas { let key = GlyphKey { glyph_id, size_bits: scaled.to_bits(), font_id }; self.evict_if_full( &key ); - if !self.glyph_cache.contains_key( &key ) + self.glyph_cache.entry( key ).or_insert_with( || { let ( metrics, bitmap ) = font.rasterize_indexed( glyph_id, scaled ); - self.glyph_cache.insert( key, GlyphEntry { metrics, bitmap } ); - } - self.glyph_cache.get( &key ).expect( "inserted above on miss" ) + GlyphEntry { metrics, bitmap } + } ) } pub fn draw_text( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color ) @@ -203,7 +202,7 @@ impl SoftwareCanvas // origin on both backends; the integer glyph metrics are added after. let gx = cursor_x.round() as i32 + metrics.xmin; let gy = ( y - glyph_y_offset ).round() as i32 - - metrics.ymin as i32 + - metrics.ymin - metrics.height as i32 + 1; for ( i, &alpha ) in bitmap.iter().enumerate() diff --git a/src/resources.rs b/src/resources.rs new file mode 100644 index 0000000..4b54141 --- /dev/null +++ b/src/resources.rs @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! An app's own copy of the resources ltk otherwise finds installed on the system: for an Android package, or any bundle with nowhere to install them. +//! +//! The directory holds `themes//`, searched after the user's themes and before the system's, and `fonts/`. A font a theme names by an absolute path that does not exist is looked up in `fonts/` by its file name, so a theme written against Debian's font paths works unchanged from a bundle that carries those files. + +use std::io; +use std::path::{ Path, PathBuf }; +use std::sync::OnceLock; + +static RESOURCE_DIR: OnceLock = OnceLock::new(); + +/// Use `dir` as the app's resource bundle. Call it before the theme is first used; later calls are ignored. +pub fn set_resource_dir( dir: impl Into ) +{ + let _ = RESOURCE_DIR.set( dir.into() ); +} + +/// The app's resource bundle, when [`set_resource_dir`] gave one; on Android, the resources unpacked from the APK. An app finds its own bundled files there. +pub fn resource_dir() -> Option<&'static Path> +{ + RESOURCE_DIR.get().map( PathBuf::as_path ) +} + +/// The font at `path`, or when there is none, the bundled font of the same file name. +pub( crate ) fn read_font( path: &Path ) -> io::Result> +{ + read_font_in( path, resource_dir() ) +} + +fn read_font_in( path: &Path, bundle: Option<&Path> ) -> io::Result> +{ + match std::fs::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 ) ) + } + read => read, + } +} + +#[ cfg( test ) ] +mod tests +{ + use super::read_font_in; + use std::path::Path; + + #[ test ] + fn a_missing_font_is_found_in_the_bundle_by_name() + { + let bundle = std::env::temp_dir().join( format!( "ltk-resources-{}", std::process::id() ) ); + std::fs::create_dir_all( bundle.join( "fonts" ) ).unwrap(); + std::fs::write( bundle.join( "fonts/Face-Regular.otf" ), b"face" ).unwrap(); + let read = read_font_in( Path::new( "/nonexistent/fonts/Face-Regular.otf" ), Some( &bundle ) ); + std::fs::remove_dir_all( &bundle ).unwrap(); + assert_eq!( read.unwrap(), b"face" ); + } + + #[ test ] + fn without_a_bundle_a_missing_font_stays_missing() + { + assert!( read_font_in( Path::new( "/nonexistent/fonts/Face-Regular.otf" ), None ).is_err() ); + } +} diff --git a/src/system_fonts.rs b/src/system_fonts.rs index a785cfb..1f21a82 100644 --- a/src/system_fonts.rs +++ b/src/system_fonts.rs @@ -69,9 +69,10 @@ struct FallbackFontSpec /// the first slot whose font owns a non-zero glyph index for the /// codepoint wins, so the broadest families come first (Noto Sans /// covers Cyrillic, Greek, extended Latin) and the script-specific -/// + CJK packs trail. DejaVu goes ahead of the CJK collection: it owns +/// and CJK packs trail. DejaVu goes ahead of the CJK collection: it owns /// the geometric shapes, arrows and box drawing that would otherwise /// pull a 19 MB collection in for a single bullet. +#[ cfg( not( target_os = "android" ) ) ] const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] = &[ // Noto Sans — Cyrillic, Greek, extended Latin. @@ -110,6 +111,22 @@ const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] = FallbackFontSpec { path: "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", face: 2 }, ]; +/// The same chain over Android's `/system/fonts`, whose file names have held across releases; Devanagari changed to a variable font in Android 12. +#[ cfg( target_os = "android" ) ] +const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] = +&[ + FallbackFontSpec { path: "/system/fonts/Roboto-Regular.ttf", face: 0 }, + FallbackFontSpec { path: "/system/fonts/NotoSansDevanagari-VF.ttf", face: 0 }, + FallbackFontSpec { path: "/system/fonts/NotoSansDevanagari-Regular.otf", face: 0 }, + FallbackFontSpec { path: "/system/fonts/NotoNaskhArabic-Regular.ttf", face: 0 }, + FallbackFontSpec { path: "/system/fonts/NotoSansHebrew-Regular.ttf", face: 0 }, + FallbackFontSpec { path: "/system/fonts/NotoSansThai-Regular.ttf", face: 0 }, + FallbackFontSpec { path: "/system/fonts/NotoSansSymbols-Regular-Subsetted.ttf", face: 0 }, + FallbackFontSpec { path: "/system/fonts/NotoSansSymbols-Regular-Subsetted2.ttf", face: 0 }, + FallbackFontSpec { path: "/system/fonts/NotoColorEmoji.ttf", face: 0 }, + FallbackFontSpec { path: "/system/fonts/NotoSansCJK-Regular.ttc", face: 2 }, +]; + /// Per-slot lazy state, two stages so probing a slot for coverage never /// builds a face: `bytes` is the file read once, `font` is the face /// built only when a glyph in this slot is actually needed. `None` in a @@ -180,6 +197,7 @@ pub fn lookup( ch: char ) -> Option> /// System-font search chain for the primary UI font, ordered by preference. /// Shared by [`find_font_opt`] and [`load_default_font_bytes`]. +#[ cfg( not( target_os = "android" ) ) ] const SYSTEM_FONT_CANDIDATES: &[&str] = &[ // Debian `fonts-sora` — the canonical path `ltk-theme-default` @@ -197,6 +215,10 @@ const SYSTEM_FONT_CANDIDATES: &[&str] = "/usr/share/fonts/TTF/DejaVuSans.ttf", ]; +/// None on Android: the embedded Sora is ltk's face there, not the system's Roboto. +#[ cfg( target_os = "android" ) ] +const SYSTEM_FONT_CANDIDATES: &[&str] = &[]; + /// Resolve the first system font available from [`SYSTEM_FONT_CANDIDATES`], or /// `None` if none exist. Used by the font registry and by tests that skip /// gracefully on images without the usual fonts installed. diff --git a/src/theme/assets.rs b/src/theme/assets.rs index fe23508..ed10e9b 100644 --- a/src/theme/assets.rs +++ b/src/theme/assets.rs @@ -250,7 +250,7 @@ fn pick_best_raster( dir: &Path, sw: u32, sh: u32 ) -> Option { continue; } - let Some( ( w_str, h_str ) ) = stem.split_once( |c: char| c == 'x' || c == 'X' ) else { continue }; + let Some( ( w_str, h_str ) ) = stem.split_once( [ 'x', 'X' ] ) else { continue }; let ( Ok( w ), Ok( h ) ) = ( w_str.parse::(), h_str.parse::() ) else { continue }; let new_area = ( w as u64 ) * ( h as u64 ); if w >= sw && h >= sh @@ -484,7 +484,7 @@ mod tests assert_eq!( rgba.len(), ( 16 * 16 * 4 ) as usize ); // Centre pixel should be opaque red (premultiplied → R=255 A=255). let centre = ( ( 8 * 16 + 8 ) * 4 ) as usize; - assert!( rgba[ centre + 0 ] > 200, "red channel" ); + assert!( rgba[ centre ] > 200, "red channel" ); assert!( rgba[ centre + 1 ] < 50, "green channel" ); assert!( rgba[ centre + 2 ] < 50, "blue channel" ); assert_eq!( rgba[ centre + 3 ], 255, "alpha" ); diff --git a/src/theme/document.rs b/src/theme/document.rs index 60ce249..888634b 100644 --- a/src/theme/document.rs +++ b/src/theme/document.rs @@ -95,7 +95,8 @@ impl ThemeDocument /// Order, highest priority first: /// 1. `$LTK_THEMES_DIR//` (when the env var is set) /// 2. `$XDG_DATA_HOME/ltk/themes//` (defaults to `~/.local/share/...`) - /// 3. `/usr/share/ltk/themes//` + /// 3. `/themes//` (when [`crate::set_resource_dir`] was called) + /// 4. `/usr/share/ltk/themes//` pub fn find( id: &str ) -> Result { for base in search_paths() diff --git a/src/theme/font_registry.rs b/src/theme/font_registry.rs index 6a35db0..359d824 100644 --- a/src/theme/font_registry.rs +++ b/src/theme/font_registry.rs @@ -182,7 +182,7 @@ impl FontRegistry } for src in &family.sources { - let bytes = std::fs::read( &src.path ) + let bytes = crate::resources::read_font( &src.path ) .map( Arc::new ) .map_err( |e| FontLoadError::Io( src.path.clone(), e ) )?; let font = FontFace::from_bytes( bytes, 0 ) @@ -217,7 +217,7 @@ impl FontRegistry } for src in &family.sources { - let bytes = match std::fs::read( &src.path ) + let bytes = match crate::resources::read_font( &src.path ) { Ok( b ) => Arc::new( b ), Err( e ) => diff --git a/src/theme/paint.rs b/src/theme/paint.rs index 745ffb1..3bc88db 100644 --- a/src/theme/paint.rs +++ b/src/theme/paint.rs @@ -37,24 +37,20 @@ use crate::types::Color; /// linear-light shows an undesirable darkening in the mid-point. [`GradientSpace::Srgb`] /// exists primarily to reproduce designs that were authored against it /// byte-for-byte. -#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] +#[ derive( Debug, Clone, Copy, PartialEq, Eq, Default ) ] pub enum GradientSpace { /// Interpolate directly in sRGB gamma-encoded space. Cheap, but midpoints /// of saturated gradients look muddy. Srgb, /// Interpolate in linear-light RGB. Default. Physically correct and fast. + #[ default ] LinearRgb, /// Interpolate in the Oklab perceptual colour space. Best for saturated /// brand gradients; slightly more expensive. Oklab, } -impl Default for GradientSpace -{ - fn default() -> Self { GradientSpace::LinearRgb } -} - // ─── Colour stops ──────────────────────────────────────────────────────────── /// One stop of a gradient: a `position` along the gradient axis (for linear) diff --git a/src/theme/prefs.rs b/src/theme/prefs.rs index 1e4bd3b..8f350e6 100644 --- a/src/theme/prefs.rs +++ b/src/theme/prefs.rs @@ -12,11 +12,12 @@ use crate::types::Color; // ─── Wallpaper / launcher ──────────────────────────────────────────────────── /// How a wallpaper image is fitted to its surface. -#[ derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize ) ] +#[ derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default ) ] #[ serde( rename_all = "kebab-case" ) ] pub enum WallpaperFit { /// Scale uniformly to fill the surface, cropping the overflow. + #[ default ] Cover, /// Scale uniformly to fit inside the surface, letterboxing if needed. Contain, @@ -28,11 +29,6 @@ pub enum WallpaperFit Tile, } -impl Default for WallpaperFit -{ - fn default() -> Self { WallpaperFit::Cover } -} - /// Wallpaper backing for a single theme variant. #[ derive( Debug, Clone ) ] pub struct WallpaperSpec @@ -76,19 +72,15 @@ pub struct WindowControlsSpec // ─── Mode and preference ───────────────────────────────────────────────────── /// Which of the two variants a theme family resolves to. -#[ derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize ) ] +#[ derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default ) ] #[ serde( rename_all = "kebab-case" ) ] pub enum ThemeMode { + #[ default ] Light, Dark, } -impl Default for ThemeMode -{ - fn default() -> Self { ThemeMode::Light } -} - impl ThemeMode { /// Auto-select light or dark from the local hour of day. @@ -103,20 +95,16 @@ impl ThemeMode /// Shell-side persisted preference. `Auto` is resolved to a [`ThemeMode`] /// before reaching ltk — the toolkit itself only knows about Light and Dark. -#[ derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize ) ] +#[ derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default ) ] #[ serde( rename_all = "kebab-case" ) ] pub enum ThemePreference { Light, Dark, + #[ default ] Auto, } -impl Default for ThemePreference -{ - fn default() -> Self { ThemePreference::Auto } -} - impl ThemePreference { /// Resolve to a concrete mode given the current local hour `[0, 23]`. diff --git a/src/theme/schema/raw.rs b/src/theme/schema/raw.rs index 70a48dd..bdd80b1 100644 --- a/src/theme/schema/raw.rs +++ b/src/theme/schema/raw.rs @@ -21,17 +21,16 @@ use super::color_serde; // ─── Enum mirrors with defaults ────────────────────────────────────────────── -#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ] +#[ derive( Debug, Clone, Copy, Serialize, Deserialize, Default ) ] #[ serde( rename_all = "kebab-case" ) ] pub( super ) enum RawGradientSpace { Srgb, + #[ default ] LinearRgb, Oklab, } -impl Default for RawGradientSpace { fn default() -> Self { RawGradientSpace::LinearRgb } } - impl From for GradientSpace { fn from( r: RawGradientSpace ) -> Self @@ -45,10 +44,11 @@ impl From for GradientSpace } } -#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ] +#[ derive( Debug, Clone, Copy, Serialize, Deserialize, Default ) ] #[ serde( rename_all = "kebab-case" ) ] pub( super ) enum RawBlendMode { + #[ default ] Normal, PlusLighter, Overlay, @@ -56,8 +56,6 @@ pub( super ) enum RawBlendMode Screen, } -impl Default for RawBlendMode { fn default() -> Self { RawBlendMode::Normal } } - impl From for BlendMode { fn from( r: RawBlendMode ) -> Self @@ -73,11 +71,9 @@ impl From for BlendMode } } -#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ] +#[ derive( Debug, Clone, Copy, Serialize, Deserialize, Default ) ] #[ serde( rename_all = "kebab-case" ) ] -pub( super ) enum RawFontStyle { Normal, Italic } - -impl Default for RawFontStyle { fn default() -> Self { RawFontStyle::Normal } } +pub( super ) enum RawFontStyle { #[ default ] Normal, Italic } impl From for FontStyle { @@ -91,11 +87,9 @@ impl From for FontStyle } } -#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ] +#[ derive( Debug, Clone, Copy, Serialize, Deserialize, Default ) ] #[ serde( rename_all = "kebab-case" ) ] -pub( super ) enum RawTextTransform { None, Uppercase, Lowercase, Capitalize } - -impl Default for RawTextTransform { fn default() -> Self { RawTextTransform::None } } +pub( super ) enum RawTextTransform { #[ default ] None, Uppercase, Lowercase, Capitalize } impl From for TextTransform { @@ -111,11 +105,9 @@ impl From for TextTransform } } -#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ] +#[ derive( Debug, Clone, Copy, Serialize, Deserialize, Default ) ] #[ serde( rename_all = "kebab-case" ) ] -pub( super ) enum RawTextDecoration { None, Underline, Strikethrough } - -impl Default for RawTextDecoration { fn default() -> Self { RawTextDecoration::None } } +pub( super ) enum RawTextDecoration { #[ default ] None, Underline, Strikethrough } impl From for TextDecoration { diff --git a/src/theme/search.rs b/src/theme/search.rs index e52539c..1faff30 100644 --- a/src/theme/search.rs +++ b/src/theme/search.rs @@ -16,7 +16,9 @@ use super::font_registry::FontRegistry; /// /// `LTK_THEMES_DIR` (when set) takes absolute precedence — useful during /// development to point the shells at an in-tree `themes/` directory without -/// having to install or symlink anything. +/// having to install or symlink anything. The app's own resource bundle +/// ([`crate::set_resource_dir`]) comes after the user's themes and before +/// the system's. pub fn search_paths() -> Vec { let mut out = Vec::new(); @@ -32,6 +34,10 @@ pub fn search_paths() -> Vec { out.push( PathBuf::from( home ).join( ".local/share/ltk/themes" ) ); } + if let Some( bundle ) = crate::resources::resource_dir() + { + out.push( bundle.join( "themes" ) ); + } out.push( PathBuf::from( "/usr/share/ltk/themes" ) ); out } diff --git a/src/theme/shadow.rs b/src/theme/shadow.rs index b404640..5167f1e 100644 --- a/src/theme/shadow.rs +++ b/src/theme/shadow.rs @@ -30,10 +30,11 @@ use crate::types::Color; /// All modes assume **premultiplied** colour and alpha. The GPU pipeline is /// expected to be premul-correct; the software pipeline must premultiply /// before applying these formulas. -#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] +#[ derive( Debug, Clone, Copy, PartialEq, Eq, Default ) ] pub enum BlendMode { /// Standard `src-over`: `result = src + dst × (1 − src.a)`. + #[ default ] Normal, /// CSS `plus-lighter`: `result = min(1, src + dst)`, channel-wise on /// premultiplied values. Adds light; never darkens. @@ -47,11 +48,6 @@ pub enum BlendMode Screen, } -impl Default for BlendMode -{ - fn default() -> Self { BlendMode::Normal } -} - // ─── Outer shadow ──────────────────────────────────────────────────────────── /// An outer drop shadow cast by a shape. diff --git a/src/theme/text_style.rs b/src/theme/text_style.rs index 6ddc241..a2965af 100644 --- a/src/theme/text_style.rs +++ b/src/theme/text_style.rs @@ -34,23 +34,20 @@ pub enum FontRef // ─── Style axes ────────────────────────────────────────────────────────────── /// Italic vs upright. -#[ derive( Debug, Clone, Copy, PartialEq, Eq, Hash ) ] +#[ derive( Debug, Clone, Copy, PartialEq, Eq, Hash, Default ) ] pub enum FontStyle { + #[ default ] Normal, Italic, } -impl Default for FontStyle -{ - fn default() -> Self { FontStyle::Normal } -} - /// Case transform applied at render time. -#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] +#[ derive( Debug, Clone, Copy, PartialEq, Eq, Default ) ] pub enum TextTransform { /// Render the string unchanged. + #[ default ] None, /// Uppercase every character. Uppercase, @@ -60,25 +57,16 @@ pub enum TextTransform Capitalize, } -impl Default for TextTransform -{ - fn default() -> Self { TextTransform::None } -} - /// Underline / strikethrough decoration. -#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] +#[ derive( Debug, Clone, Copy, PartialEq, Eq, Default ) ] pub enum TextDecoration { + #[ default ] None, Underline, Strikethrough, } -impl Default for TextDecoration -{ - fn default() -> Self { TextDecoration::None } -} - // ─── Line height ───────────────────────────────────────────────────────────── /// How the vertical advance between lines is specified. diff --git a/src/tree.rs b/src/tree.rs index f4f1695..2d1f3d8 100755 --- a/src/tree.rs +++ b/src/tree.rs @@ -27,20 +27,20 @@ pub fn find_widget_at( /// *focusable leaves*, not the size of the [`crate::widget::Element`] tree. /// If the hot path ever needs O(1), swap the per-surface `widget_rects` /// slice for a `Vec` + a `HashMap` index. -pub fn find_widget<'a, Msg: Clone>( - widget_rects: &'a [ LaidOutWidget ], +pub fn find_widget( + widget_rects: &[ LaidOutWidget ], flat_idx: usize, -) -> Option<&'a LaidOutWidget> +) -> Option<&LaidOutWidget> { widget_rects.iter().find( |w| w.flat_idx == flat_idx ) } /// Convenience wrapper around [`find_widget`] that returns just the handler /// snapshot — what most input dispatch sites actually want. -pub fn find_handlers<'a, Msg: Clone>( - widget_rects: &'a [ LaidOutWidget ], +pub fn find_handlers( + widget_rects: &[ LaidOutWidget ], flat_idx: usize, -) -> Option<&'a WidgetHandlers> +) -> Option<&WidgetHandlers> { find_widget( widget_rects, flat_idx ).map( |w| &w.handlers ) } diff --git a/src/types.rs b/src/types.rs index e23f26f..62370c0 100644 --- a/src/types.rs +++ b/src/types.rs @@ -299,9 +299,10 @@ pub struct WidgetId( pub &'static str ); /// runtime can convert losslessly. Compositors that do not advertise /// `wp_cursor_shape_v1` ignore these — the user sees their default /// system cursor. -#[ derive( Debug, Clone, Copy, PartialEq, Eq, Hash ) ] +#[ derive( Debug, Clone, Copy, PartialEq, Eq, Hash, Default ) ] pub enum CursorShape { + #[ default ] Default, ContextMenu, Help, @@ -344,11 +345,6 @@ pub enum CursorShape ZoomOut, } -impl Default for CursorShape -{ - fn default() -> Self { CursorShape::Default } -} - #[ cfg( test ) ] mod tests { diff --git a/src/wallpaper.rs b/src/wallpaper.rs index 2a896b1..b9a4eb3 100644 --- a/src/wallpaper.rs +++ b/src/wallpaper.rs @@ -256,7 +256,7 @@ mod tests // First pixel of the cropped image must come from column 0 of the source. assert_eq!( bytes[0], 0 ); // Last pixel of the first row must come from column (w-1) of the crop = 5. - let last = ( ( w as usize - 1 ) * 4 ) as usize; + let last = ( w as usize - 1 ) * 4; assert_eq!( bytes[ last ], 5 ); } diff --git a/src/widget/date_picker/mod.rs b/src/widget/date_picker/mod.rs index fad6cdd..57a34b3 100644 --- a/src/widget/date_picker/mod.rs +++ b/src/widget/date_picker/mod.rs @@ -369,7 +369,7 @@ impl DatePicker { // Convert the column index (display order) back to a // weekday number (0 = Sunday) honouring `first_dow`. - let dow = ( ( slot + first ) % 7 ) as u8; + let dow = ( slot + first ) % 7; let cell: Element = container::( text( dow_short( dow ) ) .size( dow_fs ) @@ -395,7 +395,7 @@ impl DatePicker let mut day_grid = grid::( 7 ).spacing( theme::SPACING ); for slot in 0..total_cells { - let day_num = slot as i32 - leading_blanks as i32 + 1; + let day_num = slot - leading_blanks as i32 + 1; let cell: Element = if day_num < 1 || day_num > dim as i32 { container::( spacer() ).padding( 0.0 ).into() diff --git a/src/widget/separator/mod.rs b/src/widget/separator/mod.rs index 61a65f1..d46025e 100644 --- a/src/widget/separator/mod.rs +++ b/src/widget/separator/mod.rs @@ -42,6 +42,14 @@ pub struct Separator pub( crate ) pad_v: Option, } +impl Default for Separator +{ + fn default() -> Self + { + Self::new() + } +} + impl Separator { /// Create a separator with the theme's default divider colour; the diff --git a/src/widget/slider/mod.rs b/src/widget/slider/mod.rs index a45be8e..17dff72 100644 --- a/src/widget/slider/mod.rs +++ b/src/widget/slider/mod.rs @@ -224,7 +224,7 @@ impl Slider } /// Switch the thumb to the accent-pill style: accent-coloured inner - /// + 4 px white border. Default thumb stays white-on-text-primary + /// with a 4 px white border. Default thumb stays white-on-text-primary /// for shells that haven't opted in. pub fn accent_thumb( mut self, on: bool ) -> Self { diff --git a/src/widget/text_edit/tests.rs b/src/widget/text_edit/tests.rs index a56084a..3fe3116 100644 --- a/src/widget/text_edit/tests.rs +++ b/src/widget/text_edit/tests.rs @@ -92,7 +92,7 @@ fn insert_str_clamps_runaway_cursor() fn insert_str_returns_on_change_with_new_value() { let mut t = new_te( "ab" ) - .on_change( |s| Msg::Changed( s ) ); + .on_change( Msg::Changed ); let msg = t.insert_str( "c" ); assert_eq!( msg, Some( Msg::Changed( "abc".into() ) ) ); } @@ -160,7 +160,7 @@ fn backspace_handles_emoji_codepoint() fn backspace_returns_on_change_with_new_value() { let mut t = new_te( "ab" ) - .on_change( |s| Msg::Changed( s ) ); + .on_change( Msg::Changed ); let msg = t.backspace(); assert_eq!( msg, Some( Msg::Changed( "a".into() ) ) ); } diff --git a/src/widget/time_picker/mod.rs b/src/widget/time_picker/mod.rs index 3adaca7..3259804 100644 --- a/src/widget/time_picker/mod.rs +++ b/src/widget/time_picker/mod.rs @@ -96,22 +96,6 @@ impl Time } } -/// Compute the signed delta needed to step `value` to the next -/// (`dir = 1`) or previous (`dir = -1`) multiple of `step`. The -/// returned delta carries the right sign already, so the caller just -/// adds it to its working seconds-of-day count. -/// -/// Designed for the minute / second steppers in -/// [`TimePicker`](crate::widget::time_picker::TimePicker) — it ignores -/// the wraparound at 60 (the picker always feeds the result through -/// [`Time::from_seconds`], which handles the `:00` → next-hour -/// rollover via `rem_euclid`). -/// -/// Examples (`step = 5`): -/// * `value = 30, dir = 1` → `+5` → next aligned at `35` -/// * `value = 32, dir = 1` → `+3` → next aligned at `35` -/// * `value = 32, dir = -1` → `-2` → previous aligned at `30` -/// * `value = 30, dir = -1` → `-5` → previous aligned at `25` /// Filter `s` down to its ASCII digit characters, parse them as a /// decimal integer and clamp the result to `0..=max`. Returns `None` /// only when the filtered string is empty so the caller can detect @@ -130,6 +114,22 @@ fn parse_clamped_digits( s: &str, max: u8 ) -> Option Some( n.min( max as u32 ) as u8 ) } +/// Compute the signed delta needed to step `value` to the next +/// (`dir = 1`) or previous (`dir = -1`) multiple of `step`. The +/// returned delta carries the right sign already, so the caller just +/// adds it to its working seconds-of-day count. +/// +/// Designed for the minute / second steppers in +/// [`TimePicker`](crate::widget::time_picker::TimePicker) — it ignores +/// the wraparound at 60 (the picker always feeds the result through +/// [`Time::from_seconds`], which handles the `:00` → next-hour +/// rollover via `rem_euclid`). +/// +/// Examples (`step = 5`): +/// * `value = 30, dir = 1` → `+5` → next aligned at `35` +/// * `value = 32, dir = 1` → `+3` → next aligned at `35` +/// * `value = 32, dir = -1` → `-2` → previous aligned at `30` +/// * `value = 30, dir = -1` → `-5` → previous aligned at `25` fn snap_step_delta( value: i32, step: i32, dir: i32 ) -> i32 { let step = step.max( 1 ); diff --git a/src/widget/window_button/mod.rs b/src/widget/window_button/mod.rs index db73a5e..619e378 100644 --- a/src/widget/window_button/mod.rs +++ b/src/widget/window_button/mod.rs @@ -222,7 +222,7 @@ fn icon_name( kind: WindowButtonKind ) -> &'static str /// Render the glyph for `kind` centered inside `rect`, tinted with /// `color`. Tries the theme catalogue first via [`crate::theme::icon_rgba`] -/// + [`crate::theme::tint_symbolic`]; falls back to the programmatic +/// and [`crate::theme::tint_symbolic`]; falls back to the programmatic /// line / rect drawing in [`draw_symbol`] when the active theme has no /// catalogue entry for `window/`. fn draw_glyph( canvas: &mut Canvas, kind: WindowButtonKind, rect: Rect, color: Color ) diff --git a/tests/element_map.rs b/tests/element_map.rs index d0cc532..8940563 100644 --- a/tests/element_map.rs +++ b/tests/element_map.rs @@ -82,13 +82,10 @@ fn button_press_msg_is_remapped() let mut found = false; for_each_handler( &surface, |h| { - if let WidgetHandlers::Button { on_press, .. } = h + if let WidgetHandlers::Button { on_press: Some( msg ), .. } = h { - if let Some( msg ) = on_press - { - assert_eq!( *msg, AppMsg::Sub( SubMsg::Pressed ), "button.on_press" ); - found = true; - } + assert_eq!( *msg, AppMsg::Sub( SubMsg::Pressed ), "button.on_press" ); + found = true; } } ); assert!( found, "no Button handler with on_press in the rendered tree" ); diff --git a/tests/text_edit_dispatch.rs b/tests/text_edit_dispatch.rs index d1b799c..79d4af5 100644 --- a/tests/text_edit_dispatch.rs +++ b/tests/text_edit_dispatch.rs @@ -26,13 +26,13 @@ fn login_view() -> Element .spacing( 8.0 ) .push( text_edit( "Username", "alice" ) - .on_change( |s| Msg::Username( s ) ) + .on_change( Msg::Username ) .on_submit( Msg::UsernameSubmit ), ) .push( text_edit::( "Password", "" ) .secure( true ) - .on_change( |s| Msg::Password( s ) ) + .on_change( Msg::Password ) .on_submit( Msg::PasswordSubmit ), ) .into() @@ -129,7 +129,7 @@ fn current_value_reflects_state_after_app_update() .padding( 16.0 ) .push( text_edit( "Username", val.to_string() ) - .on_change( |s| Msg::Username( s ) ), + .on_change( Msg::Username ), ) .into() }; diff --git a/tests/widget_dispatch.rs b/tests/widget_dispatch.rs index c2637b9..143632d 100644 --- a/tests/widget_dispatch.rs +++ b/tests/widget_dispatch.rs @@ -148,7 +148,7 @@ fn three_radios_each_route_to_their_own_message() fn slider_handler_variant_is_slider() { let view: Element = column() - .push( slider( 0.5 ).on_change( |v| Msg::Brightness( v ) ) ) + .push( slider( 0.5 ).on_change( Msg::Brightness ) ) .into(); let surface = render_view( view ); @@ -161,7 +161,7 @@ fn slider_handler_variant_is_slider() fn slider_change_msg_wraps_value_through_callback() { let view: Element = column() - .push( slider( 0.0 ).on_change( |v| Msg::Brightness( v ) ) ) + .push( slider( 0.0 ).on_change( Msg::Brightness ) ) .into(); let surface = render_view( view ); @@ -175,7 +175,7 @@ fn slider_change_msg_wraps_value_through_callback() fn slider_value_from_pos_at_left_edge_is_zero() { let view: Element = column() - .push( slider( 0.5 ).on_change( |v| Msg::Brightness( v ) ) ) + .push( slider( 0.5 ).on_change( Msg::Brightness ) ) .into(); let surface = render_view( view ); @@ -189,7 +189,7 @@ fn slider_value_from_pos_at_left_edge_is_zero() fn slider_value_from_pos_at_right_edge_is_one() { let view: Element = column() - .push( slider( 0.5 ).on_change( |v| Msg::Brightness( v ) ) ) + .push( slider( 0.5 ).on_change( Msg::Brightness ) ) .into(); let surface = render_view( view ); @@ -203,7 +203,7 @@ fn slider_value_from_pos_at_right_edge_is_one() fn slider_value_from_pos_at_midpoint_is_one_half() { let view: Element = column() - .push( slider( 0.0 ).on_change( |v| Msg::Brightness( v ) ) ) + .push( slider( 0.0 ).on_change( Msg::Brightness ) ) .into(); let surface = render_view( view ); @@ -222,7 +222,7 @@ fn slider_value_from_pos_clamps_outside_rect() { // Pointer way outside the rect on either side must clamp to 0 or 1. let view: Element = column() - .push( slider( 0.5 ).on_change( |v| Msg::Brightness( v ) ) ) + .push( slider( 0.5 ).on_change( Msg::Brightness ) ) .into(); let surface = render_view( view ); @@ -274,7 +274,7 @@ fn slider_axis_enum_is_publicly_exposed() fn slider_layout_rect_has_non_zero_width() { let view: Element = column() - .push( slider( 0.5 ).on_change( |v| Msg::Brightness( v ) ) ) + .push( slider( 0.5 ).on_change( Msg::Brightness ) ) .into(); let surface = render_view( view ); let w: Rect = surface.widget_rects()[ 0 ].rect;