Commit Graph

21 Commits

Author SHA1 Message Date
yamabush1
f7ef932976 list_item: optional leading icon
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Add a leading-icon slot to ListItem so settings-style rows can pair
a 24 px symbolic glyph with the label. `.icon( rgba, w, h )` takes
the same shape the rest of the toolkit uses for raw pixmaps; the
draw path reserves `ICON_SIZE + ICON_GAP` on the left and shifts
the label / subtitle text origin so existing icon-less rows render
unchanged.
2026-05-25 09:00:00 +02:00
24f4d2703a ltk: introduce viewport-relative Length so any size, padding, spacing or font height can scale with the surface instead of being frozen at a px constant, fix text::preferred_size to honour the font-declared line gap, and add a responsive typographic scale
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
The motivating bug was a lockscreen in a downstream app (eydos-loginmanager) where the clock at 87 px overlapped the date at 24 px on a Pinephone but not on a winit dev screen. The root cause split in two: the layout was wired with a single `f32` spacing constant that worked at the dev resolution and broke at the smaller one, and `text::Text::preferred_size` was returning `ascent - descent` for the line height — fontdue's terminology for "the minimum bounding box of an unaccented line", which deliberately drops the `line_gap` that every typographic renderer (Pango, CoreText, DirectWrite) reserves between adjacent rows. At Sora's 200/em line gap, an 87 px row was visually 17 px taller than the rect the column allocated for it; stacked tight against the row above, the descenders bled into the row below. This commit fixes both halves at the toolkit level so every consumer benefits without bolting on a per-screen `Sizing` helper in their own view code.
`types::Length` (with the `LengthBase` enum behind it) is the new currency for any "how big" or "how far apart" parameter. Six variants — `Px`, `Vw`, `Vh`, `Vmin`, `Vmax`, `Em` — cover the cases a real UI hits: absolute pixels for fixed-chrome decisions, viewport-relative percentages for sizes that have to survive a portrait/landscape rotation, and root-font-size multiples for typographic hierarchy. Optional `min_px` / `max_px` bounds attach to the same `Length` value via `.clamp( lo, hi )` (both ends), `.at_least( lo )` and `.at_most( hi )` (one-sided); the names are intentionally divergent from `f32::min`/`f32::max` to avoid being read with the opposite semantics (`x.min(24)` in std means "the smaller of x and 24", which is the inverse of what a min bound expresses). The bounds are stored as raw `f32` rather than nested `Length` values, which keeps `Length` `Copy` and avoids a `Box` allocation per widget per frame — the bounded-by-relative case (`Vmin(20).clamp(Vmin(10), Vmin(40))`) is rare enough that the trade is the right one. `From<f32>`, `From<i32>` and `From<u32>` are implemented so every legacy `.size( 24.0 )` / `.padding( 8.0 )` / `.spacing( 4.0 )` call keeps compiling unchanged; the migration is opt-in per call site. The `EM_BASE_DEFAULT = 16.0` constant matches `theme::typography::BODY` so `Length::em( 2.0 )` resolves consistently with the body-text default; a future change can thread a theme-supplied em base through without breaking the resolver shape.
The resolver — `Length::resolve( viewport: ( f32, f32 ), em_base: f32 ) -> f32` — runs at layout time against a viewport supplied by the renderer. `Canvas::viewport_logical()` is the new helper that exposes that viewport: it divides the canvas's physical size by `dpi_scale` and falls back to physical size when `dpi_scale <= 0.0`, guarding the misconfigured-canvas path so a Vmin call doesn't poison every downstream measurement with `NaN` or `inf`. The viewport is in **logical** pixels — matching what every wayland `xdg_toplevel.configure` event already hands the client — so `Length::vmin( 18.0 )` on a 360×720-logical Librem 5 portrait surface resolves to 64.8 px and the same expression on a 1600×900 dev screen resolves to 162 px, automatically.
Every widget setter that took an `f32` size, padding, spacing, max-width, or fixed dimension now takes `impl Into<Length>` and stores the value as `Length`:
- `widget::text::Text::size( impl Into<Length> )`; the `size` field is now `Length`. `Text::resolved_size( &Canvas )` is the internal accessor that every measurement / drawing path routes through, so the field can stay `Length` without churning the call sites. `preferred_size` and `draw` now read `new_line_size = ascent - descent + line_gap` from fontdue's `LineMetrics` (the fix for the original bug) — the baseline placement is unchanged, only the row height grows by the font's declared leading, which is what every stacked layout was implicitly relying on.
- `layout::Spacer::height( impl Into<Length> )` / `.width( impl Into<Length> )`; `fixed_height` / `fixed_width` are now `Option<Length>`. New `resolved_height( &Canvas )` / `resolved_width( &Canvas )` helpers replace the direct `s.fixed_height.unwrap_or( 0.0 )` reads in `layout::column`, `layout::row` and `layout::stack`. `Spacer::preferred_size` grows a `&Canvas` parameter for the same reason; `Element::preferred_size` passes the canvas through.
- `layout::Column::spacing` / `.padding` / `.max_width`, `layout::Row::spacing` / `.padding` — all take `impl Into<Length>` and store `Length`. Internal `resolved_spacing( &Canvas )`, `resolved_padding( &Canvas )`, `resolved_max_width( &Canvas )` helpers funnel every read, so the layout code paths stay readable. The column's `inner_w` private helper picks up a `&Canvas` argument; the test that used it directly is updated.
`theme::typography` keeps its historic `f32` constants (`H0`…`BODY_XS`, plus `LINE_HEIGHT`) so the migration is gradual, and adds a parallel responsive scale exposed as functions returning `Length`: `h0()`, `h1()`, `h2()`, `h3()`, `body()`, `body_s()`, `body_xs()`. Each is a `Length::vmin( pct ).clamp( min_px, max_px )` whose percentage is calibrated against a 1000-px smaller side reproducing the legacy px constant exactly, and whose px clamps protect both ends of the spectrum — a 360-px Pinephone hits the lower clamp on the larger headings, a 4K desktop hits the upper one. The tests in `theme::typography` exercise all three regimes (narrow phone, calibration point, large display) so future drift in the percentages or clamps is caught immediately.
`Canvas::viewport_logical` is the only render-surface API touched. None of the existing per-frame paths (`draw_text`, `measure_text`, `font_line_metrics`) change shape, so backends and external embedders aren't disturbed. The `dpi_scale` accessor already existed; this commit only adds the convenience that ratios it against the surface size to return the unit layout actually wants.
Test coverage rounds out the addition rather than just smoke-testing the happy path: 22 new tests, broken down as `types::length_tests` (7 — every variant, clamp with relative value, clamp with swapped bounds, `From<f32>`), `render::viewport_tests` (3 — scale 1, scale 2, scale 0 fallback), `theme::typography::tests` (3 — phone-clamped, calibrated, 4K-clamped), `layout::spacer::tests` (4 — px height, vmin height, vw width, flex spacer reports `None`), `layout::column::tests` (3 new — vmin spacing accumulates, vmin padding, vmin max-width caps inner-w), `layout::row::tests` (2 new — vmin padding, vmin spacing produces correct visible gap between non-flex children regardless of the row's centering anchor), and `widget::text::tests` (3 updated/new — defaults compare against `Length::px(16.0)`, `.size( f32 )` and `.size( Length )` both verified). The existing integration test in `tests/layout_stack_spacer.rs` is updated to call `Spacer::preferred_size( &canvas )` and compare `fixed_height` / `fixed_width` against `Some( Length::px( n ) )`.
Documentation is updated end-to-end so the new API is discoverable from `cargo doc` without grepping the source: `lib.rs` gets a new entry for `Length` under the **Types** section and a new **Designing for multiple resolutions** section that lists the three patterns (relative `Length` for sizing, responsive typography for hierarchy, `view()`-level branching on surface dimensions only when the structure itself must change). `Canvas::viewport_logical` ships with a runnable `assert_eq!` example covering the scale-2 case. The module-level docstrings for `Spacer`, `Column` and `Row` now show both an `f32` example (legacy, still valid) and a `Length::vmin( ... ).clamp( ... )` example for the responsive variant — `cargo doc` renders both side by side so the upgrade path is obvious.
Out of scope for this commit, deliberate: `WrapGrid::spacing_x` / `spacing_y` / `padding`, `widget::text_edit::TextEdit::font_size`, and `widget::image::Image::size` still take `f32`. None of them are on a critical responsive path right now, the `From<f32>` shim means migrating later is a one-line setter signature change per widget, and keeping this commit focused on the widgets the lockscreen actually uses keeps the diff reviewable. The line-gap fix in `text::preferred_size` already benefits `TextEdit` indirectly because its caret/row math reads from the same metrics helpers.
2026-05-24 00:12:50 +02:00
88385e14b2 add Carousel widget and WrapGrid::centre_last_row
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
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.
2026-05-22 19:38:48 +02:00
0e52274053 app, event_loop: first-frame-committed hook and foreign_toplevel app_id only
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
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.
2026-05-21 01:51:19 +02:00
78a7ae151c layout/stack: opt-in Stack::fit_content() so a wrapping container can adopt the stack's intrinsic size
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
`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.
2026-05-20 15:26:41 +02:00
757063694e layout/stack: opt-in Stack::fit_content() so a wrapping container can adopt the stack's intrinsic size
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
`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.
2026-05-20 15:24:34 +02:00
640db23de2 doc: silence rustdoc intra-doc warnings (private-item links and palette/surface ambiguities)
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
`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.
2026-05-19 22:41:42 +02:00
a8bbd1e35c input/keyboard: fall through to App::on_key when the Enter dispatch target widget has no submit/press message
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
`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`.
2026-05-19 21:40:20 +02:00
yamabush1
9cc65e70ea fix(event_loop): walk error source chain for BrokenPipe in OtherError
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
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.
2026-05-18 20:48:35 +02:00
yamabush1
750eae7a93 fix(event_loop): exit cleanly on OtherError(BrokenPipe) from compositor
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.
2026-05-17 18:31:12 +02:00
4a80165428 event_loop, a11y, text_shaping: AccessKit AT-SPI2 bridge, cross-app clipboard, xdg-activation, HarfBuzz shaping, multi-touch hooks
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
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
4aa3480b64 refactor: split every monolithic module into focused submodules
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.
2026-05-15 23:46:56 +02:00
3d237039c6 input/keyboard: let the app intercept arrow / Tab keys before the default text-edit and focus-shift behaviours
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.
2026-05-15 00:40:01 +02:00
bfe27b6fef event_loop, widget, input: pointer-dwell tooltips, global drag coords, foreign-toplevel name cascade
`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.
2026-05-14 22:36:17 +02:00
821037f509 container, text, date_picker: width-aware sizing pass
`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 "...".
2026-05-13 19:38:41 +02:00
96f437544a event_loop: route take_focus_request to widgets on overlay surfaces
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.
2026-05-13 13:47:01 +02:00
c3839060cc theme, event_loop: window_controls.bar_bg slot + graceful exit on compositor disconnect
`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.
2026-05-13 10:17:36 +02:00
dc781fb78d event_loop: client binding for ext-foreign-toplevel-list-v1
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.
2026-05-11 22:30:29 +02:00
39fbafec24 container: generalise background from Color to Paint
`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`.
2026-05-11 12:20:28 +02:00
703a1ed228 event_loop: honour App::window_size_hint() on the first configure
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.
2026-05-10 23:16:17 +02:00
bbab5e238d First commit. Version 0.1.0 2026-05-10 09:58:23 +02:00