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
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

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<A, P: Platform = NativePlatform<A>>` and `SurfaceState<Msg, S = NativeSurface>`, 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.
This commit is contained in:
2026-10-16 23:46:50 +02:00
parent 85aa85a357
commit cacf871ddd
131 changed files with 6155 additions and 3492 deletions
+12
View File
@@ -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<A, P: Platform = NativePlatform<A>>` and `SurfaceState<Msg, S = NativeSurface>`, 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.
+37 -6
View File
@@ -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
+52 -2
View File
@@ -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)/
+8 -2
View File
@@ -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.
+18
View File
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
// `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" );
}
}
+66 -6
View File
File diff suppressed because one or more lines are too long
+56
View File
@@ -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<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg>
{
let palette = ltk::theme_palette();
column::<Msg>()
.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::<Msg>( "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() );
}
+1 -2
View File
@@ -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::<Message>(
column::<Message>()
.padding( 24.0 )
+1 -1
View File
@@ -53,7 +53,7 @@ fn circle_path( r: Rect ) -> Vec<PathCmd>
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 ),
+1 -1
View File
@@ -220,7 +220,7 @@ fn custom_body_dialog( brightness: f32 ) -> Element<Msg>
.color( secondary ) )
.push(
slider::<Msg>( brightness )
.on_change( |v| Msg::BrightnessChanged( v ) )
.on_change( Msg::BrightnessChanged )
.accent_thumb( true ),
);
+9 -9
View File
@@ -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::<Msg>()
.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,
}
}
+56
View File
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;
}
}
+160
View File
@@ -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/&/\&amp;/g; s/</\&lt;/g; s/"/\&quot;/g' )
assets=${5:-}
abi=${ANDROID_ABI:-arm64-v8a}
tools=${BUILD_TOOLS:-/usr/lib/android-sdk/build-tools/debian}
framework=${FRAMEWORK_RES:-/usr/share/android-framework-res/framework-res.apk}
keystore=${KEYSTORE:-$HOME/.android/debug.keystore}
pass=${KS_PASS:-android}
for tool in "$tools/aapt" "$tools/apksigner" "$framework"
do
if [ ! -e "$tool" ]
then
echo "android-apk: $tool missing; install aapt, apksigner, zipalign and android-framework-res" >&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 '<?xml version="1.0" encoding="utf-8"?>'
echo '<manifest xmlns:android="http://schemas.android.com/apk/res/android"'
echo " package=\"$package\""
echo " android:versionCode=\"${VERSION_CODE:-1}\""
echo " android:versionName=\"${VERSION_NAME:-0.1}\">"
echo " <uses-sdk android:minSdkVersion=\"${MIN_SDK:-26}\" android:targetSdkVersion=\"${TARGET_SDK:-34}\"/>"
echo ' <uses-feature android:glEsVersion="0x00020000" android:required="true"/>'
for permission in ${PERMISSIONS:-}
do
echo " <uses-permission android:name=\"$permission\"/>"
done
echo " <application android:label=\"$label\"$icon_attr android:hasCode=\"$has_code\""
# No action bar: hidden under the native window, it would still be taken from the content rect.
echo ' android:theme="@android:style/Theme.DeviceDefault.NoActionBar">'
echo ' <activity android:name="android.app.NativeActivity"'
echo ' android:exported="true"'
echo ' android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|density|uiMode"'
echo ' android:windowSoftInputMode="adjustResize">'
echo ' <meta-data android:name="android.app.lib_name" android:value="main"/>'
echo ' <intent-filter>'
echo ' <action android:name="android.intent.action.MAIN"/>'
echo ' <category android:name="android.intent.category.LAUNCHER"/>'
echo ' </intent-filter>'
echo ' </activity>'
if [ "$has_code" = true ]
then
use=$( printf '%s' "${KEEP_ALIVE_USE:-Keeps the app running in the background}" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/"/\&quot;/g' )
echo ' <service android:name="net.liberux.ltk.KeepAlive" android:exported="false" android:foregroundServiceType="specialUse">'
echo " <property android:name=\"android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE\" android:value=\"$use\"/>"
echo ' </service>'
fi
echo ' </application>'
echo '</manifest>'
} > "$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"
+64
View File
@@ -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
+29 -14
View File
@@ -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<ActionRequest>;
/// 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<Self>
{
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<TreeUpdate>
@@ -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<ActionRequest>,
}
#[ 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 ) {}
+1
View File
@@ -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 );
+8 -3
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App>( 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<A: App>( app: A )
/// }
/// }
/// ```
#[ cfg( backend ) ]
pub fn try_run<A: App>( app: A ) -> Result<(), RunError>
{
crate::event_loop::try_run( app )
crate::platform::try_run( app )
}
#[ cfg( backend ) ]
pub use crate::event_loop::RunError;
+1 -80
View File
@@ -1,58 +1,11 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<Vec<Rect>>,
bg: Color,
region: Option<&[Rect]>,
width: u32,
height: u32,
scale: u32,
)
{
let s = scale.max( 1 ) as f32;
let rects: Vec<Rect> = 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 );
}
}
-218
View File
@@ -1,218 +0,0 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &CompositorState,
egl_ctx: &Arc<EglContext>,
view: &Element<Msg>,
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::<Msg>( 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<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &CompositorState,
egl_ctx: &Arc<EglContext>,
view: &Element<Msg>,
bg: Color,
input_region: Option<&[Rect]>,
opaque_region: Option<&[Rect]>,
dirty_rects: Vec<Rect>,
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::<Msg>( 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;
}
+16 -15
View File
@@ -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<Msg: Clone>
/// 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<Msg: Clone>(
frame: &mut crate::event_loop::FrameState<Msg>,
focused_idx: Option<usize>,
@@ -144,9 +143,10 @@ pub( crate ) fn build_draw_ctx<Msg: Clone>(
/// 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<Msg: Clone>(
frame: &mut crate::event_loop::FrameState<Msg>,
focused_idx: Option<usize>,
@@ -178,10 +178,11 @@ pub( crate ) fn commit_draw_ctx<Msg: Clone>(
}
/// 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,
+147
View File
@@ -0,0 +1,147 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<Canvas>, 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<Msg: Clone, S>(
ss: &mut SurfaceState<Msg, S>,
view: &Element<Msg>,
bg: Color,
debug_layout: bool,
precise_damage: bool,
pw: u32,
ph: u32,
scale: u32,
) -> Vec<Rect>
{
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<Msg: Clone, S>(
ss: &mut SurfaceState<Msg, S>,
view: &Element<Msg>,
bg: Color,
dirty_rects: Vec<Rect>,
pw: u32,
ph: u32,
scale: u32,
) -> Vec<Rect>
{
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<Msg: Clone, S>(
ss: &mut SurfaceState<Msg, S>,
view: &Element<Msg>,
debug_layout: bool,
pw: u32,
ph: u32,
scale: u32,
) -> DrawCtx<Msg>
{
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::<Msg>( 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
}
-243
View File
@@ -1,243 +0,0 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &CompositorState,
view: &Element<Msg>,
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::<Msg>( 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<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &CompositorState,
view: &Element<Msg>,
bg: Color,
input_region: Option<&[Rect]>,
opaque_region: Option<&[Rect]>,
shm_format: wl_shm::Format,
swap_rb: bool,
dirty_rects: Vec<Rect>,
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::<Msg>( 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;
}
+129 -28
View File
@@ -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<egl::EGL1_4>;
/// 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<EglInstance>`, `Arc<glow::Context>`) 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<SwapBuffersWithDamageFn>,
/// 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<glow::Context>,
}
#[ 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<Self, String>
{
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, String>
{
Self::init( default_display, 1 )
}
fn init( get_display: impl FnOnce( &EglInstance ) -> Result<egl::Display, String>, swap_interval: i32 ) -> Result<Self, String>
{
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<EglSurface, String>
@@ -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<EglSurface, String>
{
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<egl::Display, String>
fn default_display( egl: &EglInstance ) -> Result<egl::Display, String>
{
// 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<egl::Display, String>
}
/// 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 ) ] );
}
}
+185
View File
@@ -0,0 +1,185 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<A: App, P: Platform> AppData<A, P>
{
/// 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<SurfaceView<A::Message>> = 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<ActionRequest> = match self.a11y
{
Some( ref a ) => a.action_rx.try_iter().collect(),
None => return,
};
if requests.is_empty() { return; }
let overlay_keys: Vec<OverlayId> = 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();
}
_ => {}
}
}
}
}
+34 -228
View File
@@ -1,83 +1,22 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App>
/// The runtime's state for one app, on the platform `P`: the build's backend unless a test supplies another.
pub struct AppData<A: App, P: Platform = NativePlatform<A>>
{
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<SubcompositorState>,
pub shm: Shm,
pub session_lock: Option<SessionLock>,
/// 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<Arc<EglContext>>,
#[ allow( dead_code ) ]
pub xdg_shell: Option<XdgShell>,
/// 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<LayerShell>,
pub keyboard: Option<WlKeyboard>,
pub pointer: Option<WlPointer>,
pub touch: Option<WlTouch>,
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<smithay_client_toolkit::seat::pointer::cursor_shape::CursorShapeManager>,
/// Per-pointer cursor-shape device, populated when the compositor
/// supports cursor-shape AND the seat has a pointer capability.
pub cursor_shape_device: Option<smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1>,
/// 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<A: App>
/// surface. `Some(s)` lets the dispatch short-circuit when the
/// target equals what we already sent.
pub current_cursor_shape: Option<crate::types::CursorShape>,
pub text_input_manager: Option<ZwpTextInputManagerV3>,
pub text_input: Option<ZwpTextInputV3>,
/// 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<A: App>
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<u64>,
/// `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<smithay_client_toolkit::activation::ActivationState>,
/// 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<String>,
/// `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<smithay_client_toolkit::data_device_manager::DataDeviceManagerState>,
/// 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<smithay_client_toolkit::data_device_manager::data_device::DataDevice>,
/// 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<smithay_client_toolkit::data_device_manager::data_source::CopyPasteSource>,
/// 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<String>,
/// Receiver half — drained once per run loop iteration.
pub clipboard_inbox_rx: std::sync::mpsc::Receiver<String>,
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<String>,
pub drop_inbox_tx: std::sync::mpsc::Sender<super::data_device::DropPayload>,
pub drop_inbox_rx: std::sync::mpsc::Receiver<super::data_device::DropPayload>,
/// 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<String>,
/// 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<A: App>
/// the per-frame tree update and the per-iteration action
/// inbox drain.
pub a11y: Option<crate::a11y::A11yState>,
/// 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<A: App>
/// long-press promotion.
pub button_repeat: Option<ButtonRepeatState>,
/// 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<A: App>
pub pending_drag_inits: Vec<Point>,
pub tooltip_pending: Option<TooltipPending>,
pub tooltip_visible: Option<TooltipVisible>,
pub qh: QueueHandle<Self>,
/// 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<bool>,
/// 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<A::Message>,
pub main: SurfaceState<A::Message, P::Surface>,
/// 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<OverlayId, SurfaceState<A::Message>>,
pub overlays: HashMap<OverlayId, SurfaceState<A::Message, P::Surface>>,
/// 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<A: App>
/// 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<A::Message> )>,
/// 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<WlOutput>,
/// Input-transparent child surfaces keyed by their stable
/// [`SubsurfaceId`]. Reconciled each frame from [`App::subsurfaces`].
pub subsurfaces: HashMap<SubsurfaceId, SubsurfaceSlot>,
/// 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<crate::render::Canvas>,
/// GLES canvases of destroyed overlays, reused by the next overlay.
pub canvas_pool: Vec<crate::render::Canvas>,
pub secondaries: Vec<( P::Output, SurfaceState<A::Message, P::Surface> )>,
/// 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<A: App>
pub perf: super::perf::PerfState,
}
impl<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
/// 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 );
}
/// 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<SurfaceFocus>
{
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<A::Message>
pub( crate ) fn surface( &self, focus: SurfaceFocus ) -> &SurfaceState<A::Message, P::Surface>
{
match focus
{
@@ -363,7 +186,7 @@ impl<A: App> AppData<A>
/// 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<A::Message>>
pub( crate ) fn try_surface( &self, focus: SurfaceFocus ) -> Option<&SurfaceState<A::Message, P::Surface>>
{
match focus
{
@@ -374,7 +197,7 @@ impl<A: App> AppData<A>
/// Mutable counterpart of [`surface`].
#[ allow( dead_code ) ]
pub( crate ) fn surface_mut( &mut self, focus: SurfaceFocus ) -> &mut SurfaceState<A::Message>
pub( crate ) fn surface_mut( &mut self, focus: SurfaceFocus ) -> &mut SurfaceState<A::Message, P::Surface>
{
match focus
{
@@ -385,7 +208,7 @@ impl<A: App> AppData<A>
}
/// Non-panicking variant of [`surface_mut`].
pub( crate ) fn try_surface_mut( &mut self, focus: SurfaceFocus ) -> Option<&mut SurfaceState<A::Message>>
pub( crate ) fn try_surface_mut( &mut self, focus: SurfaceFocus ) -> Option<&mut SurfaceState<A::Message, P::Surface>>
{
match focus
{
@@ -447,37 +270,20 @@ impl<A: App> AppData<A>
}
}
// 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::<Self>( &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 );
+33 -7
View File
@@ -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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
/// 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<A: App> AppData<A>
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 )
{
+2 -1
View File
@@ -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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
/// Open the built-in Copy / Cut / Paste context menu near `pos`,
/// targeting the TextEdit at `widget_idx`. Clamps the menu rect
+19 -58
View File
@@ -1,13 +1,26 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
/// 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<A: App> AppData<A>
// 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<A: App> AppData<A>
pub( crate ) fn resize_edge_under_pointer( &self, focus: SurfaceFocus ) -> Option<ResizeEdge>
{
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;
+4 -3
View File
@@ -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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
/// 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<A: App> AppData<A>
// 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<A::Message>|
let fire = |ss: &mut SurfaceState<A::Message, P::Surface>|
-> Option<( Option<A::Message>, Option<A::Message>, Point, Option<usize> )>
{
let start = ss.gesture.long_press_start?;
@@ -100,7 +101,7 @@ impl<A: App> AppData<A>
pub( crate ) fn clear_long_press_drag( &mut self )
{
let clear = |ss: &mut SurfaceState<A::Message>|
let clear = |ss: &mut SurfaceState<A::Message, P::Surface>|
{
ss.gesture.long_press_start = None;
ss.gesture.long_press_origin = None;
+7 -2
View File
@@ -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" ),
}
}
}
+6 -7
View File
@@ -1,8 +1,6 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
/// 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<A: App> AppData<A>
self.dirty_caches();
}
pub( crate ) fn set_focus( &mut self, focus: SurfaceFocus, idx: Option<usize>, qh: &QueueHandle<Self> )
pub( crate ) fn set_focus( &mut self, focus: SurfaceFocus, idx: Option<usize> )
{
let was_text_input;
let is_text_input;
@@ -192,7 +191,7 @@ impl<A: App> AppData<A>
}
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<A: App> AppData<A>
{
// 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<A: App> AppData<A>
}
/// Whether the surface's focused widget is a text input in its current frame.
pub( crate ) fn surface_focused_text_input<Msg: Clone>( ss: &super::surface::SurfaceState<Msg> ) -> bool
pub( crate ) fn surface_focused_text_input<Msg: Clone, S>( ss: &super::surface::SurfaceState<Msg, S> ) -> bool
{
let by_id = ss.focused_id.and_then( |id|
{
+35 -86
View File
@@ -1,41 +1,21 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App>( data: &mut AppData<A> ) -> bool
pub( crate ) fn draw_frame<A: App, P: Platform>( data: &mut AppData<A, P> ) -> 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<A: App>( data: &mut AppData<A> ) -> 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<SurfaceFocus> = Vec::new();
@@ -55,19 +33,15 @@ pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> ) -> bool
{
text_focus_rebuilt.push( SurfaceFocus::Main );
}
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, SurfaceFocus::Main ); };
draw_surface::<A::Message>(
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<A: App>( data: &mut AppData<A> ) -> 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::<A::Message>(
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<A: App>( data: &mut AppData<A> ) -> 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::<A::Message>(
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<A: App>( data: &mut AppData<A> ) -> bool
first_commit_now
}
fn draw_surface<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &CompositorState,
egl_ctx: Option<&Arc<EglContext>>,
/// 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<P: Platform, Msg: Clone>(
platform: &P,
ss: &mut SurfaceState<Msg, P::Surface>,
view: &Element<Msg>,
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<SurfaceFocus>,
)
{
let scale = ss.scale_factor.max( 1 ) as u32;
@@ -183,6 +138,7 @@ fn draw_surface<Msg: Clone>(
&& 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<Msg: Clone>(
);
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;
}
}
+2 -1
View File
@@ -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<A: App>( data: &mut AppData<A>, scope: InvalidationScope )
pub( super ) fn apply_invalidation<A: App, P: Platform>( data: &mut AppData<A, P>, scope: InvalidationScope )
{
match scope
{
+7 -11
View File
@@ -1,36 +1,32 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;
+58 -12
View File
@@ -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<Panel>, 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<Panel>, 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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
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<A: App> AppData<A>
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<A: App> AppData<A>
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<A: App> AppData<A>
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<String>, 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;
+4 -3
View File
@@ -2,20 +2,21 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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,
}
File diff suppressed because it is too large Load Diff
+118
View File
@@ -0,0 +1,118 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<A: App, P: Platform> AppData<A, P>
{
/// `app` on `platform` with `main` as its only surface, nothing focused, pressed or queued.
pub( crate ) fn new( app: A, platform: P, main: SurfaceState<A::Message, P::Surface>, loop_handle: LoopHandle<'static, Self>, builtin_osk: bool, a11y: Option<A11yState> ) -> 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::<A::Message>();
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::<f32>();
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( () )
}
}
+242
View File
@@ -0,0 +1,242 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<A: App, P: Platform> AppData<A, P>
{
/// 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<Duration>
{
[ 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
}
}
+29 -258
View File
@@ -1,30 +1,11 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<AppData<impl crate::app::App>>,
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<Msg: Clone> FrameState<Msg>
}
}
/// 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<Msg: Clone>
pub( crate ) struct SurfaceState<Msg: Clone, S = NativeSurface>
{
pub pool: Option<SlotPool>,
/// 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<EglSurface>,
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<Canvas>,
pub width: u32,
pub height: u32,
@@ -288,9 +168,8 @@ pub( crate ) struct SurfaceState<Msg: Clone>
pub touch_slots: HashMap<i32, crate::types::Point>,
/// 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<Msg: Clone>
/// 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<crate::app::Anchor>,
/// Anchor rect the xdg-popup positioner was last configured with.
/// `None` for non-popup surfaces.
pub last_popup_anchor: Option<Rect>,
/// 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<Msg: Clone>
/// 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<Vec<Rect>>,
}
impl<Msg: Clone> SurfaceState<Msg>
impl<Msg: Clone, S> SurfaceState<Msg, S>
{
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<Msg: Clone> SurfaceState<Msg>
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<Msg: Clone> SurfaceState<Msg>
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<EglContext>>,
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<Msg: Clone> SurfaceState<Msg>
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();
}
}
+266
View File
@@ -0,0 +1,266 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg>
{
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<TestApp, MockPlatform>,
_event_loop: EventLoop<'static, AppData<TestApp, MockPlatform>>,
_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<TestApp, MockPlatform>> = 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<Request>
{
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<Msg> ) -> 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<Vec<Rect>>
{
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" );
}
+2 -1
View File
@@ -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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
/// Read the current text value of the focused widget — preferring
/// the pending typed-but-not-yet-applied value when present, falling
+19 -72
View File
@@ -1,60 +1,31 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle<Self>, 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<A: App> AppData<A>
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,20 +52,17 @@ impl<A: App> AppData<A>
( None, Some( _ ) ) => self.deactivate_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 )
{
( 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;
}
pub( crate ) fn reenable_text_input( &self )
{
if let Some( ti ) = &self.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();
}
}
/// Snapshot a focused-widget's geometry needed by the pointer
/// hit-testers for text editing. Returns `None` when the widget
/// isn't a TextEdit or its rect is missing — the helper above
@@ -133,23 +100,3 @@ impl<A: App> AppData<A>
}
}
}
/// 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 )
}
+2 -1
View File
@@ -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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
pub( crate ) fn handle_text_insert( &mut self, focus: SurfaceFocus, text: &str )
{
+2 -1
View File
@@ -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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
pub( crate ) fn text_scroll( &self, focus: SurfaceFocus, idx: usize ) -> TextScroll
{
+3 -5
View File
@@ -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,13 +24,10 @@ 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;
{
let mut iter = value[..bo].char_indices().rev();
while let Some( ( i, c ) ) = iter.next()
for ( i, c ) in value[..bo].char_indices().rev()
{
if is_word( c ) { start = i; } else { break; }
}
}
// Walk right from `bo` until the same.
let mut end = bo;
@@ -41,7 +39,7 @@ fn word_bounds_at( value: &str, byte_offset: usize ) -> ( usize, usize )
( start, end )
}
impl<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
/// `true` when this press should be treated as the second click
/// of a double-click pair. Looks at the timestamp + position of
+4 -3
View File
@@ -2,9 +2,10 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
pub( crate ) fn arm_tooltip( &mut self, focus: SurfaceFocus, flat_idx: usize )
{
@@ -90,7 +91,7 @@ impl<A: App> AppData<A>
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 ) ) )
}
+1 -1
View File
@@ -405,7 +405,7 @@ impl Aabb
fn finish( &self ) -> Option<OutlineBounds>
{
self.seen.then( || OutlineBounds
self.seen.then_some( OutlineBounds
{
xmin: self.xmin,
ymin: self.ymin,
+6 -3
View File
@@ -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<Vec<( usize, std::rc::Rc<std::cell::RefCell<ImageCache>> )>> =
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 );
+1 -2
View File
@@ -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 ); }
+2 -2
View File
@@ -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 )
{
+2 -1
View File
@@ -3,8 +3,9 @@
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::platform::Platform;
impl<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
/// Top-left of `focus` in main-surface (global) coordinates. Used to
/// translate per-surface pointer coords into a single coordinate
+3 -5
View File
@@ -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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
/// 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<A: App> AppData<A>
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();
+2 -1
View File
@@ -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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
/// If the press at `pos` lands on a password-toggle icon zone of
/// the widget at `idx`, push the toggle message and return
+244
View File
@@ -0,0 +1,244 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<String>,
}
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<String> ) -> 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<MouseButton>
{
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 );
}
}
+8 -9
View File
@@ -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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
/// 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<A: App> AppData<A>
false
}
pub( crate ) fn handle_back( &mut self, focus: SurfaceFocus, qh: &QueueHandle<Self> )
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<A: App> AppData<A>
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; }
+12 -15
View File
@@ -1,23 +1,20 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App> AppData<A>
use super::super::event::KeyInput;
impl<A: App, P: Platform> AppData<A, P>
{
/// 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<A: App> AppData<A>
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 ),
}
+44 -142
View File
@@ -1,9 +1,9 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
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<A: App> KeyboardHandler for AppData<A>
{
fn enter(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_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<Self>,
_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<A: App> KeyboardHandler for AppData<A>
if let Some( ref mut a ) = self.a11y { a.set_window_focus( false ); }
}
fn press_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_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<Self>,
_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
{
if state.event.keysym == event.keysym
// 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 )
{
self.stop_key_repeat();
}
}
/// 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.stop_key_repeat();
let focus = self.keyboard_focus;
self.dispatch_key( focus, key );
}
fn update_modifiers(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
modifiers: Modifiers,
_raw_modifiers: RawModifiers,
_layout: u32,
)
pub( crate ) fn modifiers_changed( &mut self, ctrl: bool, shift: bool, alt: bool )
{
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 );
self.shift_pressed = shift;
self.ctrl_pressed = ctrl;
self.alt_pressed = alt;
self.app.on_modifiers( ctrl, shift, alt );
}
fn update_repeat_info(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
info: RepeatInfo,
)
/// 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 )
{
match info
{
RepeatInfo::Repeat { rate, delay } =>
{
self.compositor_repeat_rate = rate.get();
self.compositor_repeat_rate = rate;
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<Self>,
_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 );
if rate == 0 { self.stop_key_repeat(); }
}
}
+2 -1
View File
@@ -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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
/// Step the topmost scroll's `hovered_idx` one item up or down with
/// the keyboard. Returns `true` when the move was applied (a scroll
+9 -8
View File
@@ -1,14 +1,15 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App> AppData<A>
use super::super::event::KeyInput;
impl<A: App, P: Platform> AppData<A, P>
{
pub( super ) fn handle_key_ctrl_a( &mut self, focus: SurfaceFocus )
{
@@ -46,7 +47,7 @@ impl<A: App> AppData<A>
if is_text { self.handle_paste( focus ); }
}
pub( super ) fn handle_key_tab( &mut self, focus: SurfaceFocus, event: &KeyEvent, qh: &QueueHandle<Self> )
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<A: App> AppData<A>
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<Self> )
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 );
+13 -11
View File
@@ -1,13 +1,15 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App> AppData<A>
use super::super::event::KeyInput;
impl<A: App, P: Platform> AppData<A, P>
{
/// 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<A: App> AppData<A>
.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<A: App> AppData<A>
}
}
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<A: App> AppData<A>
}
}
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<A: App> AppData<A>
}
}
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<A: App> AppData<A>
}
}
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<A: App> AppData<A>
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<A: App> AppData<A>
}
}
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<A: App> AppData<A>
}
} 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() )
{
+3 -4
View File
@@ -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;
+5 -31
View File
@@ -1,43 +1,17 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
pub( super ) fn on_pointer_enter(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_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;
// 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 );
}
}
}
+2 -1
View File
@@ -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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
/// Snapshot the swipe thresholds + surface dimensions into a
/// [`SwipeConfig`] for the gesture machine. Called once per
+27 -52
View File
@@ -1,9 +1,9 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<A: App> PointerHandler for AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
fn pointer_frame(
&mut self,
conn: &Connection,
qh: &QueueHandle<Self>,
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<A: App> AppData<A>
{
/// 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 ),
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,
+7 -19
View File
@@ -1,32 +1,20 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
pub( super ) fn on_pointer_motion(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_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 ) ) );
+20 -73
View File
@@ -1,30 +1,19 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App> AppData<A>
use super::super::event::PointerInput;
impl<A: App, P: Platform> AppData<A, P>
{
pub( super ) fn on_pointer_right_click(
&mut self,
_conn: &Connection,
qh: &QueueHandle<Self>,
_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<A: App> AppData<A>
// 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<A: App> AppData<A>
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<A: App> AppData<A>
}
/// 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<Self>, 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<Self>,
_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<A: App> AppData<A>
// 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<A: App> AppData<A>
// 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<A: App> AppData<A>
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
{
+8 -19
View File
@@ -1,29 +1,18 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App> AppData<A>
use super::super::event::PointerInput;
impl<A: App, P: Platform> AppData<A, P>
{
pub( super ) fn on_pointer_release(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_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 );
+17 -52
View File
@@ -1,42 +1,20 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App> AppData<A>
use super::super::event::{ Axis, AxisSource, PointerInput };
impl<A: App, P: Platform> AppData<A, P>
{
pub( super ) fn on_pointer_axis(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_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<A: App> AppData<A>
};
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<A: App> AppData<A>
// 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
+4 -3
View File
@@ -2,12 +2,13 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
/// 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<A: App> AppData<A>
// 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<A>|
let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData<A, P>|
{
let live_msg = crate::tree::find_handlers(
&data.surface( focus ).frame.widget_rects,
+7 -9
View File
@@ -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<A: App> AppData<A>
impl<A: App, P: Platform> AppData<A, P>
{
/// Compute the effective initial repeat delay — app override wins,
/// then compositor info, then a built-in default.
@@ -65,7 +67,7 @@ impl<A: App> AppData<A>
/// 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<A: App> AppData<A>
let event_for_timer = event.clone();
let timer = Timer::from_duration( delay );
let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData<A>|
let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData<A, P>|
{
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.
+33 -89
View File
@@ -1,9 +1,9 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<A: App> TouchHandler for AppData<A>
use super::event::{ TouchInput, TouchPhase };
impl<A: App, P: Platform> AppData<A, P>
{
fn down(
&mut self,
_conn: &Connection,
qh: &QueueHandle<Self>,
_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<A: App> TouchHandler for AppData<A>
// 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<A: App> TouchHandler for AppData<A>
// 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<A: App> TouchHandler for AppData<A>
// 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<A: App> TouchHandler for AppData<A>
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<A: App> TouchHandler for AppData<A>
}
}
fn up(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_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<A: App> TouchHandler for AppData<A>
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<A: App> TouchHandler for AppData<A>
self.release_primary( focus );
}
fn motion(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_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<A: App> TouchHandler for AppData<A>
}
}
fn shape(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_touch: &WlTouch,
_id: i32,
_major: f64,
_minor: f64,
) {}
fn orientation(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_touch: &WlTouch,
_id: i32,
_orientation: f64,
) {}
fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch )
{
self.reset_touch_state();
}
}
impl<A: App> AppData<A>
{
/// 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<A: App> AppData<A>
/// 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<A: App> AppData<A>
// 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<A::Message>, out: &mut Vec<( i32, crate::types::Point )>|
let collect = |ss: &mut SurfaceState<A::Message, P::Surface>, out: &mut Vec<( i32, crate::types::Point )>|
{
for ( id, pos ) in ss.touch_slots.drain() { out.push( ( id, pos ) ); }
ss.primary_touch_id = None;
+1 -4
View File
@@ -240,10 +240,7 @@ impl<Msg: Clone> Column<Msg>
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 {
+71 -71
View File
@@ -148,7 +148,7 @@ impl<Msg: Clone> WrapGrid<Msg>
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<Msg: Clone> WrapGrid<Msg>
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<Msg: Clone + 'static> From<WrapGrid<Msg>> for Element<Msg>
}
}
/// 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<Msg> {
/// grid( 4 ).padding( 16.0 ).spacing( 8.0 ).push( button( "A" ).on_press( Msg::A ) )
/// # }
/// ```
pub fn grid<Msg: Clone>( columns: usize ) -> WrapGrid<Msg>
{
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<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
/// 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<Msg: Clone>( min_cell_width: impl Into<Length> ) -> WrapGrid<Msg>
{
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<Msg> {
/// grid( 4 ).padding( 16.0 ).spacing( 8.0 ).push( button( "A" ).on_press( Msg::A ) )
/// # }
/// ```
pub fn grid<Msg: Clone>( columns: usize ) -> WrapGrid<Msg>
{
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<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
/// 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<Msg: Clone>( min_cell_width: impl Into<Length> ) -> WrapGrid<Msg>
{
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,
}
}
+22 -4
View File
@@ -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;
}
+1
View File
@@ -234,6 +234,7 @@ pub fn search_paths() -> Vec<PathBuf>
} 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
}
+68
View File
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<Vec<u8>>
{
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( () )
}
+169
View File
@@ -0,0 +1,169 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<A: App> AppData<A>
{
/// 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<char>
{
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 }
}
+156
View File
@@ -0,0 +1,156 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<R>( app: &AndroidApp, f: impl FnOnce( &mut JNIEnv, &JObject ) -> jni::errors::Result<R> ) -> Option<R>
{
// 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<JObject<'local>>
{
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<String>
{
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<i32>
{
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( () )
} );
}
+60
View File
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<Keysym>
{
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,
} )
}
+51
View File
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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() ); }
}
} );
}
+133
View File
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<A: App> Platform for AndroidPlatform<A>
{
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<Canvas>, 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 ) {}
}
+85
View File
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<A: App>
(
platform: &AndroidPlatform<A>,
surface: &mut AndroidSurfaceState,
canvas: &mut Option<Canvas>,
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<A: App>
(
platform: &AndroidPlatform<A>,
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::<u8>(), n ); }
}
true
}
+203
View File
@@ -0,0 +1,203 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! `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<Option<AndroidApp>> = 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<A: App>( app: A )
{
if let Err( e ) = try_run( app )
{
panic!( "ltk::run failed during init: {e}" );
}
}
pub( crate ) fn try_run<A: App>( 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<AppData<A>> = 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<A: App>( event_loop: &EventLoop<'static, AppData<A>>, 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<A: App>( data: &AppData<A> ) -> Option<Duration>
{
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<A: App> AppData<A>
{
/// 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 ) ) {}
}
}
+52
View File
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App>
{
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<Arc<EglContext>>,
/// 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<bool>,
/// The runtime asked for a frame without drawing one; the loop reports it a refresh later.
pub frame_requested: Cell<bool>,
/// Each input device's character map, read once through JNI.
pub key_maps: HashMap<i32, KeyCharacterMap>,
pub destroyed: bool,
_app: PhantomData<fn() -> A>,
}
impl<A: App> AndroidPlatform<A>
{
pub( crate ) fn new( activity: AndroidApp, egl_context: Option<Arc<EglContext>> ) -> 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,
}
}
}
+100
View File
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<EglSurface>,
pub window: Option<NativeWindow>,
pub raster_trace: Option<RasterTrace>,
/// The software renderer's frame, copied line by line into the window's buffer, whose lines may be longer.
pub pixels: Vec<u8>,
}
/// 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> ) -> u32
{
dpi.map_or( 1, |dpi| ( ( dpi as f32 / 160.0 ).round() as u32 ).max( 1 ) )
}
impl<A: App> AppData<A>
{
/// 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;
}
}
+157
View File
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<SurfaceFocus>, damage: Vec<Rect>, 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<Vec<Request>>,
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<Request>
{
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<Canvas>, 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() ) );
}
}
+119
View File
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<A> = wayland::WaylandPlatform<A>;
#[ cfg( feature = "android" ) ]
pub( crate ) type NativePlatform<A> = android::AndroidPlatform<A>;
/// 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<SurfaceFocus>,
}
/// 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<Canvas>, 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 );
}
+58
View File
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App>( platform: &WaylandPlatform<A>, 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,
}
}
@@ -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<DropPayload>, std::syn
std::sync::mpsc::channel()
}
impl<A: App> AppData<A>
/// 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<A: App>( platform: &mut WaylandPlatform<A> )
{
/// 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<A: App> DataDeviceHandler for AppData<A>
@@ -115,24 +107,24 @@ impl<A: App> DataDeviceHandler for AppData<A>
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<Self>, _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<Self>, _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<A: App> DataDeviceHandler for AppData<A>
{
let Some( data ) = d.data::<DataDeviceData>() 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<A: App> DataDeviceHandler for AppData<A>
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<A: App> DataDeviceHandler for AppData<A>
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<A: App> DataSourceHandler for AppData<A>
// 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<A: App> DataSourceHandler for AppData<A>
// 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<String>
.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<String>, mpsc::Receiver<String> )
{
mpsc::channel()
}
@@ -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<Msg: Clone>(
ss: &mut super::SurfaceState<Msg>,
ss: &mut SurfaceState<Msg>,
shm: &Shm,
surface: &WlSurface,
new_factor: i32,
@@ -65,15 +67,15 @@ fn apply_surface_scale<Msg: Clone>(
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<A: App> CompositorHandler for AppData<A>
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<A: App> CompositorHandler for AppData<A>
// 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<A: App> CompositorHandler for AppData<A>
fn surface_enter( &mut self, _: &Connection, _: &QueueHandle<Self>, 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<A: App> LayerShellHandler for AppData<A>
{
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<A: App> LayerShellHandler for AppData<A>
// 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<A: App> WindowHandler for AppData<A>
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<A: App> WindowHandler for AppData<A>
_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<A: App> WindowHandler for AppData<A>
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<A: App> ShmHandler for AppData<A>
{
fn shm_state( &mut self ) -> &mut Shm
{
&mut self.shm
&mut self.platform.shm
}
}
@@ -335,7 +337,7 @@ impl<A: App> OutputHandler for AppData<A>
{
fn output_state( &mut self ) -> &mut OutputState
{
&mut self.output_state
&mut self.platform.output_state
}
fn new_output( &mut self, _: &Connection, qh: &QueueHandle<Self>, output: WlOutput )
@@ -343,26 +345,26 @@ impl<A: App> OutputHandler for AppData<A>
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<A: App> OutputHandler for AppData<A>
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<A: App> OutputHandler for AppData<A>
fn output_destroyed( &mut self, _: &Connection, _: &QueueHandle<Self>, 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<A: App> SeatHandler for AppData<A>
{
fn seat_state( &mut self ) -> &mut SeatState
{
&mut self.seat_state
&mut self.platform.seat_state
}
fn new_seat(
@@ -435,11 +437,11 @@ impl<A: App> SeatHandler for AppData<A>
// 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<A: App> SeatHandler for AppData<A>
{
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<A: App> SeatHandler for AppData<A>
{
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<A: App> SeatHandler for AppData<A>
{
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<A: App> PopupHandler for AppData<A>
)
{
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<A: App> PopupHandler for AppData<A>
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<A: App> SessionLockHandler for AppData<A>
// 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<WlOutput> = self.output_state.outputs().collect();
let outputs: Vec<WlOutput> = 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<Self>, _session_lock: SessionLock )
@@ -617,16 +619,16 @@ impl<A: App> SessionLockHandler for AppData<A>
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<A: App> smithay_client_toolkit::activation::ActivationHandler for AppData<A
}
return;
}
if let ( Some( ref activation ), Some( wl ) ) = ( self.activation_state.as_ref(), self.main.surface.try_wl_surface() )
if let ( Some( activation ), Some( wl ) ) = ( self.platform.activation_state.as_ref(), self.main.platform.surface.try_wl_surface() )
{
activation.activate::<Self>( &wl, token );
activation.activate::<Self>( wl, token );
}
}
}
@@ -684,7 +686,7 @@ impl<A: App> ProvidesRegistryState for AppData<A>
{
fn registry( &mut self ) -> &mut RegistryState
{
&mut self.registry_state
&mut self.platform.registry_state
}
registry_handlers![ OutputState, SeatState ];
}
@@ -735,7 +737,7 @@ impl<A: App> ForeignToplevelListHandler for AppData<A>
{
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<A: App> ForeignToplevelListHandler for AppData<A>
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<A: App> ForeignToplevelListHandler for AppData<A>
// 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<A: App> Dispatch<ZwpTextInputManagerV3, ()> for AppData<A>
// 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<A: App> Dispatch<WlCallback, super::SurfaceFocus> for AppData<A>
impl<A: App> Dispatch<WlCallback, SurfaceFocus> for AppData<A>
{
fn event(
state: &mut Self,
_proxy: &WlCallback,
_event: wl_callback::Event,
focus: &super::SurfaceFocus,
focus: &SurfaceFocus,
_conn: &Connection,
_qh: &QueueHandle<Self>,
)
{
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 );
}
}
+339
View File
@@ -0,0 +1,339 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<wl_pointer::AxisSource> ) -> AxisSource
{
match source
{
Some( wl_pointer::AxisSource::Wheel ) => AxisSource::Wheel,
Some( wl_pointer::AxisSource::Finger | wl_pointer::AxisSource::Continuous ) => AxisSource::Continuous,
_ => AxisSource::Other,
}
}
impl<A: App> KeyboardHandler for AppData<A>
{
fn enter(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_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<Self>,
_keyboard: &WlKeyboard,
_surface: &WlSurface,
_serial: u32,
)
{
self.keyboard_leave();
}
fn press_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_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<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
event: KeyEvent,
)
{
self.key_release( &key_input( event ) );
}
fn update_modifiers(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_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<Self>,
_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<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
event: KeyEvent,
)
{
self.key_repeated( key_input( event ) );
}
}
impl<A: App> PointerHandler for AppData<A>
{
fn pointer_frame(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_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<A: App> TouchHandler for AppData<A>
{
fn down(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_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<Self>,
_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<Self>,
_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<Self>,
_touch: &WlTouch,
_id: i32,
_major: f64,
_minor: f64,
) {}
fn orientation(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_touch: &WlTouch,
_id: i32,
_orientation: f64,
) {}
fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _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() ) } );
}
}
+143
View File
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<A: App> Platform for WaylandPlatform<A>
{
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<Canvas>, 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 );
}
}
@@ -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<A: App>( data: &mut AppData<A> )
{
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<A: App>( data: &mut AppData<A> )
// 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<A: App>( data: &mut AppData<A> )
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<A: App>( data: &mut AppData<A> )
// 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<A: App>( data: &mut AppData<A> )
// 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<A: App>( data: &mut AppData<A> )
)
};
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<A: App>( data: &mut AppData<A> )
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<A: App>( data: &mut AppData<A> )
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::<A::Message>::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<A: App>( data: &mut AppData<A> )
popup.xdg_popup().grab( seat, grab_serial );
}
popup.wl_surface().commit();
let mut ss = SurfaceState::<A::Message>::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<A: App>( data: &mut AppData<A> )
window.set_min_size( Some( ( w, h ) ) );
window.set_max_size( Some( ( w, h ) ) );
window.commit();
let mut ss = SurfaceState::<A::Message>::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<A: App>( data: &mut AppData<A> )
{
surface.materialize( cs, layer_shell, qh, output );
}
let mut ss = SurfaceState::<A::Message>::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 );
+226
View File
@@ -0,0 +1,226 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<A: App>
(
platform: &WaylandPlatform<A>,
surface: &mut WaylandSurfaceState,
canvas: &mut Option<Canvas>,
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<A: App>
(
platform: &WaylandPlatform<A>,
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<A: App>
(
platform: &WaylandPlatform<A>,
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<A: App>( platform: &WaylandPlatform<A>, wl_surface: &WlSurface, opaque_applied: &mut Option<Vec<Rect>>, 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<Rect>
{
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<Vec<Rect>>,
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( &region ), 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( &region ), 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() );
}
}
@@ -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<PrimarySelectionDevice>,
/// Ours while we own the selection; dropping it gives the selection up.
source: Option<PrimarySelectionSource>,
pub text: String,
/// The selection of the focused text field when it was last looked at.
field: Option<String>,
inbox_tx: mpsc::Sender<String>,
inbox_rx: mpsc::Receiver<String>,
}
@@ -45,55 +43,38 @@ impl PrimarySelection
pub( crate ) fn new( manager: Option<PrimarySelectionManagerState> ) -> 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<String>
{
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<A: App>( platform: &mut WaylandPlatform<A> )
{
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<A: App> AppData<A>
{
pub( crate ) fn bind_primary_selection( &mut self, qh: &QueueHandle<Self>, 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<A: App> PrimarySelectionDeviceHandler for AppData<A>
let Some( offer ) = device.data::<PrimarySelectionDeviceData>().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<A: App> PrimarySelectionSourceHandler for AppData<A>
{
fn send_request( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, 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<Self>, source: &ZwpPrimarySelectionSourceV1 )
{
if self.owns_primary( source ) { self.primary.source = None; }
if self.owns_primary( source ) { self.platform.primary.source = None; }
}
}
+499
View File
@@ -0,0 +1,499 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App>( 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<A: App>( 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<AppData<A>> = 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> = 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<AppData<A>>|
-> Result<XdgShell, RunError>
{
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<ZwpTextInputManagerV3> = 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::<std::io::Error>()
{
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<A: App>( data: &mut AppData<A> )
{
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::<AppData<A>>( &data.platform.qh, req );
}
None =>
{
if let Some( msg ) = data.app.on_activation_token( tag, String::new() )
{
data.pending_msgs.push( msg );
}
}
}
}
}
@@ -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<A: App>( handle: &LoopHandle<'static, AppData
handle
.insert_source( Timer::from_duration( SAVE_INTERVAL ), |_, _, data: &mut AppData<A>|
{
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<A: App> Dispatch<XdgSessionV1, ()> for AppData<A>
{
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(),
}
}
}
+117
View File
@@ -0,0 +1,117 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App>
{
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<SubcompositorState>,
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<Arc<EglContext>>,
pub session_lock: Option<SessionLock>,
#[ allow( dead_code ) ]
pub xdg_shell: Option<XdgShell>,
/// Shared by the main surface and every overlay; `None` when the compositor does not advertise the protocol.
pub layer_shell: Option<LayerShell>,
pub keyboard: Option<WlKeyboard>,
pub pointer: Option<WlPointer>,
pub touch: Option<WlTouch>,
/// `None` when the compositor does not advertise `wp_cursor_shape_v1`; the compositor's default cursor then stays.
pub cursor_shape_manager: Option<CursorShapeManager>,
/// Present once the compositor supports cursor shapes and the seat has a pointer.
pub cursor_shape_device: Option<WpCursorShapeDeviceV1>,
/// 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<ZwpTextInputManagerV3>,
pub text_input: Option<ZwpTextInputV3>,
/// `xdg-activation-v1`, used on the first configure to honour `XDG_ACTIVATION_TOKEN` and for [`App::request_activation_token`].
pub activation_state: Option<ActivationState>,
/// Honoured once, after the first configure of the main surface.
pub activation_token_pending: Option<String>,
/// `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<DataDeviceManagerState>,
/// Created with the first seat that has a keyboard or a pointer.
pub data_device: Option<DataDevice>,
/// The selection we publish, held so each paste from another client can be served the clipboard text.
pub clipboard_source: Option<CopyPasteSource>,
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<String>,
pub clipboard_inbox_rx: std::sync::mpsc::Receiver<String>,
/// 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<String>,
/// What other clients drop on our surfaces, read by a worker thread.
pub drop_inbox_tx: std::sync::mpsc::Sender<DropPayload>,
pub drop_inbox_rx: std::sync::mpsc::Receiver<DropPayload>,
/// 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<Canvas>,
/// `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<WlOutput>,
/// Input-transparent child surfaces keyed by their stable [`SubsurfaceId`], reconciled each frame from [`App::subsurfaces`].
pub subsurfaces: HashMap<SubsurfaceId, SubsurfaceSlot>,
/// 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<Canvas>,
pub qh: QueueHandle<AppData<A>>,
}

Some files were not shown because too many files have changed in this diff Show More