docs overhaul, orientation API, fluid-sizing fixes, examples made honest
Documentation pass: every claim in docs/ and the meta files was audited against the source and the drift fixed — around ninety corrections. CONTRIBUTING and the CI workflow now run cargo test with --features test-support (the gated test_support module made both the documented commands and the CI build fail to compile), make example becomes make examples, make doctest-md and the debhelper requirement of make clean are documented, and patch shape asks for a CHANGELOG entry. theming.md loses the nonexistent surface.backdrop, gains the real gradient defaults (linear-rgb, oklab), the six slot variants including typography, the ten-field palette, a truthful effects-consumer table, the ThemePreference/from_hour API and a responsive-sizing note; the stale docstrings in src/theme that fed the drift are fixed too. architecture.md's "Known gaps" section is rewritten against reality (multi-touch slots, xdg-activation, a11y live regions and SetValue/Increment/Decrement are implemented), gains a module map, subsurfaces and window-lifecycle coverage, and correct crustace/loginmanager paths. widgets.md fixes the ten factual errors (stateless spinner, toast/combo via overlays(), tooltip hover contract, row has no max_width, scroll axes, multiline text_edit, dialog panic wording) and now states the column() 16 px default padding — the recurring ambush — plus row's differing 0 default and dialog's max_width. onboarding, README and cookbook get the remaining sweep: build/test instructions, complete example lists, img_widget, clipping-parity honesty, ~30 Hz software cap, read_rgba_pixels signature, tab indentation in snippets, and rustdoc-style links that rendered literally are gone everywhere. CHANGELOG is restructured per Keep a Changelog with the missing entries (window_resizable, claims_raw_touch, Row::align_top/fill_height, caret fixes, dependency pins) and the pad_v Added/Changed contradiction resolved. New adaptive-layout API: ltk::orientation() with the Orientation enum, backed by viewport_size()/set_viewport_size — the runtime records the main surface's physical dimensions on every configure, before App::on_resize, so view() can branch a layout on portrait vs landscape without hand-tracking resizes. The portrait rule matches Length::orient (square counts as portrait); embedders driving core::UiSurface call set_viewport_size themselves. Documented in the crate root's responsive-design section and architecture.md. Fluid-vs-fixed sizing fixes in widgets, all the same disease — fluid content inside a fixed-pixel box. TextEdit::fixed_width takes impl Into<Length> (f32 call sites keep compiling as px) and the time picker's digit fields move to Length::fluid( 72.0 ), matching their fluid font so digits can no longer outgrow the box. Dialog::max_width takes impl Into<Length> with a Length::fluid( 480.0 ) default so the card scales with the stock buttons inside it, and the card's interior no longer stacks the column() default 16 px padding on top of CARD_PADDING — that double inset squeezed the action row until its buttons clipped on narrow windows. App::on_pointer_axis now triggers a view rebuild and repaint; previously state mutated in the hook did not paint until the next unrelated event. Examples reworked to be honest demos: responsive's mode/density controls become stock buttons in a grid/column so they follow the modes they demonstrate instead of overflowing; dialog's openers stack vertically, and the example gains the app-level ESC handler so the ESC chain closes an open dialog first and quits second; widgets' tab strip now switches real per-tab pages; carousel gains pointer/touch drag through the horizontal-swipe hooks (crustace's pager pattern), one-tile-per-detent mouse wheel, and snap math driven by the real surface width from on_resize instead of a hardcoded 800; clip_path arranges its cells by ltk::orientation() and sizes them from the counter-axis of the flow.
This commit is contained in:
@@ -4,12 +4,12 @@ If you are new to the library, start with [`docs/onboarding.md`](./onboarding.md
|
||||
first. This document assumes you already know how to run an example and what
|
||||
kind of application surface you are trying to build.
|
||||
|
||||
This document covers the patterns that the small `examples/` files cannot show: how a real application is structured on top of the [`App`] trait, how multiple surfaces coordinate, how theming is consumed, how to build animations, and where the cost of a frame actually lives.
|
||||
This document covers the patterns that the small `examples/` files cannot show: how a real application is structured on top of the `App` trait, how multiple surfaces coordinate, how theming is consumed, how to build animations, and where the cost of a frame actually lives.
|
||||
|
||||
For copy-pasteable patterns the canonical references are the two downstream consumers in the Eydos workspace:
|
||||
|
||||
- **crustace** (`crustace/src/`) — the Eydos shell. Layer-shell background surface + 8 overlays, system polling, MPRIS, notifications, animated OSD.
|
||||
- **loginmanager** (`loginmanager/src/`) — greeter. `keyboard_exclusive`, single overlay, focus management, async PAM via `set_channel_sender`.
|
||||
- **loginmanager** (`loginmanager/crates/lockscreen/src/`) — greeter / lock screen. `keyboard_exclusive`, focus management, a subsurface-driven reveal animation, async PAM on a worker thread drained in `poll_external`.
|
||||
|
||||
The rest of this document explains *why* those repos look the way they do.
|
||||
|
||||
@@ -23,9 +23,33 @@ This document mostly lives in the overlap between `ltk::shell` and
|
||||
`ltk::runtime`. If you only want to build a plain app window, stay with
|
||||
`docs/onboarding.md` and the `ltk::window` surface first.
|
||||
|
||||
## Module map
|
||||
|
||||
Where things live under `src/`, one line each:
|
||||
|
||||
- `a11y/` — AccessKit tree building and the AT-SPI2 bridge.
|
||||
- `app.rs` — the `App` trait, `OverlaySpec`, `SubsurfaceSpec`, `run` / `try_run`.
|
||||
- `chassis.rs` — scaffolding for full-screen ambient surfaces (greeter, lock screen, kiosk): theme bring-up, branding/wallpaper loading, the wallpaper-backed view stack.
|
||||
- `core.rs` — `UiSurface`, runtime-free embedding.
|
||||
- `draw/` — the per-frame drawing pipeline shared by both backends.
|
||||
- `egl_context.rs` — EGL bootstrap for the GPU path.
|
||||
- `event_loop/` — the Wayland run loop: frame scheduling, invalidation, clipboard / data device, text editing and IME, tooltips, focus, subsurfaces, perf guardrails.
|
||||
- `gles_render/` — GPU backend (EGL + GLES2 / GLES3).
|
||||
- `input/` — pointer, keyboard and touch handling, the gesture machine, dispatch.
|
||||
- `layout/` — composable arrangers for `Element` trees.
|
||||
- `render/` — software rendering surface used by every widget.
|
||||
- `secure_mem.rs` — volatile wipe of secret buffers behind `TextEdit`'s secure mode.
|
||||
- `system_fonts.rs` — primary-font resolution and the per-glyph fallback chain.
|
||||
- `text_shaping.rs` — BiDi reordering and rustybuzz (HarfBuzz) shaping.
|
||||
- `theme/` — theme documents, slot stores, the embedded fallback.
|
||||
- `tree.rs` — element-tree traversal helpers.
|
||||
- `types.rs` — geometry and primitive value types (`Length`, `Color`, `Rect`, …).
|
||||
- `wallpaper.rs` — orientation-aware wallpaper helper.
|
||||
- `widget/` — the widget set.
|
||||
|
||||
## Mental model
|
||||
|
||||
ltk is Elm-shaped. The application is a value implementing [`App`]; ltk drives the loop and the application reacts.
|
||||
ltk is Elm-shaped. The application is a value implementing `App`; ltk drives the loop and the application reacts.
|
||||
|
||||
Every frame: ltk calls `view()` and `overlays()`, lays out the returned tree(s), draws them, and dispatches input events back as `Message` values which are fed to `update()`. There are no retained widgets. `Element<Msg>` is rebuilt from scratch every frame from the application's own state.
|
||||
|
||||
@@ -72,23 +96,32 @@ In practice, that model is easiest to adopt in three steps:
|
||||
|
||||
**Implement for animations and focus**
|
||||
|
||||
- `is_animating()` — return `true` while a tween is running; the loop redraws at ~60 Hz.
|
||||
- `is_animating()` — return `true` while a tween is running; the loop redraws on every compositor frame callback (~60 Hz on a typical display, capped at ~30 Hz on the software backend — see *Performance*).
|
||||
- `take_focus_request()` → `Option<WidgetId>` — pull-once focus retargeting.
|
||||
- `on_text_input_focused(active)` — surface IME state.
|
||||
|
||||
**Implement for window / toplevel lifecycle**
|
||||
|
||||
- `window_config()` — deprecated forced-window escape hatch; prefer `shell_mode()`.
|
||||
- `on_close_requested()` — return `false` to veto a close (compositor request, titlebar button, layer-shell closed event).
|
||||
- `on_toplevel_event(event)` — open / close notifications from `ext-foreign-toplevel-list-v1`, keyed by a stable handle id.
|
||||
- `requested_exit()` — polled after every batch of `update`s; return `true` to tear the surface down and exit the loop (a `SessionLock` surface is unlocked first).
|
||||
|
||||
Relatedly, `ltk::try_run( app )` is the fallible variant of `ltk::run` — it returns a `RunError` (no Wayland connection, missing protocol) instead of aborting, for apps that want a CLI fallback or a clean diagnostic.
|
||||
|
||||
The defaults for everything else are sensible enough that a minimal app overrides only the four methods in the first group.
|
||||
|
||||
Another way to read the trait is by API layer:
|
||||
|
||||
- `ltk::window`: `view`, `update`, plus the widgets/layouts you use to build the tree.
|
||||
- `ltk::shell`: `shell_mode`, `layer_anchor`, `layer_size`, `exclusive_zone`, `keyboard_exclusive`, `overlays`.
|
||||
- `ltk::runtime`: `set_channel_sender`, `poll_external`, `poll_interval`, `invalidate_after`, `take_focus_request`, `is_animating`, and `core::UiSurface`.
|
||||
- `ltk::runtime`: the module's actual re-exports — `ChannelSender`, `InvalidationScope`, `SurfaceTarget`, the runtime theme state (`active_document`, `set_active_mode` and the `theme_*` accessors) and the embedding surface `core::UiSurface`. The trait hooks that pair with them (`set_channel_sender`, `poll_external`, `poll_interval`, `invalidate_after`, `take_focus_request`, `is_animating`) live on `App` itself.
|
||||
|
||||
That is the intended order of adoption for third-party users.
|
||||
|
||||
## Surface composition
|
||||
|
||||
The main surface is what `view()` paints. `overlays()` returns a `Vec<OverlaySpec<Msg>>` describing additional layer-shell surfaces that should exist this frame. The runtime diffs that list against the previous frame using [`OverlayId`]:
|
||||
The main surface is what `view()` paints. `overlays()` returns a `Vec<OverlaySpec<Msg>>` describing additional layer-shell surfaces that should exist this frame. The runtime diffs that list against the previous frame using `OverlayId`:
|
||||
|
||||
- Same id present last frame and this frame → keep the surface alive, only re-render its `view`.
|
||||
- New id → create a new layer-shell surface.
|
||||
@@ -96,7 +129,7 @@ The main surface is what `view()` paints. `overlays()` returns a `Vec<OverlaySpe
|
||||
|
||||
This is why crustace declares stable `const OVERLAY_LAUNCHER: OverlayId = OverlayId(1)` etc. at the top of `app.rs`. Don't allocate ids dynamically — diffing relies on stability.
|
||||
|
||||
Each overlay carries its own `view`, `anchor`, `size`, `layer`, `keyboard_exclusive`, `input_region`, and `on_dismiss`. The `Message` type is shared with the main app: a button inside an overlay produces the same `Msg` that a button on the main surface would, and `update()` handles both. There is no per-overlay state machine — overlays are pure projections of `App` state.
|
||||
Each overlay carries its own `view`, `anchor`, `anchor_widget_id`, `size`, `layer`, `exclusive_zone`, `keyboard_exclusive`, `input_region`, and `on_dismiss`. The `Message` type is shared with the main app: a button inside an overlay produces the same `Msg` that a button on the main surface would, and `update()` handles both. There is no per-overlay state machine — overlays are pure projections of `App` state.
|
||||
|
||||
`on_dismiss` is fired by three independent paths: a `popup_done` event from the compositor (xdg-popup mode); a pointer / touch press on the main surface that does not land on the trigger pointed at by `anchor_widget_id` while the overlay is mapped (covers compositors that route the button to the parent surface instead of breaking the popup grab); and Escape pressed while at least one xdg-popup overlay is open. The application only has to flip its `is_open` flag to `false` in `update()`; the runtime tolerates the message arriving more than once for the same open / close cycle.
|
||||
|
||||
@@ -109,6 +142,8 @@ Common patterns:
|
||||
|
||||
Overlays do not nest. A "submenu inside the quick settings panel" is just a second overlay with a different id whose `view()` builds the submenu. Crustace uses this for the WiFi and Bluetooth pickers.
|
||||
|
||||
Separate from overlays, `subsurfaces()` returns `SubsurfaceSpec` entries: input-transparent `wl_subsurface` children composited over the main surface and diffed by id, like overlays. They are the cheap way to move a pre-rendered element around every frame — the compositor repositions the subsurface instead of the app repainting the whole tree. Loginmanager's slide-to-unlock reveal is the reference.
|
||||
|
||||
If your application does not need overlays or layer-shell, you can ignore this
|
||||
entire section and stay in the `ltk::window` subset.
|
||||
|
||||
@@ -116,8 +151,8 @@ entire section and stay in the `ltk::window` subset.
|
||||
|
||||
`ltk::theme` exposes a process-wide active theme. Three layers:
|
||||
|
||||
1. **Document** — a [`ThemeDocument`] loaded from disk (`/usr/share/ltk/themes/<id>/theme.json`). Each document carries a `light` and `dark` [`Mode`] with a typed [`SlotStore`] (colors, paints, shadows, surfaces, text styles), wallpaper/lockscreen/launcher specs and a shared `fonts` block. When the `default` document cannot be located ltk falls back to an embedded B/W theme + embedded Sora Regular font, logs a stderr warning, and stamps every frame with a red banner pointing at the `ltk-theme-default` Debian package so the missing-theme signal is visible without the process aborting. `ltk::is_fallback_active()` exposes the state for apps that want to react programmatically.
|
||||
2. **Mode** — [`ThemeMode::Light`] or `Dark`; flips which mode of the document is active.
|
||||
1. **Document** — a `ThemeDocument` loaded from disk (`/usr/share/ltk/themes/<id>/theme.json`). Each document carries a `light` and `dark` `Mode` with a typed `SlotStore` (colors, paints, shadows, surfaces, text styles), wallpaper/lockscreen/launcher specs and a shared `fonts` block. When the `default` document cannot be located ltk falls back to an embedded B/W theme + embedded Sora Regular font, logs a stderr warning, and stamps every frame with a red banner pointing at the `ltk-theme-default` Debian package so the missing-theme signal is visible without the process aborting. `ltk::is_fallback_active()` exposes the state for apps that want to react programmatically.
|
||||
2. **Mode** — `ThemeMode::Light` or `Dark`; flips which mode of the document is active.
|
||||
3. **Active state** — `ltk::active_document()` / `ltk::active_mode()` return the current pair. Per-slot shorthands (`ltk::theme_color`, `theme_paint`, `theme_shadows`, `theme_surface`, `theme_text_style`, `theme_palette`, `theme_window_controls`, `theme_wallpaper`, `theme_lockscreen`) cover the common patterns.
|
||||
|
||||
Inside a widget tree, read the palette through the per-slot helper:
|
||||
@@ -164,6 +199,8 @@ Stock widgets do not hard-code either strategy. Each carries a design pixel per
|
||||
|
||||
Both `density()` and `widget_scaling()` are process globals read during layout; set them at startup (or, for density, whenever the surface moves to an output with a different DPI). Because they are global, ltk's own test suite serialises the tests that touch them.
|
||||
|
||||
`Length` adapts *sizes* to the orientation; to adapt the *structure* of a layout (a row of panels in landscape, the same panels stacked in portrait), branch the view on `ltk::orientation()`. The runtime records the main surface's physical dimensions on every configure (also readable as `ltk::viewport_size()`) and rebuilds the view after each resize, so a `match ltk::orientation() { Landscape => row()…, Portrait => column()… }` follows the window live. The portrait/landscape rule matches `Length::orient` (a square surface counts as portrait). `examples/clip_path.rs` shows the pattern.
|
||||
|
||||
## Animations
|
||||
|
||||
The render loop is event-driven by default: it sleeps until input arrives, a `poll_interval` ticks, or `set_channel_sender` is woken from a thread. To run a tween, override `is_animating()`:
|
||||
@@ -179,7 +216,7 @@ fn is_animating( &self ) -> bool
|
||||
# }
|
||||
```
|
||||
|
||||
While `is_animating()` returns `true`, ltk redraws at ~60 Hz. Do *not* mutate state in `view()`; instead read `Instant::now()` against a stored start time and compute the tween value:
|
||||
While `is_animating()` returns `true`, ltk redraws on every compositor frame callback — ~60 Hz on a typical display with the GLES backend, capped at ~30 Hz on the software backend by default (see *Performance*). Do *not* mutate state in `view()`; instead read `Instant::now()` against a stored start time and compute the tween value:
|
||||
|
||||
```rust,no_run
|
||||
# use std::time::Instant;
|
||||
@@ -213,7 +250,7 @@ core onboarding path.
|
||||
|
||||
A four-button demo can keep all state in one struct and one flat `Msg` enum. Anything bigger needs structure. Conventions used by crustace and loginmanager:
|
||||
|
||||
**One module per screen / panel.** Each module owns its sub-state struct and its sub-message enum, and exposes `fn view(...) -> Element<AppMsg>` and `fn update(&mut self, msg: SubMsg)` (or the parent inlines those calls). See `crustace/src/homescreen.rs`, `launcher/`, `notifications.rs`, `powermenu.rs`.
|
||||
**One module per screen / panel.** Each module owns its sub-state struct and its sub-message enum, and exposes `fn view(...) -> Element<AppMsg>` and `fn update(&mut self, msg: SubMsg)` (or the parent inlines those calls). See `crustace/src/homescreen/`, `launcher/`, `quicksettings/`, `powermenu.rs`.
|
||||
|
||||
**Wrap sub-messages in the top-level enum.** `enum AppMsg { Home(HomeMsg), Settings(SettingsMsg), Nav(Route), Tick }`. `update()` matches the outer variant, then forwards to the right sub-module. This avoids one-giant-message-enum bloat once the app passes ~30 variants.
|
||||
|
||||
@@ -235,53 +272,53 @@ The cheap things and the expensive things, in rough order:
|
||||
|
||||
- *Cheap*: building the `Element<Msg>` tree. It's plain enums and `Vec`s. crustace rebuilds the entire shell every frame and stays idle when nothing changes.
|
||||
- *Cheap*: input dispatch. Per-leaf handler snapshots are captured during the layout pass; pointer/key events are O(N_focusable_leaves) lookups, not tree walks.
|
||||
- *Cheap*: `active_document()` / `theme_palette()`. The first returns a clone of an `Arc<ThemeDocument>` from a `RwLock`-protected cell; the second projects the active mode's slot table onto the eight canonical palette fields.
|
||||
- *Cheap*: `active_document()` / `theme_palette()`. The first returns a clone of an `Arc<ThemeDocument>` from a `RwLock`-protected cell; the second projects the active mode's slot table onto the ten canonical palette fields.
|
||||
- *Avoid in `view()`*: filesystem walks, image decoding, `serde` parsing, regex compilation. Cache the result on the app struct (behind `RefCell` if needed) and look it up.
|
||||
- *Avoid in `view()`*: cloning large `Vec<u8>` image buffers. `img_widget` takes an `Arc<Vec<u8>>`; build the `Arc` once at load time and clone *the Arc*, not the bytes.
|
||||
- *Avoid `is_animating() = true` when nothing is moving.* It pegs the loop at 60 Hz and burns battery on the mobile target.
|
||||
- *Avoid `is_animating() = true` when nothing is moving.* It pegs the loop at the full animation rate and burns battery on the mobile target.
|
||||
- *Lower `poll_interval()` is not free.* Crustace polls every 30 s because the clock only shows HH:MM. If your UI shows seconds, `Some(Duration::from_secs(1))` is fine; if it shows nothing time-sensitive, leave it `None`.
|
||||
- *Scroll viewports own a sub-canvas.* They're slightly more expensive to draw than a plain column. Use them when you need clipping or actual scrolling, not as a wrapper.
|
||||
- *GPU vs software*: the GLES path is selected automatically when EGL is available, with no API-level difference for the application. The two backends are not yet pixel-identical — gradients and the shadow / backdrop pipeline degrade under software; the authoritative list of gaps is the README's *Backend Differences* section.
|
||||
|
||||
When a redraw feels sluggish: add a one-line print at the top of `view()` and confirm it's not being called more often than expected. The single most common mistake is leaving `is_animating()` returning `true` after the animation finished.
|
||||
|
||||
**Runtime guardrails.** The rules above are the app's responsibility, but the runtime also helps catch and blunt the common footguns. Set `LTK_PERF_WARN=1` to get one-shot stderr diagnostics during development when `is_animating()` stays true for 10 s (a settled animation that forgot to return `false`), when `poll_interval()` is under 100 ms (defeats the idle model), or when the software backend animates continuously for seconds (mobile CPU sink). Independently, animation on the **software** renderer is capped to ~30 Hz by default — GLES is never capped — since 60 fps software rasterization is a battery drain with no GPU offload; override [`App::cap_software_animation`] to keep the full rate. These live in `event_loop/perf.rs`.
|
||||
**Runtime guardrails.** The rules above are the app's responsibility, but the runtime also helps catch and blunt the common footguns. Set `LTK_PERF_WARN=1` to get one-shot stderr diagnostics during development when `is_animating()` stays true for 10 s (a settled animation that forgot to return `false`), when `poll_interval()` is under 100 ms (defeats the idle model), or when the software backend animates continuously for seconds (mobile CPU sink). Independently, animation on the **software** renderer is capped to ~30 Hz by default — GLES is never capped — since 60 fps software rasterization is a battery drain with no GPU offload; override `App::cap_software_animation` to keep the full rate. These live in `event_loop/perf.rs`. For layout problems rather than performance ones, `LTK_DEBUG_LAYOUT=1` outlines every laid-out widget rect in red so misplaced or zero-sized boxes are visible at a glance.
|
||||
|
||||
## Where to look in the consumer repos
|
||||
|
||||
| Pattern | File |
|
||||
| --- | --- |
|
||||
| Multi-overlay coordination, overlay id constants | `crustace/src/app.rs` (`overlays()`, lines ~250–380) |
|
||||
| Multi-overlay coordination, overlay id constants | `crustace/src/app.rs` (`overlays()`, line ~126) |
|
||||
| Background poller + channel sender | `crustace/src/app.rs` (`set_channel_sender`, `poll_external`) |
|
||||
| Sub-module per screen | `crustace/src/{homescreen.rs, notifications.rs, powermenu.rs, launcher/}` |
|
||||
| Sub-module per screen | `crustace/src/{homescreen/, launcher/, quicksettings/, powermenu.rs}` |
|
||||
| Cached icon loading via `RefCell` | `crustace/src/launcher/icon_cache.rs` and use sites in `app.rs` |
|
||||
| OSD overlay with auto-expiry | `crustace/src/app.rs` (`show_osd`, `build_osd`, `OSD_TIMEOUT_SECS`) |
|
||||
| `keyboard_exclusive` + `take_focus_request` | `loginmanager/src/main.rs` |
|
||||
| OSD overlay with auto-expiry | `crustace/src/osd.rs` (`Osd::show` / `tick` / `view`) |
|
||||
| `keyboard_exclusive` + `take_focus_request` | `loginmanager/crates/lockscreen/src/app.rs` |
|
||||
| Theme on disk (slot-typed JSON) | `ltk/themes/default/theme.json`, `ltk::ThemeDocument::find` |
|
||||
|
||||
For a self-contained example that exercises overlays, theme switching, and animation in one ~300-line file, see `examples/mini_shell.rs`.
|
||||
For a self-contained example that exercises overlays, theme switching, and animation in one ~630-line file, see `examples/mini_shell.rs`.
|
||||
|
||||
## Known gaps and non-goals
|
||||
|
||||
A short, honest list of what ltk does not currently provide. None of these are accidental — each is either deferred work or a deliberate non-goal. The list is here so that downstream consumers and audit reviewers know what to plan around without reading the source.
|
||||
|
||||
**AT-SPI2 / assistive technology bridge — wired through AccessKit, with composite widgets still flat.** Combo, Notebook tabs, DatePicker and TimePicker render as collections of inner widgets and currently expose those leaves individually (a combo trigger reads as "Button" + its caption, the popup items as `ListItem`s inside an overlay). Promoting them to their semantic roles (`ComboBoxMenuButton` with `Expanded` state, `TabList`/`Tab`/`TabPanel`, `Date`) needs each compound widget to declare an "outer rol hint" the layout pass can attach to the `LaidOutWidget` it pushes. Tracked separately.
|
||||
**AT-SPI2 / assistive technology bridge — wired through AccessKit, with composite widgets still flat.** Combo, Notebook tabs, DatePicker and TimePicker render as collections of inner widgets and currently expose those leaves individually (a combo trigger reads as "Button" + its caption, the popup items as `ListItem`s inside an overlay). Promoting them to their semantic roles (`ComboBoxMenuButton` with `Expanded` state, `TabList`/`Tab`/`TabPanel`, `Date`) needs each compound widget to declare an "outer role hint" the layout pass can attach to the `LaidOutWidget` it pushes. Tracked separately.
|
||||
|
||||
ltk delegates the AT-SPI2 D-Bus protocol to [`accesskit_unix`]. After every layout pass, the runtime hands the platform adapter a fresh [`accesskit::TreeUpdate`] built from `widget_rects` (one accessible node per laid-out widget, plus a `Window` root). Buttons / toggles / checkboxes / radios / list items map to `Role::Button` / `Role::Switch` / `Role::CheckBox` / `Role::RadioButton` / `Role::ListItem`; sliders to `Role::Slider`; single- and multi-line text edits to `Role::TextInput` / `Role::MultilineTextInput`. Each interactive node advertises the `Click` and `Focus` actions, and inbound action requests (Orca pressing a button, switch-control moving focus) are translated into a synthetic press / focus on the matching widget the next iteration of the run loop.
|
||||
ltk delegates the AT-SPI2 D-Bus protocol to `accesskit_unix`. After every layout pass, the runtime hands the platform adapter a fresh `accesskit::TreeUpdate` built from `widget_rects`: a `Window` root whose children are the main surface's widgets, with each overlay grouped under its own `Dialog` node and rich-text runs nested as `TextRun` children. Buttons / toggles / checkboxes / radios / list items map to `Role::Button` / `Role::Switch` / `Role::CheckBox` / `Role::RadioButton` / `Role::ListItem`; sliders to `Role::Slider`; single- and multi-line text edits to `Role::TextInput` / `Role::MultilineTextInput`; non-interactive labels, images, separators and progress bars surface as `Label` / `Image` / `Splitter` / `ProgressIndicator` nodes. Inbound action requests are translated into the matching widget message on the next iteration of the run loop: `Click` and `Focus` on every interactive node, `SetValue` on sliders and text edits, `Increment` / `Decrement` on sliders, and the scroll actions on scroll viewports. Live regions are wired too — `Container::live_region( true )` marks a subtree `Live::Polite` so status messages and OSDs announce themselves on appearance.
|
||||
|
||||
The integration is best-effort: when the AT-SPI2 daemon is not on the session bus (headless CI runners, locked-down compositors) the adapter creation returns `None` and the rest of the pipeline runs unchanged. The current cut covers the common cases — buttons, lists, form fields, dialogs — and intentionally leaves room for follow-up:
|
||||
The adapter is constructed unconditionally — `accesskit_unix::Adapter::new` is infallible and simply stays inactive when no AT client is attached — and the tree is only built when AT-SPI2 is actually observing the application, so the pipeline costs nothing on headless runners. The current cut covers the common cases — buttons, lists, form fields, dialogs — and intentionally leaves room for follow-up:
|
||||
|
||||
- **Hierarchical nodes (groups, lists with explicit children)**: today the tree is flat. AccessKit supports nesting but the layout pass does not expose `Column` / `Row` / `Container` parents to the accessibility builder. Adding that requires either recording the nesting on `LaidOutWidget` or walking `Element` again from the a11y side.
|
||||
- **Per-widget accessible label / description / `LabelledBy` relations**: the API to set them (`Button::accessible_name(...)`, etc.) is not exposed yet — labels currently fall back to the widget's tooltip text. Adding the builders is mechanical but touches every widget module.
|
||||
- **Live regions**: status messages, notifications and OSDs that should announce themselves on appearance need `Live::{Polite, Assertive}` on the relevant nodes. Not wired in.
|
||||
- **`Action::SetValue` for sliders and text inputs**: the inbound action handler does not yet translate these requests into the corresponding widget message. Adding them needs the same plumbing as `Click` / `Focus` but with payload extraction from `ActionData`.
|
||||
- **Generic container nesting**: dialogs, text runs and the root do nest, but `Column` / `Row` / `Container` parents inside a surface are not represented — a surface's widgets are siblings. Adding that requires either recording the nesting on `LaidOutWidget` or walking `Element` again from the a11y side.
|
||||
- **Per-widget accessible label / description / `LabelledBy` relations**: the internal `accessible_label` plumbing exists (labels are derived from the widget's own content, with the tooltip as last fallback), but the public builders to override them (`Button::accessible_name(...)`, etc.) are not exposed yet. Adding them is mechanical but touches every widget module.
|
||||
|
||||
Downstream consumers shipping into regulated environments (EN 301 549, WCAG 2.1 AA, EAA) should still treat the integration as a starting point that needs a real audit with assistive technology users — the foundation is in place but the per-widget metadata work is what determines whether Orca actually reads a useful announcement.
|
||||
|
||||
**Cross-application drag-and-drop — deferred.** The clipboard now bridges to the Wayland selection via `wl_data_device_manager` (see `event_loop/data_device.rs`), so Ctrl+C / Ctrl+V crosses application boundaries when the compositor advertises the global. Middle-click primary selection (`zwp_primary_selection_v1`) and inter-app drag-and-drop targets (drop-zone widgets that accept text / URI lists from outside the process) are still pending — they share most of the offer / source plumbing but need widget-level drop-target wiring on top.
|
||||
|
||||
**Multi-touch — deferred.** `input/touch/mod.rs` is single-slot by design today; a second finger overwrites the first. Pinch-zoom, two-finger scroll and gesture combos are out until the slot table lands. Tracked separately.
|
||||
**Multi-touch — primary slot plus raw auxiliary fingers.** The first finger to land on a surface becomes its primary slot and drives the built-in gesture machine (swipe, scroll, long-press, drag). Additional fingers bypass the gesture machine and surface directly through `App::on_touch_down` / `on_touch_move` / `on_touch_up`, so apps can implement pinch-zoom or two-finger pan without losing the built-in gestures. Apps that want the whole stream raw — an embedded web view, a drawing canvas — return `true` from `App::claims_raw_touch`: every finger, primary included, then reports through `on_touch_*` and widget presses, taps and swipes never fire. What ltk itself does not provide is *recognition* of multi-finger gestures — pinch and rotate detection is the app's job on top of the raw stream.
|
||||
|
||||
**HarfBuzz shaping — wired in.** `src/text_shaping.rs::shape_line` now drives both renderers: the line is BiDi-reordered, split into per-font sub-runs and shaped through rustybuzz. The glyph cache is keyed on `(glyph_id, size_bits, font_id)` and each glyph is rasterised by index via `fontdue::Font::rasterize_indexed`, so Arabic connected forms, Devanagari clusters and CJK shaped glyphs render correctly.
|
||||
|
||||
**xdg-activation-v1 and fractional scale — deferred.** Activation tokens (so an external launcher can raise an ltk window with focus) and `wp_fractional_scale_v1` (so 125 % / 150 % outputs render natively instead of via compositor downscale) are tracked as upcoming protocol work.
|
||||
**xdg-activation-v1 — wired in.** Both directions work: a token found in `$XDG_ACTIVATION_TOKEN` at startup is used to activate the app's own window once it maps (so an external launcher can raise an ltk window with focus), and an app that spawns children requests fresh tokens through `App::take_activation_requests` and receives them via `App::on_activation_token` to place in the child's environment.
|
||||
|
||||
**Fractional scale — deferred.** `wp_fractional_scale_v1` (so 125 % / 150 % outputs render natively instead of via compositor downscale) remains tracked as upcoming protocol work.
|
||||
|
||||
Reference in New Issue
Block a user