In both `themes/default/branding/dark/launcher.svg` and `themes/default/branding/light/launcher.svg` the launcher glyph is built from nine cells, each with its own `filterN_diiii_4479_*`. Those filters layered four successive inner-shadow passes (`effect2..effect5_innerShadow_*`) on top of the outer drop shadow `effect1_dropShadow_*`: pairs of `feColorMatrix in="SourceAlpha"` + `feOffset` + `feGaussianBlur` + `feComposite operator="arithmetic"` + `feBlend`, with offsets `(-3.6,-3.6)`, `(1.8,1.8)`, `(0.45,0.45)` and `(1.8,1.8)` and blend modes `plus-lighter`, `overlay` and `normal` to reproduce the light-above / dark-below bevel and inner halo of the original Figma export. That is twenty-four lines per cell and nine cells per file — 216 lines per SVG, 432 in total.
This change removes those four inner-shadow passes in both files; the outer drop shadow (`effect1_dropShadow_*`), the `clipPath` with `bgblur`, the `rect` elements defining each cell and the rest of the document are kept verbatim. The launcher silhouette and its placement do not change: the icon still occupies the same `viewBox` and produces the same clip; what disappears is the specular highlight and dark inner contour of each cell, leaving flat rectangles on the background with their projected shadow. The diff is pure deletions, with no added lines, and the existing difference between the `dark` and `light` variants is preserved (only the filter identifiers differ, `_38862` versus `_38700`).
Forge's app switcher needs two layouts the existing widget set didn't cover. The desktop grid wants a partial last row centred under the rows above (3 tiles → row 1: two, row 2: one centred) so a 7-of-9 leftover band reads balanced rather than left-aligned. The mobile variant wants a horizontal carousel where the focused tile sits centred in the viewport at a configurable fraction of its width and its neighbours peek out on the sides at a fixed gap.
Extend `WrapGrid` with `centre_last_row( bool )`. When set, layout offsets a row that has fewer than `columns` children by `(missing * (cell_w + spacing)) / 2` so it stays centred inside the content rect. Defaults to false; every existing call site continues to land tiles flush-left. Covered by three layout tests (centred partial row, full row no-op, off-by-default).
Add the `Carousel` widget at `src/widget/carousel/`. It is a pure layout primitive: `focused_width_frac` (0.05–1.0, clamped), `gap` and `offset` are owned by the caller, leaving drag / inertia / snap policy to the host so the compositor can plug in its existing touch pipeline. Each child gets a rect at `base_x + idx * (child_w + gap)` and the full viewport height; `snap_offset( viewport_w, idx )` translates index to centring offset and `focused_index( viewport_w )` rounds the current offset back to the nearest tile. Plumbed into `Element::Carousel` with the matching arms in `widget/element.rs` and walker in `draw/layout.rs`; re-exported as `ltk::{ Carousel, carousel }`. Covered by nine unit tests (layout, offset shift, snap / focus round-trip, frac clamp, child height) plus a `cargo run --example carousel` demo with Prev / Next / arrow-key navigation against an external offset state. The example is wired into the `examples` Makefile target.
Updates the widget catalogue and the `widget/mod.rs` landing comment to list the carousel under "Clipping wrappers" and to mention `centre_last_row` in the grid section.
Two independent changes, both blockers for a working desktop session through the loginmanager-daemon handoff and the dock's running-app icons.
`App::on_first_frame_committed` is a new trait hook fired exactly once, immediately after the very first `wl_surface.commit` of a rendered buffer on the main surface. `AppData` grows a `first_frame_committed: bool`, `draw_frame` now returns whether this call performed that first commit, and `try_run` invokes the hook after the borrows held during the draw are released. Used by the loginmanager-daemon-aware crustace path to signal "ready to be presented" back to the daemon as soon as the GPU has the first frame — the actual present can still be deferred under VT switching (no DRM master yet), but the client-side commit is the right edge for handoff.
`toplevel_display_id` in the foreign-toplevel-list handler no longer falls back to `info.title` or `info.identifier` when `info.app_id` is empty. Smithay creates each `ext-foreign-toplevel-list-v1` handle with `app_id = ""` and `init_new_instance` flushes a `done` immediately, so subscribers used to see the protocol-level identifier (a 32-char `Alphanumeric` random token) as the "app id" of every new toplevel — and remained stuck on it whenever the client's real `set_app_id` arrived between done events but the subscribing app's matcher couldn't resolve it to a `.desktop` entry. Returning the raw `app_id` (empty or not) makes that first transient `done` ignorable by the consumer's own empty-string guard; the second `done`, carrying the real app id, is processed normally.
`Stack::preferred_size` unconditionally reported `(max_width, max_h)` — every stack claimed the full width its parent offered, regardless of what its children actually needed. That is the right default for a FrameLayout-style overlay (the existing callers all rely on it) but it makes the natural "pin a stack to a fixed-size child (e.g. a spacer of `card_w × card_h`) and centre other children on top of it" pattern impossible: a container wrapping such a stack always inherited the full parent width and ignored the spacer's footprint. The new `Stack::fit_content()` builder mirrors the `Column::fit_content` flag — when set, `preferred_size` returns the max of children's intrinsic widths and heights instead of claiming the parent's `max_width`, with the same "skip filler widgets that themselves claim `max_width`" exclusion list (`Spacer` with no `fixed_width`, `Separator`, `Scroll`, `ProgressBar`, `Slider`, `TextEdit` without `fixed_width`) so the flag is not defeated by a flexible child slipping into the stack. Default behaviour is unchanged.
`Stack::preferred_size` unconditionally reported `(max_width, max_h)` — every stack claimed the full width its parent offered, regardless of what its children actually needed. That is the right default for a FrameLayout-style overlay (the existing callers all rely on it) but it makes the natural "pin a stack to a fixed-size child (e.g. a spacer of `card_w × card_h`) and centre other children on top of it" pattern impossible: a container wrapping such a stack always inherited the full parent width and ignored the spacer's footprint. The new `Stack::fit_content()` builder mirrors the `Column::fit_content` flag — when set, `preferred_size` returns the max of children's intrinsic widths and heights instead of claiming the parent's `max_width`, with the same "skip filler widgets that themselves claim `max_width`" exclusion list (`Spacer` with no `fixed_width`, `Separator`, `Scroll`, `ProgressBar`, `Slider`, `TextEdit` without `fixed_width`) so the flag is not defeated by a flexible child slipping into the stack. Default behaviour is unchanged.
`cargo doc --no-deps` was emitting eight warnings about intra-doc links resolving to private items or ambiguous fn/module names. None reflect a behaviour bug; they were just noise that cluttered the docs build output.
Five of the warnings came from doc comments that cross-referenced items not in the rendered public API. `widget::scroll::Scroll` (struct), `Scroll::horizontal`, `Scroll::both`, `event_loop::text_editing` (module) and `text_shaping` (module) are all `pub` in their own modules, but the `widget` and `event_loop` parents are private to the crate root, so rustdoc treats them as private when resolving links from items that ARE on the public surface (`ScrollAxis`, the `scroll` constructor, `font_bytes`, the `text_edit` module docs). The fix is to drop the link syntax for those references: keep the identifier in backticks (so it still renders as code) but remove the surrounding `[...]` so rustdoc doesn't try to resolve it. Where the cross-reference had no semantic load beyond "see this module", the prose is rephrased to name the module without trying to link to it (e.g. "the `event_loop::text_editing` private module", "see the `text_shaping` private module").
The remaining three warnings were `palette` / `surface` linking ambiguously between a module of that name and a function of the same name within `crate::theme`. Adding `()` after the identifier inside the brackets (`palette` → `palette()`, `surface` → `surface()`) disambiguates to the function, which is the intent in all three sites — the surrounding text talks about "per-slot shorthand accessors", which is what the functions are.
After this `cargo doc --no-deps` runs clean with no warnings.
`handle_key_return` previously routed `Return` to either the focused or hovered widget's submit/press message and, only when neither index existed, fell through to `app.on_key_with_modifiers`. If a stale `hovered_idx` (left over from a prior screen) pointed at a widget that exists in the new `widget_rects` but exposes no submit or press handler, `target.is_some()` was true and the message-less dispatch silently swallowed the key — the `else` branch never ran and `App::on_key` never saw the keysym. This manifested in the eydos-loginmanager greeter as `Enter` on the Lock screen failing to fire `Message::Unlock` after a pause/resume cycle, until the user clicked something to refresh `hovered_idx`. Track whether the widget path actually pushed a message and, when it didn't, fall through to the app-level handler so the `Return` keysym still gets a chance to be interpreted by the application's `on_key`. No behaviour change when the focused/hovered widget does provide a `submit_msg` or `press_msg`.
The previous fix checked downcast_ref::<io::Error> directly on the
OtherError payload, but wayland-client wraps the io::Error inside
WaylandError::Io — one level deeper. Downcast failed, is_closed
stayed false, and the panic arm fired anyway.
Replace the single downcast with a source()-chain walk that finds
the io::Error at any depth, matching the same BrokenPipe /
ConnectionReset check already used for the direct IoError arm.
calloop surfaces the closed-socket condition either as
Error::IoError(BrokenPipe) — already handled — or as
Error::OtherError wrapping an std::io::Error with kind BrokenPipe,
which previously fell through to panic!(). Add a matching arm that
downcasts the inner error and treats both forms the same: log and
set exit_requested so the run loop terminates gracefully instead
of unwinding.
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`).
Each source file that had grown beyond a single concern is replaced by an identically-named directory containing focused submodules. `src/event_loop/mod.rs` (878 lines) becomes a directory with clipboard, context_menu, cursor_shape, drag, focus, handlers, invalidation, overlays_reconcile, repeat, run, surface, text_editing, and tooltip. Every widget, input handler, and theme component follows the same split. Public interfaces are unchanged — only the internal file layout moves.
image bumped from 0.25.2 to 0.25.9.
The keysym dispatcher used to give the focused widget first refusal on Left / Right / Up / Down / Tab. A `text_edit` swallowed the four arrows for cursor movement, and Tab walked the keyboard-focus ring through `next_focusable_index`. Apps only got `on_key_with_modifiers` for those keys when no text input was focused (Tab never), so a search field with autocomplete couldn't drive a selection cursor through arrows or Tab without putting focus on something else first — which is exactly the wrong UX for a search-as-you-type list.
Three arms in `AppData::handle_key_press` now query `self.app.on_key_with_modifiers( keysym, ctrl, shift )` *first* and only fall back to the default behaviour if the app returns `None`:
- `Left | Right`: previously branched on `is_text_input` and routed the keypress to `handle_cursor_left/right` for text fields, otherwise asked the app. Now: app first; if app says `None`, the existing text-cursor path runs for text inputs and non-text widgets get nothing (their previous fallback already produced no useful action without an app handler).
- `Up | Down`: the previous logic was a four-way decision that tried text-cursor movement, then `move_keyboard_hover` for combo / list widgets, then the app handler. Now the app gets first refusal too. When it declines, the original cascade (text-cursor → hover navigation) still runs, so multiline `text_edit` cursor walking and combo / scrollable-list keyboard nav are unchanged for any app that doesn't intercept these keys.
- `Tab | ISO_Left_Tab`: app first; on `None` the focus-shift path (`next_focusable_index` + `set_focus`) runs. Apps that want Tab as a navigation message between custom UI states (a search field cycling through results, a wizard advancing pages) finally have a hook; apps that don't get the standard tab-through-focusable behaviour by leaving `on_key_with_modifiers` returning `None` for Tab, which is the trait's default.
The interception is keysym-only — modifier state is forwarded so an app can distinguish `Tab` from `Shift+Tab`, `Right` from `Ctrl+Right`. The text-input check is preserved as a local before the dispatch so the fallback doesn't lose the "is this even a text edit?" question; it just runs *after* the app instead of before.
Net effect: a list / autocomplete that needs arrow / Tab navigation can now be implemented purely by overriding `App::on_key_with_modifiers`, with no changes to `text_edit` itself and no risk of breaking apps that don't need it. The Up / Down branch's old comment about the "fall through to list hover-navigation" rationale is dropped — the cascade still exists, but the app-first ordering is the new contract and the comment was about the previous one.
`Button::tooltip( text )` registers a hint string that fires after a 600 ms pointer dwell. `LaidOutWidget` gains a `tooltip: Option<String>` field, `Element::tooltip()` exposes it to the input layer, and the existing pointer-hover path now calls `arm_tooltip` on hover-enter and `cancel_tooltip` on hover-leave / touch. The deadline is polled alongside `next_long_press_wakeup` in `try_run` so an idle pointer still gets a wake-up at the firing instant; on fire, `tooltip_overlay()` synthesises an `OverlaySpec` — a rounded `text_primary @ 95%` pill drawn with `bg` text — anchored above the hovered widget, flipping below or clamping inside the screen if it would clip, and pushed alongside the app's own overlays both in the redraw path and in `reconcile_overlays` so the layer surface is created the same frame the tooltip becomes visible. Pointer-only by design: touch events explicitly cancel because a tap-and-release should never linger into a hint. The `showcase` example wires `.tooltip(..)` on the three button variants as a smoke test.
The drag pipeline now reports positions in main-surface (global) coordinates instead of per-surface. `surface_offset_for( focus )` derives the top-left of an overlay surface from `SurfaceState::layer_anchor` — newly stored at `reconcile_overlays` time from `OverlaySpec::anchor` — combined with the main surface's dimensions; `on_drag_move`, `on_drop`, the synthetic move emitted on drag-promotion in `pointer.rs`, and the `pending_drag_inits` push site in `gesture::start_drag` all translate before handing coordinates to the app. The motion and release paths additionally `request_redraw()` every overlay so a dock-style drop target painted on an `Anchor::Bottom` layer surface gets repainted as the drag moves — without that, the visible drop indicator only updates when the cursor re-enters the main surface. Drops still target whichever surface fired the release; only the coordinates are unified.
`ForeignToplevelListHandler` previously read `app_id` directly via `ForeignToplevelList::info()`. Clients that never set `app_id` (some winit-windowed compositors, simple test clients) were silently invisible to crustace-style docks because the empty string fell through `unwrap_or_default()` and the dock then keyed entries off `""`. `toplevel_display_id()` cascades: prefer `app_id` for desktop-entry matching, fall back to `title` for human-readable identification, and finally to the protocol-issued `identifier` which is always present and unique per handle. Applied to both `new_toplevel` and `update_toplevel`.
`theme::system_fontdb()` lazily loads the system font database once via `OnceLock` and reuses the `Arc` for every `decode_svg_bytes` call. resvg's default `Options::fontdb` is empty, so any SVG containing `<text>` rendered with the built-in fallback font or no font at all; with the system DB attached, icons and decorative SVGs with embedded labels now resolve glyphs correctly. Cached because `load_system_fonts()` walks every font path on the system and is comfortably tens of milliseconds on a cold cache — not something to repeat per icon decode.
`themes/default/theme.json` tweaks one variant's slot palette: `surface-alt` from `@indigo/D9` to `@white/D9` and `text-primary` from `@white` to `@navy`, plus a cosmetic re-alignment of the `"value"` columns in the same slots block.
`Container::max_width(px)` mirrors the same flag on `Column` / `Row` — the container reports `min( offered, px )` upwards and the draw pass caps `rect.width` to `px`, so a decorated child wrapped in `container().max_width(260)` no longer needs a `column()`-of-one shell just to access the cap. Propagated through `map_msg`, covered by a test, and documented under the `container` section of `docs/widgets.md`.
`Text::no_truncate()` opts out of the default ellipsis behaviour: when `truncate = false` the draw pass paints the full string even if `measure(text) > rect.width`. Useful for very short labels (calendar days "1"–"31", day-of-week stubs "Lu" / "Mi" / "Sá") inside grid slots whose width is dictated by the parent — a couple of pixels of overflow centred in the rect is invisible, while "..." in place of a single-digit number is loud.
`DatePicker::width(px)` is a layout hint: when set, `build()` derives `header_fs`, `dow_fs` and `day_fs` from the slot width so the worst-case label in each row ("September 2026" in the header, "Mié" / "Sáb" in the DOW row, "30" in the day cell) fits at a sensible size; the design defaults stay as upper bounds. Day and DOW cells also flip on `Text::no_truncate()` as a safety net — heuristic font sizing can't perfectly predict glyph widths across families, and overflowing one pixel beats truncating to "...".
The `take_focus_request` block in `try_run` only searched `data.main.widget_rects` for the requested `WidgetId`, so an app that wanted to focus a widget living on an overlay surface (a search field on a launcher overlay, a text edit on a dialog modal, a password field on a popup) silently no-op'd: the widget existed and had been laid out, but the lookup never found it because it was scanning the wrong `widget_rects`. Crustace hit this trying to put cursor focus on the launcher search field when the launcher slides up — the field rendered with a visible caret but typing went to whoever the keyboard had been focused on before.
The lookup now falls through to `data.overlays.iter()` if the main scan misses, returning the first `(SurfaceFocus::Overlay(id), flat_idx)` whose surface carries a widget with that id. `data.set_focus` already accepts the `SurfaceFocus` discriminant, so the call site just forwards what the lookup found and requests a redraw on the matching surface instead of unconditionally on main. No effect on apps that target main-surface widgets — the main scan still runs first and short-circuits the overlay walk.
`WindowControlsSpec` gains `bar_bg`, the background fill of the SSD title-bar strip the close / maximize / minimize controls sit on. Forge used to paint that strip with `palette.surface` clamped to alpha 1.0 — a hack on a translucent panel token. With a dedicated slot the theme decides directly: `@off-white` in light and a new `@window-bar-dark` shade in dark. Schema gets the matching `Option<String>` field (defaulting to `palette.surface` to keep existing themes rendering the same) and the fallback `WindowControlsSpec` seeds `Color::WHITE` so a missing theme still draws something readable.
`try_run`'s dispatch loop previously `.expect("dispatch")`-ed every calloop error. When the compositor closes the wayland socket — `wl_display.error`, forge crashing, the user logging out — the loop saw a `BrokenPipe` / `ConnectionReset` and panicked, which polluted the user's stderr with a backtrace for what is just "the session ended". The match now treats those two `IoError` kinds as a clean shutdown: it prints `Connection::protocol_error()` (the typed `wl_display.error` if the server sent one), sets `exit_requested = true`, and lets the loop drain out normally. Any other dispatch error keeps panicking — those are still genuine bugs.
Adds an `App` callback that delivers the live list of open toplevels from the compositor — the data source a shell needs for dock running-app indicators, taskbar tiles, alt-tab and any other "what is currently running" UI. Hand-wiring the protocol binding from every shell that wants it is the kind of boilerplate ltk should absorb once: this is that move.
`Cargo.toml` adds `"staging"` to `wayland-protocols`' feature list. SCTK 0.20 already pulls staging in transitively (it carries `foreign_toplevel_list.rs` and its own dispatch helper), so this is belt-and-braces against a future ltk tree that swaps SCTK for a different client toolkit — it keeps the protocol available crate-wide even then. `src/app.rs` introduces `ToplevelEvent { Opened { id: u32, app_id: String }, Closed { id: u32 } }` and `App::on_toplevel_event( &self, ToplevelEvent ) -> Option<Self::Message>` with the default returning `None` so apps that do not care pay nothing (no allocation, no dispatch). `id` is the Wayland protocol id of the handle proxy — unique per session, stable for the handle's lifetime, the same value paired across `Opened` and `Closed`. `src/lib.rs` re-exports `ToplevelEvent` from the public prelude.
`src/event_loop/app_data.rs` grows a `pub foreign_toplevel_list: ForeignToplevelList` field. `src/event_loop/mod.rs` constructs it via `ForeignToplevelList::new( &globals, &qh )` and stores it on `AppData`. If the compositor does not advertise the global the inner `GlobalProxy` just resolves to "absent" and the list yields no toplevels — no error path needed at construction. `src/event_loop/handlers.rs` adds the `Proxy` import, `delegate_foreign_toplevel_list!( @<A: App> AppData<A> )` next to the rest, and implements `ForeignToplevelListHandler` for `AppData<A>`. The three SCTK callbacks (`new_toplevel`, `update_toplevel`, `toplevel_closed`) each pull the handle's protocol id and its currently-cached `app_id` from the list's info cache, call `self.app.on_toplevel_event( … )`, and push the returned message onto `pending_msgs` so it flows through the normal `update` cycle with the regular `invalidate_after` scoping path. `update_toplevel` re-emits `Opened` with the latest info — compositors fire this on title changes too, not just `app_id` changes, but apps whose state is keyed on `(id, app_id)` can absorb the repeat idempotently and apps that need title-change granularity can scope via `invalidate_after`.
The wire-up is generic: a shell that wants finer behaviour (focus follow, per-title indicators, multi-window grouping) can layer on top by translating the event into more specific app messages. The default app pays zero, the shell that opts in gets a real event stream without touching `smithay-client-toolkit` directly.
`Container::background` now stores `Option<Paint>` instead of `Option<Color>`, and the builder accepts `impl Into<Paint>` so callers can pass a plain `Color` (auto-wrapped in `Paint::Solid` via the trait impl) or an explicit `LinearGradient` / `RadialGradient`. `layout_and_draw` switches from `canvas.fill_rect( rect, bg, corners )` to `canvas.fill_paint_rect( rect, &bg, corners )` to consume the wider type.
No behaviour change for existing call sites — solid-colour containers keep working unchanged thanks to the `Into<Paint>` for `Color`.
Previously only `set_min_size()` was called with the requested size, on the assumption that the compositor would adopt it as the initial dimension. In practice that doesn't hold: xdg-shell has no "preferred initial size" primitive and compositors are free to pick any size within `[min, max]` on the first configure. The window ended up opening at the 800x600 fallback instead of the size the application had asked for.
The fix pins `min == max` before the first commit, which forces the compositor to honour the requested size in its first configure, and then releases `max_size` from the configure handler via the `pending_size_hint_unpin` latch so the surface remains user-resizable afterwards. The 800x600 fallback now only applies when the application does not provide a hint.