Files
ltk/docs/architecture.md
Pedro M. de Echanove Pasquin 4a80165428
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
event_loop, a11y, text_shaping: AccessKit AT-SPI2 bridge, cross-app clipboard, xdg-activation, HarfBuzz shaping, multi-touch hooks
Five orthogonal capabilities land together because they share the same `try_run` plumbing: an optional global is bound at startup, a piece of state is added to `AppData`, the run-loop iteration drains an inbox / pushes a frame snapshot, and the public surface gains a small set of opt-in `App` hooks. Nothing here breaks an existing app — every new path degrades to a no-op when the compositor does not advertise the relevant global or when the platform adapter cannot start.
AT-SPI2 accessibility via AccessKit. A new `src/a11y/` module owns the platform adapter and the inbound `ActionRequest` channel. `A11yState::try_new` constructs an `accesskit_unix::Adapter`; when the AT-SPI2 daemon is not on the session bus (headless CI, locked-down compositors) the constructor returns `None` and the rest of the pipeline runs unchanged. After every successful `draw_frame`, the run loop builds a fresh `accesskit::TreeUpdate` from `widget_rects` and pushes it through the adapter — main surface plus every visible overlay, each translated to global coordinates via `surface_offset_for` so screen readers report positions in the same frame the user sees. Buttons / toggles / checkboxes / radios / list items / sliders / text edits map to the matching `Role`s; `Click` and `Focus` actions are advertised on every interactive node; inbound action requests are drained at the top of each iteration and translated into a synthetic press / focus on the matching widget. The integration is documented as best-effort in `docs/architecture.md` under "Known gaps and non-goals": hierarchical nesting, per-widget accessible names, live regions and `Action::SetValue` are listed as the natural follow-ups that the foundation now supports but does not yet wire.
Cross-application clipboard via `wl_data_device_manager`. A new `src/event_loop/data_device.rs` bridges the existing process-local `clipboard: String` to the Wayland selection. Outbound (Ctrl+C / Cut): after the local clipboard is populated, `publish_clipboard_selection` creates a `CopyPasteSource` offering `text/plain;charset=utf-8` and installs it as the seat's selection; `DataSourceHandler::send` writes the cached string into the fd the peer hands us. Inbound (Ctrl+V from another app): `DataDeviceHandler::selection` asks for the offered text via `WlDataOffer::receive`, spawns a tiny worker thread to drain the read pipe with a 16 MiB cap to prevent paste-bomb DoS, and posts the result back through an `mpsc::Sender` that the run loop drains each iteration into `data.clipboard`. The `clipboard:` field's doc-comment is updated to reflect the new behaviour: process-local when the compositor does not advertise the global, synchronised with the seat selection otherwise.
External drag-and-drop reception. The same `data_device` module handles `DragOffer` enter / motion / leave / drop_performed: `on_drop_motion( x, y )` fires while the drag hovers over the surface, `on_drop_leave()` when it withdraws without dropping, and `on_drop_received( x, y, mime, text )` when an external payload (`text/uri-list`, `text/plain`, …) is released on top of an ltk window. The receive path reuses the same worker-thread / channel pattern as the clipboard so the run loop never blocks on the read fd. Three new `App` hooks expose the events with no-op defaults; apps that ignore them get the previous behaviour.
`xdg-activation-v1`. The global is bound optionally; when it is present, `try_run` reads `$XDG_ACTIVATION_TOKEN` from the environment, removes it immediately (single-use; preventing leaks into child processes) and stashes it on `AppData::activation_token_pending`. After the first successful configure of the main surface — the earliest point at which `xdg_activation_v1.activate` is meaningful — the token is consumed once and the surface raised to focus. Compositors without the global leave `activation_state` as `None` and the inbound path silently degrades. An `App::request_activation_token` outbound path is reserved on the trait but not yet exercised here.
HarfBuzz shaping. A new `src/text_shaping.rs::shape_line` drives both renderers: the logical-order string is run through `unicode-bidi`, split into per-font sub-runs, and shaped through `rustybuzz`. Each `PositionedGlyph` carries the per-font `glyph_id`, the visual advance and the ink offsets — exactly what `fontdue::Font::rasterize_indexed` needs to render Arabic connected forms, Devanagari clusters and CJK shaped glyphs correctly. The GLES atlas is re-keyed on `(glyph_id, size_bits, font_id)` so glyphs from different fonts at the same size no longer collide, and the atlas format is selected per ES profile (`GL_R8` / `GL_RED` on ES3, `GL_LUMINANCE` on ES2) — the fragment shader samples `.r` for both, since `GL_LUMINANCE` replicates the coverage byte into `.r=.g=.b`. Software path follows the same key. New `Cargo.toml` deps: `unicode-bidi = "0.3"`, `rustybuzz = "0.14"`.
Multi-touch hooks. `App::on_touch_down / on_touch_move / on_touch_up( id, x, y )` expose the raw `wl_touch.id` of every secondary finger. The first finger to land remains the *primary slot* and is fed through the regular gesture machine (`on_pointer_*`, swipe, scroll, long-press, drag-and-drop). Every additional finger fires the new callbacks instead, leaving the existing single-slot behaviour untouched for apps that do not override them. This is the substrate for app-defined pinch-zoom / two-finger pan; the toolkit itself does not yet ship a built-in pinch gesture (called out in the same "Known gaps" doc section).
`event_loop::frame` extracted from `draw/mod.rs`. The `draw_frame` orchestrator and its per-format SHM helper (`pick_shm_format`) move into `src/event_loop/frame.rs`, leaving `draw/` strictly responsible for per-surface paint primitives. The import in `event_loop/run.rs` is rewritten accordingly; `draw/mod.rs` shrinks from 192-line orchestrator to a thin module index.
Overlay teardown safety. `AppData::discard_overlay( id )` synchronously removes a destroyed overlay from the map and rewrites every per-device focus that pointed at it (pointer, keyboard, every touch slot), migrating an in-flight long-press drag to the main surface the same way `reconcile_overlays` does. Used by the compositor-driven destruction paths (`PopupHandler::done`, `LayerShellHandler::closed`) where waiting for the next reconcile would leave a window in which `surface()` / `surface_mut()` panic. The non-panicking siblings `try_surface` / `try_surface_mut` are added for callers on async dispatch paths (IME `Done`, tooltip arm) that may race a teardown.
Miscellaneous. CI: `master` → `main` to match the actual default branch. `Makefile` adds `cargo run --example dialog` to the examples target. `src/lib.rs` re-exports `widget::scroll::ScrollAxis` so apps can configure a `scroll()` axis without reaching into a `pub(crate)` module. `Cargo.toml` adds `accesskit = "0.17"` and `accesskit_unix = "0.13"`. `docs/architecture.md` gains the "Known gaps and non-goals" section that enumerates the new capabilities, what still ships flat, and what is deferred (per-widget a11y labels, primary selection, intra-process multi-touch gestures, `wp_fractional_scale_v1`).
2026-05-16 22:09:59 +02:00

21 KiB
Raw Blame History

ltk architecture

If you are new to the library, start with docs/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.

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.

The rest of this document explains why those repos look the way they do.

If you are coming from cargo doc, keep the public API split in mind:

  • ltk::window — normal application windows
  • ltk::shell — layer-shell and overlays
  • ltk::runtime — advanced runtime hooks and runtime-free embedding

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.

Mental model

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.

This sounds expensive and is actually fine. The widget tree is plain enums, the layout pass is a single recursive walk that already has to happen anyway, and as of the WidgetHandlers snapshot work the input dispatch path no longer rebuilds the tree per event. The only thing the app must avoid in view() is I/O (reading files, scanning directories, walking icon caches) — keep those in poll_external or behind a RefCell cache.

In practice, that model is easiest to adopt in three steps:

  1. Start with the ltk::window mental model: one app state, one view(), one update(), one normal window.
  2. Add ltk::shell concepts only if you need layer-shell or overlays.
  3. Reach for ltk::runtime hooks only when you need async wakeups, invalidation narrowing, or embedding outside ltk::run().

The trait surface, by purpose

App looks intimidating — most of it is opt-in. Group the methods by what you actually need:

Always implement

  • type Message — your message enum.
  • view(&self) -> Element<Msg> — main surface contents.
  • update(&mut self, msg: Msg) — state transitions.

Implement when your app is multi-surface

  • overlays(&self) -> Vec<OverlaySpec<Msg>> — see Surface composition below.

Implement when your app is a shell component, not a window

  • shell_mode()ShellMode::Layer( Layer::Background | Bottom | Top | Overlay ).
  • layer_anchor(), layer_size(), exclusive_zone(), keyboard_exclusive() — the layer-shell knobs.
  • background_color()Color::rgba( 0, 0, 0, 0 ) for transparent surfaces (panels, OSDs).

Implement when external state matters

  • set_channel_sender(sender) — saved once at startup; clone into background threads to push messages into the loop without polling.
  • poll_external() -> Vec<Msg> — called after every Wayland event and every poll_interval() tick. Drain receivers here.
  • poll_interval()None (event-driven only) or Some( Duration ) (timer wakeups for clocks, expiry, etc.).

Implement when input gestures matter

  • on_swipe_up, on_swipe_down, on_swipe_progress, on_swipe_down_progress (follow-the-finger).
  • on_tap — taps that miss every widget.
  • on_key / on_key_with_modifiers — global hotkeys.
  • swipe_threshold, swipe_down_threshold — gesture sensitivity.

Implement for animations and focus

  • is_animating() — return true while a tween is running; the loop redraws at ~60 Hz.
  • take_focus_request()Option<WidgetId> — pull-once focus retargeting.
  • on_text_input_focused(active) — surface IME state.

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.

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]:

  • 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.
  • Id missing → destroy the surface.

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.

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.

Common patterns:

  • Modal panel: layer: Overlay, anchor: ALL, keyboard_exclusive: false, on_dismiss: Some( CloseMsg ). Tap-outside dismisses; the panel itself centers via column().push(spacer()).push(panel).push(spacer()).
  • Pass-through OSD: same as above but input_region: Some(Vec::new()) so pointer events fall through to whatever is below.
  • Top bar / dock: layer: Top or Bottom, anchor: TOP/BOTTOM, fixed size, non-zero exclusive_zone so app windows reflow around it. Usually returned from view() (single-purpose shell), not from overlays().
  • Greeter / lock screen: shell_mode: Layer(Overlay), keyboard_exclusive: true. Loginmanager is the reference.

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.

If your application does not need overlays or layer-shell, you can ignore this entire section and stay in the ltk::window subset.

Theming

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.
  3. Active stateltk::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:

# fn _ex() {
let _label = ltk::text( "Hello" )
    .color( ltk::theme_palette().text_primary );
# }

To switch theme at runtime, dispatch a message that calls ltk::set_active_mode( ThemeMode::Dark ) from update() and let the next frame re-resolve. There is no manual invalidation step.

Loading a different document:

let doc = ltk::ThemeDocument::find( "default" )
    .expect( "default theme not installed (ltk-theme-default)" );
ltk::set_active_document( doc );

For dev iteration set LTK_THEMES_DIR=/path/to/ltk/themes so the lookup picks files in the working tree before the system path. The full search order is:

  1. LTK_THEMES_DIR/<id>/ when the env var is set
  2. $XDG_DATA_HOME/ltk/themes/<id>/ (defaults to ~/.local/share/ltk/themes/<id>/)
  3. /usr/share/ltk/themes/<id>/

Wallpapers ship as a single landscape PNG per variant. ltk::WallpaperBundle::from_path_or_bytes( path, bundled_fallback ) handles the disk-or-builtin fallback, and bundle.for_size( sw, sh ) returns the right crop for landscape or portrait surfaces — no need to ship two PNGs.

For many third-party apps, theming is optional at first. It is reasonable to start with the default theme and come back to the runtime theme APIs later as part of the ltk::runtime layer.

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():

# struct App { toast: Option<()>, nav_progress: f32 }
# impl App {
fn is_animating( &self ) -> bool
{
    self.toast.is_some()      // an OSD is fading
        || self.nav_progress < 1.0  // a screen is sliding
}
# }

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:

# use std::time::Instant;
# use ltk::Element;
# const TOAST_DURATION: f32 = 3.0;
# #[ derive( Clone ) ] enum Msg {}
# struct App { toast_started: Option<Instant> }
# impl App {
fn view( &self ) -> Element<Msg>
{
    let progress = match self.toast_started
    {
        Some( t ) => ( t.elapsed().as_secs_f32() / TOAST_DURATION ).min( 1.0 ),
        None      => 0.0,
    };
    // … fade alpha = 1.0 - progress
#   ltk::text( "" ).into()
}
# }

The end-of-animation cleanup belongs in poll_external(): when progress >= 1.0 clear self.toast_started so is_animating() returns false and the loop sleeps again.

For follow-the-finger gestures use on_swipe_progress(progress) / on_swipe_down_progress(progress). Those fire continuously during the drag with a 0.0..=1.0 value and don't require is_animating — the gesture itself drives the redraw.

For a basic application window, defer this whole area until the rest of the UI is already working. Animation is part of the advanced runtime surface, not the core onboarding path.

Larger state patterns

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.

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.

Ephemeral caches behind RefCell (single-threaded). view(&self) is &self; if you need a mutable icon cache, scaled-image cache, layout cache, etc., wrap it in RefCell<...> on the app struct and borrow_mut() inside view(). Crustace's IconCache does exactly this. Don't reach for Mutex — the event loop is single-threaded.

External state via channel + poll. Anything that blocks (D-Bus, files, network, IPC) lives on a background thread. At startup save the ChannelSender<Msg> from set_channel_sender, hand a clone to the worker, and have the worker push messages back. poll_external() is the place for non-blocking try_recv() against in-process receivers (e.g. mpsc/crossbeam channels) or for expiry checks like "is this notification past its TTL".

Stable widget ids only when you need to programmatically focus them. WidgetId is an opt-in tag on a widget that pairs with App::take_focus_request(). Don't decorate every widget; tag the one input you want to autofocus on screen entry.

Again, the simplest progression is:

  1. one flat app state in ltk::window
  2. sub-state and overlays once the app becomes shell-like
  3. caches, channels, focus retargeting, and cross-surface invalidation only when scale requires them

Performance

The cheap things and the expensive things, in rough order:

  • Cheap: building the Element<Msg> tree. It's plain enums and Vecs. 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.
  • 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.
  • 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; both render the same pixels (see the recent commits for the alpha/SDF parity work). There is no API-level difference for the application.

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.

Where to look in the consumer repos

Pattern File
Multi-overlay coordination, overlay id constants crustace/src/app.rs (overlays(), lines ~250380)
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/}
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
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.

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 ListItems 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.

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.

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:

  • 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.

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.

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.