diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 36c0822..e3d4c30 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -56,10 +56,10 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Build - run: cargo build --workspace --all-targets + run: cargo build --workspace --all-targets --features test-support - name: Test - run: cargo test --workspace --all-targets + run: cargo test --workspace --all-targets --features test-support # External-Markdown doctests. `cargo test --doc` only sees doctests # inside `src/`; the cookbook and widget reference under `docs/` diff --git a/CHANGELOG.md b/CHANGELOG.md index 589ae8e..653ed84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,24 +8,34 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a - **`ListItem::trailing_icon( rgba, w, h )`** — right-aligned icon slot (disclosure arrow) drawn at `TRAILING_ICON_SIZE` (21 px) and vertically centered, alongside the existing leading `icon`. Coexists with `trailing` text, which shifts to the icon's left. Symbolic icons should be pre-tinted by the caller (`tint_symbolic`), matching the leading-icon contract. - **`ListItem::pad_h( impl Into )`** — per-item override of the horizontal inset between the row edge and its content; without it the theme default (16 px) applies. - +- **`Row::align_top()` / `Row::fill_height()`** — pin children to the row's top edge instead of the default vertical centering, or stretch every non-spacer child to the row's inner height (the row itself is still sized by its tallest child), for siblings whose natural heights differ by a few font-metric pixels. +- **`App::window_resizable()`** — return `false` to keep the `min_size == max_size` pin from `window_size_hint` for the toplevel's lifetime, declaring a fixed-size window compositors must not resize. Default `true`. +- **`App::claims_raw_touch()`** — return `true` to receive the primary finger through the raw `on_touch_down` / `move` / `up` stream, bypassing the built-in single-slot gesture machine entirely (widget presses, taps and swipes stop working on touch), for surfaces that are one self-contained input consumer such as an embedded WebView or a game canvas. - **Responsive sizing system** with two selectable modes via `WidgetScaling` (`Fluid` / `Physical`; `set_widget_scaling` / `widget_scaling`, default `Fluid`). New `Length` constructors — `orient( portrait, landscape )` (a percentage of the width in portrait, of the height in landscape), `fluid( px )` (surface-proportional, calibrated against `set_fluid_reference` and bounded by `FLUID_MIN` / `FLUID_MAX`), `dp( px )` (constant physical size scaled by `set_density` / `density`), and `widget( px )` (picks fluid or dp per the active mode). `Canvas::geom_px` (geometry, physical layout space) and `Canvas::font_px` (font, bridging the logical / physical split per mode) give widgets and apps one resolution path. - **`Button::font_size` / `height` / `width`** and **`TextEdit::height`** builders, all `impl Into`, so control boxes scale with the surface. `Text::line_height( mult )` opens the gap between wrapped lines. `Separator::pad_v` (with `Length::px( 0.0 )` for a flush divider). - **Performance guardrails**: opt-in diagnostics via `LTK_PERF_WARN=1` (stuck animation, sustained software-render animation, low `poll_interval`) and a ~30 Hz software-animation cap overridable with `App::cap_software_animation`. +- **`ltk::orientation()` / `viewport_size()` / `set_viewport_size`** and the `Orientation` enum — the runtime records the main surface's physical dimensions on every configure, so `view()` can branch a layout on portrait vs landscape (`match ltk::orientation() { … }`) without tracking `on_resize` by hand; the portrait/landscape rule matches `Length::orient` (square counts as portrait). Embedders driving `core::UiSurface` directly call `set_viewport_size` themselves. `examples/clip_path.rs` demonstrates it. - **`test-support` Cargo feature** gates the `test_support` module so third-party builds never see it (ltk's own `make test` enables it). ### Changed - **`OverlaySpec::size`** is now `( Length, Length )` (was `( u32, u32 )`), resolved against the main surface when the overlay is materialized; wrap existing sizes in `Length::px( … )` for the old fixed behaviour. -- **`TextEdit::font_size`** and **`Separator::thickness` / `pad_v`** now take `impl Into` (were `f32`), resolved like the button label (font space) / geometry space; the `f32` sentinels are gone (`f32` call sites still compile via `Into`). +- **`TextEdit::font_size`**, **`TextEdit::fixed_width`** and **`Separator::thickness`** now take `impl Into` (were `f32`), resolved like the button label (font space) / geometry space; the `f32` sentinels are gone (`f32` call sites still compile via `Into`). +- **Dependency pins**: `fontdue = "=0.9.3"` and `ignore = "=0.4.23"` are pinned exactly because newer releases require a rustc newer than the declared Rust 1.85 MSRV (Debian stable's toolchain). - **Renamed** `set_design_reference` / `design_reference` → `set_fluid_reference` / `fluid_reference`. **`Length::dp` changed meaning** — it used to be a surface-proportional value, and that behaviour now lives on `Length::fluid`; `dp` is the constant-physical-size unit. - **Widget struct fields are now `pub( crate )`** (configured through builders), except the value / state types apps read or construct (`Time`, `Date`, `ComboState`). ### Fixed +- **Text inputs no longer insert mid-string when the value grows after focus.** Focusing a text input pinned the cursor to a snapshot of `value.len()`; if the value kept growing without the widget seeing keystrokes (a field fed over IPC), the first normally-delivered key inserted at the stale position. The focus-time cursor is now an end-of-value sentinel that every consumer clamps to the current value length, collapsing to a concrete position on the first real keystroke or click. +- **Single-line caret height now follows the text line.** The caret spanned `rect.height - 16`, so a field taller than its text line grew an oversized caret; it now measures `font_size + 4` and is vertically centered like the text, matching the multiline caret. +- **Dialog action buttons no longer overflow the card on large surfaces.** The card's width cap was a fixed 480 px while the stock buttons inside grow fluidly with the surface, so on windows past the design size the right-aligned action row ran off the card's right edge. `Dialog::max_width` now takes `impl Into` (f32 call sites keep compiling as fixed px) and the default is `Length::fluid( 480.0 )`, matching the buttons' scaling curve. +- **`App::on_pointer_axis` now triggers a view rebuild and repaint.** The raw axis hook (wheel/touchpad outside any ltk scroll viewport) fired the app callback but never requested a redraw, so state mutated there (a wheel-stepped carousel, an embedder-scrolled canvas) did not repaint until the next unrelated event. +- **Dialog cards no longer double-pad their interior.** The inner card column carried the `column()` 16 px default padding on top of the container's `CARD_PADDING` (24 px), giving the content 40 px of interior inset per side and squeezing the action row until its buttons clipped on narrow windows. The inner column is now flush and the interior inset is `CARD_PADDING` alone; the centering column's margin to the surface edges is explicit. +- **Time-picker digits no longer overflow their boxes.** The editable digit fields paired a fluid font (`font_size_fluid`) with a fixed 72 px box, so on any surface where the fluid font resolved larger than its design size the glyphs outgrew the field. The box is now `Length::fluid( 72.0 )` — the same curve and clamp factors as the font, so both scale in lockstep. - **Content inside a `scroll` no longer renders smaller than the rest of the surface.** Scroll viewports draw their child into a sub-canvas sized to the viewport rect, and fluid `Length` resolution (`Canvas::geom_px` / `font_px` — icon sizes, row heights, paddings, font sizes) resolved against that smaller canvas instead of the surface, shrinking everything inside any scroll by the width ratio (~13 % in a 360 px window with 24 px margins). Sub-canvases now inherit the root canvas's layout viewport (`viewport_layout` / `viewport_logical`), propagated through nested sub-canvases, so geometry resolves identically inside and outside offscreen content. -## [0.2.0] — 2026-06-25 +## [0.2.0] - 2026-06-25 This release adds the primitives an embedder needs to drive ltk as the render backend for a retained, externally-owned widget tree (for example projecting an Android view hierarchy onto an ltk surface). Each is kept general rather than tied to one consumer. @@ -49,9 +59,12 @@ This release adds the primitives an embedder needs to drive ltk as the render ba - Default theme launcher SVGs replaced with renderer-compatible versions. -## [0.1.0] — 2026-03-10 +## [0.1.0] - 2026-03-10 + +### Added - Initial release. +[Unreleased]: https://github.com/liberux/ltk/compare/v0.2.0...HEAD [0.2.0]: https://github.com/liberux/ltk/releases/tag/v0.2.0 [0.1.0]: https://github.com/liberux/ltk/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ad28d3..622f5cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,20 +37,29 @@ sudo apt-get install \ git clone cd ltk cargo build -cargo test +cargo test --features test-support ``` +Tests require the `test-support` feature: the `test_support` module is +feature-gated in `src/lib.rs`, and the code under `tests/` and +`benches/` imports it, so a bare `cargo test` fails to compile. + The `Makefile` wraps the common targets: ```bash make all # cargo build --release -make test # cargo test +make test # cargo test --features test-support +make doctest-md # typecheck the Rust snippets in docs/*.md make audit # cargo audit (installs cargo-audit on first run) make doc # cargo doc --no-deps -make example # run every example under examples/ in turn -make clean +make examples # run every example under examples/ in turn +make clean # runs dh_clean, so it needs debhelper installed ``` +Run `make doctest-md` after editing any file under `docs/` — it feeds +each markdown file to `rustdoc --test` to catch API drift in the +snippets. + Running the examples requires a Wayland session and the default theme on disk: @@ -83,7 +92,7 @@ Match the surrounding code when in doubt. - Keep changes focused: one logical change per pull request. A bug fix and a refactor go in separate PRs. -- Add tests. The repository has ~400 tests covering the existing +- Add tests. The repository has ~680 tests covering the existing surface, and a contribution that adds behaviour without test coverage will get review pushback. See [`tests/`](tests/) for examples of integration tests via `UiSurface`, and the inline @@ -96,20 +105,23 @@ Match the surrounding code when in doubt. crate hits `1.0`, breaking changes go in minor versions (`0.x.0 → 0.(x+1).0`); patch versions (`0.x.y → 0.x.(y+1)`) keep source compatibility. -- Run `cargo test` and `make audit` before sending. CI will run them +- Give user-visible changes a `CHANGELOG.md` entry under + `[Unreleased]`. +- Run `cargo test --features test-support` and `make audit` before + sending. CI will run them again, but it is faster for both of us if your local run is clean. ## Architectural decisions worth knowing A few patterns recur across the codebase: -- **Builder methods consume `self`** (`pub fn padding( mut self, p: f32 ) -> Self`). +- **Builder methods consume `self`** (`pub fn padding( mut self, p: impl Into ) -> Self`). Chaining works because every builder returns `Self`. Don't introduce setters that take `&mut self`. - **Layouts and widgets share `Element`.** Anything that converts to `Element` can be pushed into any layout. The split between - [`crate::layout`] and [`crate::widget`] is documentation, not - architecture. + [`src/layout/`](src/layout/) and [`src/widget/`](src/widget/) is + documentation, not architecture. - **The runtime is single-threaded.** Use `RefCell` for caches inside `App` state, never `Mutex`. Cross-thread communication goes through [`ChannelSender`](src/app.rs). @@ -119,7 +131,7 @@ A few patterns recur across the codebase: - **Theming is process-global.** There is no per-app theme; the active document and mode live in a `RwLock>`. `view()` reads the state, never writes it. Mode flips and document swaps go through - [`crate::set_active_mode`] and [`crate::set_active_document`]. + `ltk::set_active_mode` and `ltk::set_active_document`. - **Per-frame allocations are fine.** Building the `Element` tree on every render is the supported model. Don't try to retain widgets across frames. diff --git a/Makefile b/Makefile index e284cc4..fc2cedc 100644 --- a/Makefile +++ b/Makefile @@ -46,6 +46,7 @@ examples: LTK_THEMES_DIR=themes cargo run --release --example pickers LTK_THEMES_DIR=themes cargo run --release --example dialog LTK_THEMES_DIR=themes cargo run --release --example carousel + LTK_THEMES_DIR=themes cargo run --release --example clip_path clean: dh_clean diff --git a/README.md b/README.md index d80d990..a89dfd1 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ Add `ltk` to your `Cargo.toml`: ltk = { path = "../ltk" } ``` +If you consume `ltk` from outside this workspace, replace the `path` dependency with a versioned one. + Minimal app: ```rust @@ -183,6 +185,8 @@ Useful entry points in this repository: - `cargo run --example dialog` - `cargo run --example sliders` - `cargo run --example pickers` +- `cargo run --example carousel` +- `cargo run --example clip_path` - `cargo run --example mini_shell` In general: @@ -199,7 +203,7 @@ Most applications should start with this subset: - `App` - `Element` -- widgets such as `button`, `text`, `text_edit`, `image` +- widgets such as `button`, `text`, `text_edit`, `img_widget` - layouts such as `column`, `row`, `stack`, `grid`, `spacer` - `Color` - `run` @@ -265,9 +269,11 @@ The main rules for downstream applications are: - keep `view()` pure and cheap - do not perform I/O inside `view()` - use `poll_interval()` sparingly -- return `true` from `is_animating()` only while something is actually moving +- return `true` from `is_animating()` only while something is actually moving — note that on the software backend animation redraws are capped at ~30 Hz by default (override with `App::cap_software_animation`) - cache decoded images and expensive derived state in your app +Set `LTK_PERF_WARN=1` during development for opt-in diagnostics: it warns about stuck animations, sustained software-render animation, and low `poll_interval` values. + The library already provides: - event-driven redraw scheduling @@ -279,8 +285,11 @@ The library already provides: The public API is the same across backends, but visual parity is not perfect yet. The widget tree, layout, hit-testing, text, images, fills, strokes and -clipping all paint identically on both paths. The gaps are in gradients and the -shadow / backdrop pipeline. +single-rect / path clipping all paint identically on both paths. The gaps are in +gradients, the shadow / backdrop pipeline, and multi-rect clipping: the GLES +backend installs the bounding-box union of the rects as its `glScissor` — a +coarse clip that does not cull pixels between disjoint rects — while the +software backend clips each rect exactly. Effects that currently render only on the **GLES** backend, and degrade on the **Software** backend: @@ -311,7 +320,7 @@ LTK_FORCE_SOFTWARE=1 cargo run --example showcase ``` Closing this gap (porting the shadow / inset-shadow pipeline to tiny-skia) is -on the post-v0.1 roadmap. +on the roadmap. ## Documentation @@ -323,6 +332,8 @@ on the post-v0.1 roadmap. | [`docs/theming.md`](docs/theming.md) | JSON theme schema, slot conventions, runtime APIs. | | [`docs/cookbook.md`](docs/cookbook.md) | Concrete recipes — slide-in panels, password fields, runtime theme toggle, channel-driven state, embedding without `ltk::run`. | | `cargo doc --open` | Per-item rustdoc for the public API. | +| [`CHANGELOG.md`](CHANGELOG.md) | What changed in each release, and what is pending unreleased. | +| [`code_style_guide.md`](code_style_guide.md) | The Modified Allman style rules the codebase follows. | | [`SECURITY.md`](SECURITY.md) | How to report a vulnerability and what is in / out of scope. | | [`CONTRIBUTING.md`](CONTRIBUTING.md) | Build, test, code style, patch shape. | diff --git a/docs/architecture.md b/docs/architecture.md index a0aa5e8..68cd121 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,12 +4,12 @@ If you are new to the library, start with [`docs/onboarding.md`](./onboarding.md first. This document assumes you already know how to run an example and what kind of application surface you are trying to build. -This document covers the patterns that the small `examples/` files cannot show: how a real application is structured on top of the [`App`] trait, how multiple surfaces coordinate, how theming is consumed, how to build animations, and where the cost of a frame actually lives. +This document covers the patterns that the small `examples/` files cannot show: how a real application is structured on top of the `App` trait, how multiple surfaces coordinate, how theming is consumed, how to build animations, and where the cost of a frame actually lives. For copy-pasteable patterns the canonical references are the two downstream consumers in the Eydos workspace: - **crustace** (`crustace/src/`) — the Eydos shell. Layer-shell background surface + 8 overlays, system polling, MPRIS, notifications, animated OSD. -- **loginmanager** (`loginmanager/src/`) — greeter. `keyboard_exclusive`, single overlay, focus management, async PAM via `set_channel_sender`. +- **loginmanager** (`loginmanager/crates/lockscreen/src/`) — greeter / lock screen. `keyboard_exclusive`, focus management, a subsurface-driven reveal animation, async PAM on a worker thread drained in `poll_external`. The rest of this document explains *why* those repos look the way they do. @@ -23,9 +23,33 @@ This document mostly lives in the overlap between `ltk::shell` and `ltk::runtime`. If you only want to build a plain app window, stay with `docs/onboarding.md` and the `ltk::window` surface first. +## Module map + +Where things live under `src/`, one line each: + +- `a11y/` — AccessKit tree building and the AT-SPI2 bridge. +- `app.rs` — the `App` trait, `OverlaySpec`, `SubsurfaceSpec`, `run` / `try_run`. +- `chassis.rs` — scaffolding for full-screen ambient surfaces (greeter, lock screen, kiosk): theme bring-up, branding/wallpaper loading, the wallpaper-backed view stack. +- `core.rs` — `UiSurface`, runtime-free embedding. +- `draw/` — the per-frame drawing pipeline shared by both backends. +- `egl_context.rs` — EGL bootstrap for the GPU path. +- `event_loop/` — the Wayland run loop: frame scheduling, invalidation, clipboard / data device, text editing and IME, tooltips, focus, subsurfaces, perf guardrails. +- `gles_render/` — GPU backend (EGL + GLES2 / GLES3). +- `input/` — pointer, keyboard and touch handling, the gesture machine, dispatch. +- `layout/` — composable arrangers for `Element` trees. +- `render/` — software rendering surface used by every widget. +- `secure_mem.rs` — volatile wipe of secret buffers behind `TextEdit`'s secure mode. +- `system_fonts.rs` — primary-font resolution and the per-glyph fallback chain. +- `text_shaping.rs` — BiDi reordering and rustybuzz (HarfBuzz) shaping. +- `theme/` — theme documents, slot stores, the embedded fallback. +- `tree.rs` — element-tree traversal helpers. +- `types.rs` — geometry and primitive value types (`Length`, `Color`, `Rect`, …). +- `wallpaper.rs` — orientation-aware wallpaper helper. +- `widget/` — the widget set. + ## Mental model -ltk is Elm-shaped. The application is a value implementing [`App`]; ltk drives the loop and the application reacts. +ltk is Elm-shaped. The application is a value implementing `App`; ltk drives the loop and the application reacts. Every frame: ltk calls `view()` and `overlays()`, lays out the returned tree(s), draws them, and dispatches input events back as `Message` values which are fed to `update()`. There are no retained widgets. `Element` is rebuilt from scratch every frame from the application's own state. @@ -72,23 +96,32 @@ In practice, that model is easiest to adopt in three steps: **Implement for animations and focus** -- `is_animating()` — return `true` while a tween is running; the loop redraws at ~60 Hz. +- `is_animating()` — return `true` while a tween is running; the loop redraws on every compositor frame callback (~60 Hz on a typical display, capped at ~30 Hz on the software backend — see *Performance*). - `take_focus_request()` → `Option` — pull-once focus retargeting. - `on_text_input_focused(active)` — surface IME state. +**Implement for window / toplevel lifecycle** + +- `window_config()` — deprecated forced-window escape hatch; prefer `shell_mode()`. +- `on_close_requested()` — return `false` to veto a close (compositor request, titlebar button, layer-shell closed event). +- `on_toplevel_event(event)` — open / close notifications from `ext-foreign-toplevel-list-v1`, keyed by a stable handle id. +- `requested_exit()` — polled after every batch of `update`s; return `true` to tear the surface down and exit the loop (a `SessionLock` surface is unlocked first). + +Relatedly, `ltk::try_run( app )` is the fallible variant of `ltk::run` — it returns a `RunError` (no Wayland connection, missing protocol) instead of aborting, for apps that want a CLI fallback or a clean diagnostic. + The defaults for everything else are sensible enough that a minimal app overrides only the four methods in the first group. Another way to read the trait is by API layer: - `ltk::window`: `view`, `update`, plus the widgets/layouts you use to build the tree. - `ltk::shell`: `shell_mode`, `layer_anchor`, `layer_size`, `exclusive_zone`, `keyboard_exclusive`, `overlays`. -- `ltk::runtime`: `set_channel_sender`, `poll_external`, `poll_interval`, `invalidate_after`, `take_focus_request`, `is_animating`, and `core::UiSurface`. +- `ltk::runtime`: the module's actual re-exports — `ChannelSender`, `InvalidationScope`, `SurfaceTarget`, the runtime theme state (`active_document`, `set_active_mode` and the `theme_*` accessors) and the embedding surface `core::UiSurface`. The trait hooks that pair with them (`set_channel_sender`, `poll_external`, `poll_interval`, `invalidate_after`, `take_focus_request`, `is_animating`) live on `App` itself. That is the intended order of adoption for third-party users. ## Surface composition -The main surface is what `view()` paints. `overlays()` returns a `Vec>` describing additional layer-shell surfaces that should exist this frame. The runtime diffs that list against the previous frame using [`OverlayId`]: +The main surface is what `view()` paints. `overlays()` returns a `Vec>` describing additional layer-shell surfaces that should exist this frame. The runtime diffs that list against the previous frame using `OverlayId`: - Same id present last frame and this frame → keep the surface alive, only re-render its `view`. - New id → create a new layer-shell surface. @@ -96,7 +129,7 @@ The main surface is what `view()` paints. `overlays()` returns a `Vec/theme.json`). Each document carries a `light` and `dark` [`Mode`] with a typed [`SlotStore`] (colors, paints, shadows, surfaces, text styles), wallpaper/lockscreen/launcher specs and a shared `fonts` block. When the `default` document cannot be located ltk falls back to an embedded B/W theme + embedded Sora Regular font, logs a stderr warning, and stamps every frame with a red banner pointing at the `ltk-theme-default` Debian package so the missing-theme signal is visible without the process aborting. `ltk::is_fallback_active()` exposes the state for apps that want to react programmatically. -2. **Mode** — [`ThemeMode::Light`] or `Dark`; flips which mode of the document is active. +1. **Document** — a `ThemeDocument` loaded from disk (`/usr/share/ltk/themes//theme.json`). Each document carries a `light` and `dark` `Mode` with a typed `SlotStore` (colors, paints, shadows, surfaces, text styles), wallpaper/lockscreen/launcher specs and a shared `fonts` block. When the `default` document cannot be located ltk falls back to an embedded B/W theme + embedded Sora Regular font, logs a stderr warning, and stamps every frame with a red banner pointing at the `ltk-theme-default` Debian package so the missing-theme signal is visible without the process aborting. `ltk::is_fallback_active()` exposes the state for apps that want to react programmatically. +2. **Mode** — `ThemeMode::Light` or `Dark`; flips which mode of the document is active. 3. **Active state** — `ltk::active_document()` / `ltk::active_mode()` return the current pair. Per-slot shorthands (`ltk::theme_color`, `theme_paint`, `theme_shadows`, `theme_surface`, `theme_text_style`, `theme_palette`, `theme_window_controls`, `theme_wallpaper`, `theme_lockscreen`) cover the common patterns. Inside a widget tree, read the palette through the per-slot helper: @@ -164,6 +199,8 @@ Stock widgets do not hard-code either strategy. Each carries a design pixel per Both `density()` and `widget_scaling()` are process globals read during layout; set them at startup (or, for density, whenever the surface moves to an output with a different DPI). Because they are global, ltk's own test suite serialises the tests that touch them. +`Length` adapts *sizes* to the orientation; to adapt the *structure* of a layout (a row of panels in landscape, the same panels stacked in portrait), branch the view on `ltk::orientation()`. The runtime records the main surface's physical dimensions on every configure (also readable as `ltk::viewport_size()`) and rebuilds the view after each resize, so a `match ltk::orientation() { Landscape => row()…, Portrait => column()… }` follows the window live. The portrait/landscape rule matches `Length::orient` (a square surface counts as portrait). `examples/clip_path.rs` shows the pattern. + ## Animations The render loop is event-driven by default: it sleeps until input arrives, a `poll_interval` ticks, or `set_channel_sender` is woken from a thread. To run a tween, override `is_animating()`: @@ -179,7 +216,7 @@ fn is_animating( &self ) -> bool # } ``` -While `is_animating()` returns `true`, ltk redraws at ~60 Hz. Do *not* mutate state in `view()`; instead read `Instant::now()` against a stored start time and compute the tween value: +While `is_animating()` returns `true`, ltk redraws on every compositor frame callback — ~60 Hz on a typical display with the GLES backend, capped at ~30 Hz on the software backend by default (see *Performance*). Do *not* mutate state in `view()`; instead read `Instant::now()` against a stored start time and compute the tween value: ```rust,no_run # use std::time::Instant; @@ -213,7 +250,7 @@ core onboarding path. A four-button demo can keep all state in one struct and one flat `Msg` enum. Anything bigger needs structure. Conventions used by crustace and loginmanager: -**One module per screen / panel.** Each module owns its sub-state struct and its sub-message enum, and exposes `fn view(...) -> Element` and `fn update(&mut self, msg: SubMsg)` (or the parent inlines those calls). See `crustace/src/homescreen.rs`, `launcher/`, `notifications.rs`, `powermenu.rs`. +**One module per screen / panel.** Each module owns its sub-state struct and its sub-message enum, and exposes `fn view(...) -> Element` and `fn update(&mut self, msg: SubMsg)` (or the parent inlines those calls). See `crustace/src/homescreen/`, `launcher/`, `quicksettings/`, `powermenu.rs`. **Wrap sub-messages in the top-level enum.** `enum AppMsg { Home(HomeMsg), Settings(SettingsMsg), Nav(Route), Tick }`. `update()` matches the outer variant, then forwards to the right sub-module. This avoids one-giant-message-enum bloat once the app passes ~30 variants. @@ -235,53 +272,53 @@ The cheap things and the expensive things, in rough order: - *Cheap*: building the `Element` tree. It's plain enums and `Vec`s. crustace rebuilds the entire shell every frame and stays idle when nothing changes. - *Cheap*: input dispatch. Per-leaf handler snapshots are captured during the layout pass; pointer/key events are O(N_focusable_leaves) lookups, not tree walks. -- *Cheap*: `active_document()` / `theme_palette()`. The first returns a clone of an `Arc` from a `RwLock`-protected cell; the second projects the active mode's slot table onto the eight canonical palette fields. +- *Cheap*: `active_document()` / `theme_palette()`. The first returns a clone of an `Arc` from a `RwLock`-protected cell; the second projects the active mode's slot table onto the ten canonical palette fields. - *Avoid in `view()`*: filesystem walks, image decoding, `serde` parsing, regex compilation. Cache the result on the app struct (behind `RefCell` if needed) and look it up. - *Avoid in `view()`*: cloning large `Vec` image buffers. `img_widget` takes an `Arc>`; build the `Arc` once at load time and clone *the Arc*, not the bytes. -- *Avoid `is_animating() = true` when nothing is moving.* It pegs the loop at 60 Hz and burns battery on the mobile target. +- *Avoid `is_animating() = true` when nothing is moving.* It pegs the loop at the full animation rate and burns battery on the mobile target. - *Lower `poll_interval()` is not free.* Crustace polls every 30 s because the clock only shows HH:MM. If your UI shows seconds, `Some(Duration::from_secs(1))` is fine; if it shows nothing time-sensitive, leave it `None`. - *Scroll viewports own a sub-canvas.* They're slightly more expensive to draw than a plain column. Use them when you need clipping or actual scrolling, not as a wrapper. - *GPU vs software*: the GLES path is selected automatically when EGL is available, with no API-level difference for the application. The two backends are not yet pixel-identical — gradients and the shadow / backdrop pipeline degrade under software; the authoritative list of gaps is the README's *Backend Differences* section. When a redraw feels sluggish: add a one-line print at the top of `view()` and confirm it's not being called more often than expected. The single most common mistake is leaving `is_animating()` returning `true` after the animation finished. -**Runtime guardrails.** The rules above are the app's responsibility, but the runtime also helps catch and blunt the common footguns. Set `LTK_PERF_WARN=1` to get one-shot stderr diagnostics during development when `is_animating()` stays true for 10 s (a settled animation that forgot to return `false`), when `poll_interval()` is under 100 ms (defeats the idle model), or when the software backend animates continuously for seconds (mobile CPU sink). Independently, animation on the **software** renderer is capped to ~30 Hz by default — GLES is never capped — since 60 fps software rasterization is a battery drain with no GPU offload; override [`App::cap_software_animation`] to keep the full rate. These live in `event_loop/perf.rs`. +**Runtime guardrails.** The rules above are the app's responsibility, but the runtime also helps catch and blunt the common footguns. Set `LTK_PERF_WARN=1` to get one-shot stderr diagnostics during development when `is_animating()` stays true for 10 s (a settled animation that forgot to return `false`), when `poll_interval()` is under 100 ms (defeats the idle model), or when the software backend animates continuously for seconds (mobile CPU sink). Independently, animation on the **software** renderer is capped to ~30 Hz by default — GLES is never capped — since 60 fps software rasterization is a battery drain with no GPU offload; override `App::cap_software_animation` to keep the full rate. These live in `event_loop/perf.rs`. For layout problems rather than performance ones, `LTK_DEBUG_LAYOUT=1` outlines every laid-out widget rect in red so misplaced or zero-sized boxes are visible at a glance. ## Where to look in the consumer repos | Pattern | File | | --- | --- | -| Multi-overlay coordination, overlay id constants | `crustace/src/app.rs` (`overlays()`, lines ~250–380) | +| Multi-overlay coordination, overlay id constants | `crustace/src/app.rs` (`overlays()`, line ~126) | | Background poller + channel sender | `crustace/src/app.rs` (`set_channel_sender`, `poll_external`) | -| Sub-module per screen | `crustace/src/{homescreen.rs, notifications.rs, powermenu.rs, launcher/}` | +| Sub-module per screen | `crustace/src/{homescreen/, launcher/, quicksettings/, powermenu.rs}` | | Cached icon loading via `RefCell` | `crustace/src/launcher/icon_cache.rs` and use sites in `app.rs` | -| OSD overlay with auto-expiry | `crustace/src/app.rs` (`show_osd`, `build_osd`, `OSD_TIMEOUT_SECS`) | -| `keyboard_exclusive` + `take_focus_request` | `loginmanager/src/main.rs` | +| OSD overlay with auto-expiry | `crustace/src/osd.rs` (`Osd::show` / `tick` / `view`) | +| `keyboard_exclusive` + `take_focus_request` | `loginmanager/crates/lockscreen/src/app.rs` | | Theme on disk (slot-typed JSON) | `ltk/themes/default/theme.json`, `ltk::ThemeDocument::find` | -For a self-contained example that exercises overlays, theme switching, and animation in one ~300-line file, see `examples/mini_shell.rs`. +For a self-contained example that exercises overlays, theme switching, and animation in one ~630-line file, see `examples/mini_shell.rs`. ## Known gaps and non-goals A short, honest list of what ltk does not currently provide. None of these are accidental — each is either deferred work or a deliberate non-goal. The list is here so that downstream consumers and audit reviewers know what to plan around without reading the source. -**AT-SPI2 / assistive technology bridge — wired through AccessKit, with composite widgets still flat.** Combo, Notebook tabs, DatePicker and TimePicker render as collections of inner widgets and currently expose those leaves individually (a combo trigger reads as "Button" + its caption, the popup items as `ListItem`s inside an overlay). Promoting them to their semantic roles (`ComboBoxMenuButton` with `Expanded` state, `TabList`/`Tab`/`TabPanel`, `Date`) needs each compound widget to declare an "outer rol hint" the layout pass can attach to the `LaidOutWidget` it pushes. Tracked separately. +**AT-SPI2 / assistive technology bridge — wired through AccessKit, with composite widgets still flat.** Combo, Notebook tabs, DatePicker and TimePicker render as collections of inner widgets and currently expose those leaves individually (a combo trigger reads as "Button" + its caption, the popup items as `ListItem`s inside an overlay). Promoting them to their semantic roles (`ComboBoxMenuButton` with `Expanded` state, `TabList`/`Tab`/`TabPanel`, `Date`) needs each compound widget to declare an "outer role hint" the layout pass can attach to the `LaidOutWidget` it pushes. Tracked separately. - ltk delegates the AT-SPI2 D-Bus protocol to [`accesskit_unix`]. After every layout pass, the runtime hands the platform adapter a fresh [`accesskit::TreeUpdate`] built from `widget_rects` (one accessible node per laid-out widget, plus a `Window` root). Buttons / toggles / checkboxes / radios / list items map to `Role::Button` / `Role::Switch` / `Role::CheckBox` / `Role::RadioButton` / `Role::ListItem`; sliders to `Role::Slider`; single- and multi-line text edits to `Role::TextInput` / `Role::MultilineTextInput`. Each interactive node advertises the `Click` and `Focus` actions, and inbound action requests (Orca pressing a button, switch-control moving focus) are translated into a synthetic press / focus on the matching widget the next iteration of the run loop. + ltk delegates the AT-SPI2 D-Bus protocol to `accesskit_unix`. After every layout pass, the runtime hands the platform adapter a fresh `accesskit::TreeUpdate` built from `widget_rects`: a `Window` root whose children are the main surface's widgets, with each overlay grouped under its own `Dialog` node and rich-text runs nested as `TextRun` children. Buttons / toggles / checkboxes / radios / list items map to `Role::Button` / `Role::Switch` / `Role::CheckBox` / `Role::RadioButton` / `Role::ListItem`; sliders to `Role::Slider`; single- and multi-line text edits to `Role::TextInput` / `Role::MultilineTextInput`; non-interactive labels, images, separators and progress bars surface as `Label` / `Image` / `Splitter` / `ProgressIndicator` nodes. Inbound action requests are translated into the matching widget message on the next iteration of the run loop: `Click` and `Focus` on every interactive node, `SetValue` on sliders and text edits, `Increment` / `Decrement` on sliders, and the scroll actions on scroll viewports. Live regions are wired too — `Container::live_region( true )` marks a subtree `Live::Polite` so status messages and OSDs announce themselves on appearance. -The integration is best-effort: when the AT-SPI2 daemon is not on the session bus (headless CI runners, locked-down compositors) the adapter creation returns `None` and the rest of the pipeline runs unchanged. The current cut covers the common cases — buttons, lists, form fields, dialogs — and intentionally leaves room for follow-up: +The adapter is constructed unconditionally — `accesskit_unix::Adapter::new` is infallible and simply stays inactive when no AT client is attached — and the tree is only built when AT-SPI2 is actually observing the application, so the pipeline costs nothing on headless runners. The current cut covers the common cases — buttons, lists, form fields, dialogs — and intentionally leaves room for follow-up: -- **Hierarchical nodes (groups, lists with explicit children)**: today the tree is flat. AccessKit supports nesting but the layout pass does not expose `Column` / `Row` / `Container` parents to the accessibility builder. Adding that requires either recording the nesting on `LaidOutWidget` or walking `Element` again from the a11y side. -- **Per-widget accessible label / description / `LabelledBy` relations**: the API to set them (`Button::accessible_name(...)`, etc.) is not exposed yet — labels currently fall back to the widget's tooltip text. Adding the builders is mechanical but touches every widget module. -- **Live regions**: status messages, notifications and OSDs that should announce themselves on appearance need `Live::{Polite, Assertive}` on the relevant nodes. Not wired in. -- **`Action::SetValue` for sliders and text inputs**: the inbound action handler does not yet translate these requests into the corresponding widget message. Adding them needs the same plumbing as `Click` / `Focus` but with payload extraction from `ActionData`. +- **Generic container nesting**: dialogs, text runs and the root do nest, but `Column` / `Row` / `Container` parents inside a surface are not represented — a surface's widgets are siblings. Adding that requires either recording the nesting on `LaidOutWidget` or walking `Element` again from the a11y side. +- **Per-widget accessible label / description / `LabelledBy` relations**: the internal `accessible_label` plumbing exists (labels are derived from the widget's own content, with the tooltip as last fallback), but the public builders to override them (`Button::accessible_name(...)`, etc.) are not exposed yet. Adding them is mechanical but touches every widget module. Downstream consumers shipping into regulated environments (EN 301 549, WCAG 2.1 AA, EAA) should still treat the integration as a starting point that needs a real audit with assistive technology users — the foundation is in place but the per-widget metadata work is what determines whether Orca actually reads a useful announcement. **Cross-application drag-and-drop — deferred.** The clipboard now bridges to the Wayland selection via `wl_data_device_manager` (see `event_loop/data_device.rs`), so Ctrl+C / Ctrl+V crosses application boundaries when the compositor advertises the global. Middle-click primary selection (`zwp_primary_selection_v1`) and inter-app drag-and-drop targets (drop-zone widgets that accept text / URI lists from outside the process) are still pending — they share most of the offer / source plumbing but need widget-level drop-target wiring on top. -**Multi-touch — deferred.** `input/touch/mod.rs` is single-slot by design today; a second finger overwrites the first. Pinch-zoom, two-finger scroll and gesture combos are out until the slot table lands. Tracked separately. +**Multi-touch — primary slot plus raw auxiliary fingers.** The first finger to land on a surface becomes its primary slot and drives the built-in gesture machine (swipe, scroll, long-press, drag). Additional fingers bypass the gesture machine and surface directly through `App::on_touch_down` / `on_touch_move` / `on_touch_up`, so apps can implement pinch-zoom or two-finger pan without losing the built-in gestures. Apps that want the whole stream raw — an embedded web view, a drawing canvas — return `true` from `App::claims_raw_touch`: every finger, primary included, then reports through `on_touch_*` and widget presses, taps and swipes never fire. What ltk itself does not provide is *recognition* of multi-finger gestures — pinch and rotate detection is the app's job on top of the raw stream. **HarfBuzz shaping — wired in.** `src/text_shaping.rs::shape_line` now drives both renderers: the line is BiDi-reordered, split into per-font sub-runs and shaped through rustybuzz. The glyph cache is keyed on `(glyph_id, size_bits, font_id)` and each glyph is rasterised by index via `fontdue::Font::rasterize_indexed`, so Arabic connected forms, Devanagari clusters and CJK shaped glyphs render correctly. -**xdg-activation-v1 and fractional scale — deferred.** Activation tokens (so an external launcher can raise an ltk window with focus) and `wp_fractional_scale_v1` (so 125 % / 150 % outputs render natively instead of via compositor downscale) are tracked as upcoming protocol work. +**xdg-activation-v1 — wired in.** Both directions work: a token found in `$XDG_ACTIVATION_TOKEN` at startup is used to activate the app's own window once it maps (so an external launcher can raise an ltk window with focus), and an app that spawns children requests fresh tokens through `App::take_activation_requests` and receives them via `App::on_activation_token` to place in the child's environment. + +**Fractional scale — deferred.** `wp_fractional_scale_v1` (so 125 % / 150 % outputs render natively instead of via compositor downscale) remains tracked as upcoming protocol work. diff --git a/docs/cookbook.md b/docs/cookbook.md index d861ec4..0049075 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -47,22 +47,24 @@ ratio. # #[ derive( Clone ) ] enum Msg {} # fn _ex( logo: Arc>, lw: u32, lh: u32 ) -> Element { column::() - .padding( Length::vmin( 4.0 ).clamp( 16.0, 48.0 ) ) - .spacing( Length::vmin( 2.0 ).clamp( 8.0, 24.0 ) ) - // Logo: 40 % of the width in portrait, 5 % of the height in landscape. - .push( img_widget( logo, lw, lh ).short_side( Length::orient( 40.0, 5.0 ) ) ) - // Heading: fluid, but never below 20 px nor above 44 px. - .push( text( "Welcome" ).size( Length::vmin( 6.0 ).clamp( 20.0, 44.0 ) ) ) - .into() + .padding( Length::vmin( 4.0 ).clamp( 16.0, 48.0 ) ) + .spacing( Length::vmin( 2.0 ).clamp( 8.0, 24.0 ) ) + // Logo: 40 % of the width in portrait, 5 % of the height in landscape. + .push( img_widget( logo, lw, lh ).short_side( Length::orient( 40.0, 5.0 ) ) ) + // Heading: fluid, but never below 20 px nor above 44 px. + .push( text( "Welcome" ).size( Length::vmin( 6.0 ).clamp( 20.0, 44.0 ) ) ) + .into() # } ``` Fluid units scale with the screen's pixels, not real-world -millimetres. For body text that must stay physically legible and -honour the user's font-size preference across an open-ended device -set, prefer `Length::dp` or `Length::em` over raw `vmin`; the +millimetres. For sizes that must stay physically constant across an +open-ended device set — touch targets, text that honours the user's +font-size preference — use `Length::dp` or `Length::em`. For text +that should stay fluid, reach for the [`typography`](../src/theme/typography.rs) scale (`h0`…`body_xs`) -gives clamped-`vmin` sizes tuned for running text. Reserve +instead of hand-rolling raw `vmin`: its ramp is clamped-`vmin`, +tuned for running text. Reserve `view()`-level branching on `surface_width` / `surface_height` for genuine layout restructuring (sidebar → bottom tabs), not for sizing. @@ -85,57 +87,62 @@ edge does not knife-cut against the layer below. # fn quick_settings_view( &self ) -> Element { text( "qs" ).into() } fn build_quick_settings_overlay( &self ) -> OverlaySpec { - // Compute the slide progress based on a stored start instant. While - // animating, `is_animating()` returns `true` so the runtime redraws - // at ~60 Hz and reads the new progress every frame. - let progress = match self.qs_started - { - Some( t ) => ( t.elapsed().as_secs_f32() / SLIDE_DURATION ).min( 1.0 ), - None => 1.0, - }; + // Compute the slide progress based on a stored start instant. While + // animating, `is_animating()` returns `true` so the runtime redraws + // every compositor frame (~60 Hz on GLES; capped at ~30 Hz on the + // software backend, see `cap_software_animation`) and reads the new + // progress each time. + let progress = match self.qs_started + { + Some( t ) => ( t.elapsed().as_secs_f32() / SLIDE_DURATION ).min( 1.0 ), + None => 1.0, + }; - let panel_height = self.surface_height as f32 * 0.85; - let visible_h = panel_height * progress; + let panel_height = self.surface_height as f32 * 0.85; + let visible_h = panel_height * progress; - // Feather the bottom edge during the slide; drop the fade once the - // panel is fully open so the bottom of a settled panel is hard. - let fade_px = if progress < 1.0 { 16.0 } else { 0.0 }; + // Feather the bottom edge during the slide; drop the fade once the + // panel is fully open so the bottom of a settled panel is hard. + let fade_px = if progress < 1.0 { 16.0 } else { 0.0 }; - let panel: Element = container( self.quick_settings_view() ) - .surface( "surface-card" ) - .padding( 24.0 ) - .into(); + let panel: Element = container( self.quick_settings_view() ) + .surface( "surface-card" ) + .padding( 24.0 ) + .into(); - OverlaySpec - { - id: OVERLAY_QS, - layer: Layer::Overlay, - anchor: Anchor::TOP, - size: ( Length::px( self.surface_width as f32 ), Length::px( visible_h ) ), - exclusive_zone: 0, - keyboard_exclusive: false, - input_region: None, - view: viewport( panel ) - .height( panel_height ) - .fade_bottom( fade_px ) - .into(), - on_dismiss: Some( Msg::CloseQs ), - anchor_widget_id: None, - } + OverlaySpec + { + id: OVERLAY_QS, + layer: Layer::Overlay, + anchor: Anchor::TOP, + size: ( Length::px( self.surface_width as f32 ), Length::px( visible_h ) ), + exclusive_zone: 0, + keyboard_exclusive: false, + input_region: None, + view: viewport( panel ) + .height( panel_height ) + .fade_bottom( fade_px ) + .into(), + on_dismiss: Some( Msg::CloseQs ), + anchor_widget_id: None, + } } fn is_animating( &self ) -> bool { - self.qs_started - .map( |t| t.elapsed().as_secs_f32() < SLIDE_DURATION ) - .unwrap_or( false ) + self.qs_started + .map( |t| t.elapsed().as_secs_f32() < SLIDE_DURATION ) + .unwrap_or( false ) } # } ``` +Set `LTK_PERF_WARN=1` during development to be warned when `is_animating()` +sticks at `true` after an animation should have settled. + The `fade_bottom( px )` builder is a GLES-only effect; the software backend renders a hard edge. If your shell must look identical on both -backends, branch on [`ltk::is_software_render()`] and skip the fade +backends, branch on `ltk::is_software_render()` and skip the fade when the software path is active. **See also**: [`Viewport`](./widgets.md#viewport), @@ -157,69 +164,69 @@ app forwards the submission to a background thread that runs PAM. # fn pam_authenticate( _user: &str, _pass: &str ) -> bool { true } struct LoginApp { - username: String, - password: String, - sender: Option>, + username: String, + password: String, + sender: Option>, } impl App for LoginApp { - type Message = Msg; + type Message = Msg; - fn view( &self ) -> Element - { - column() - .padding( 32.0 ) - .spacing( 16.0 ) - .push( text( "Sign in" ).size( 28.0 ) ) - .push( - text_edit( "Username", &self.username ) - .on_change( |s| Msg::UsernameChanged( s ) ), - ) - .push( - text_edit( "Password", &self.password ) - .secure( true ) // mask glyphs + zeroize on drop - .on_change( |s| Msg::PasswordChanged( s ) ) - .on_submit( Msg::Submit ), // Enter fires this - ) - .push( button( "Log in" ).on_press( Msg::Submit ) ) - .into() - } + fn view( &self ) -> Element + { + column() + .padding( 32.0 ) + .spacing( 16.0 ) + .push( text( "Sign in" ).size( 28.0 ) ) + .push( + text_edit( "Username", &self.username ) + .on_change( |s| Msg::UsernameChanged( s ) ), + ) + .push( + text_edit( "Password", &self.password ) + .secure( true ) // mask glyphs + zeroize on drop + .on_change( |s| Msg::PasswordChanged( s ) ) + .on_submit( Msg::Submit ), // Enter fires this + ) + .push( button( "Log in" ).on_press( Msg::Submit ) ) + .into() + } - fn set_channel_sender( &mut self, s: ChannelSender ) - { - // Saved once at startup; cloned into worker threads so they can - // wake the loop without polling. - self.sender = Some( s ); - } + fn set_channel_sender( &mut self, s: ChannelSender ) + { + // Saved once at startup; cloned into worker threads so they can + // wake the loop without polling. + self.sender = Some( s ); + } - fn update( &mut self, msg: Msg ) - { - match msg - { - Msg::UsernameChanged( s ) => self.username = s, - Msg::PasswordChanged( s ) => self.password = s, - Msg::Submit => - { - let username = self.username.clone(); - let password = self.password.clone(); - let sender = self.sender.clone().unwrap(); + fn update( &mut self, msg: Msg ) + { + match msg + { + Msg::UsernameChanged( s ) => self.username = s, + Msg::PasswordChanged( s ) => self.password = s, + Msg::Submit => + { + let username = self.username.clone(); + let password = self.password.clone(); + let sender = self.sender.clone().unwrap(); - std::thread::spawn( move || - { - let result = pam_authenticate( &username, &password ); - let _ = sender.send( Msg::AuthResult( result ) ); - } ); + std::thread::spawn( move || + { + let result = pam_authenticate( &username, &password ); + let _ = sender.send( Msg::AuthResult( result ) ); + } ); - // Clear the visible field so the user has feedback; - // `secure( true )` zeroizes the buffer when the next - // view() rebuild drops the old TextEdit. - self.password.clear(); - } - Msg::AuthResult( true ) => std::process::exit( 0 ), - Msg::AuthResult( false ) => { /* show error */ } - } - } + // Clear the visible field so the user has feedback; + // `secure( true )` zeroizes the buffer when the next + // view() rebuild drops the old TextEdit. + self.password.clear(); + } + Msg::AuthResult( true ) => std::process::exit( 0 ), + Msg::AuthResult( false ) => { /* show error */ } + } + } } ``` @@ -259,54 +266,54 @@ taps outside the panel. # fn modal_body( &self ) -> Element { text( "modal" ).into() } fn overlays( &self ) -> Vec> { - if !self.modal_open { return vec![]; } + if !self.modal_open { return vec![]; } - // The modal body sits inside a column capped at 400 px so it stays - // legible on wide displays; the outer column with two spacers - // centres it vertically. - let modal: Element = column() - .max_width( 400.0 ) - .push( - container( self.modal_body() ) - .surface( "surface-card" ) - .padding( 24.0 ), - ) - .into(); + // The modal body sits inside a column capped at 400 px so it stays + // legible on wide displays; the outer column with two spacers + // centres it vertically. + let modal: Element = column() + .max_width( 400.0 ) + .push( + container( self.modal_body() ) + .surface( "surface-card" ) + .padding( 24.0 ), + ) + .into(); - vec![ - OverlaySpec - { - id: OVERLAY_MODAL, - layer: Layer::Overlay, - anchor: Anchor::ALL, - size: ( Length::px( 0.0 ), Length::px( 0.0 ) ), - exclusive_zone: 0, - keyboard_exclusive: false, - input_region: None, // accept input - view: column() - .center_y( true ) - .push( spacer() ) - .push( modal ) - .push( spacer() ) - .into(), - on_dismiss: Some( Msg::CloseModal ), // tap outside dismisses - anchor_widget_id: None, - }, - ] + vec![ + OverlaySpec + { + id: OVERLAY_MODAL, + layer: Layer::Overlay, + anchor: Anchor::ALL, + size: ( Length::px( 0.0 ), Length::px( 0.0 ) ), + exclusive_zone: 0, + keyboard_exclusive: false, + input_region: None, // accept input + view: column() + .center_y( true ) + .push( spacer() ) + .push( modal ) + .push( spacer() ) + .into(), + on_dismiss: Some( Msg::CloseModal ), // tap outside dismisses + anchor_widget_id: None, + }, + ] } // Swipe-down gesture (only fires inside the overlay because the main // surface does not declare a down-swipe target). fn on_swipe_down( &mut self ) -> Option { - Some( Msg::CloseModal ) + Some( Msg::CloseModal ) } fn on_swipe_down_progress( &mut self, progress: f32 ) { - // Optional follow-the-finger feedback: store the in-progress value - // and use it in view() to translate or fade the modal contents. - self.modal_drag_progress = progress; + // Optional follow-the-finger feedback: store the in-progress value + // and use it in view() to translate or fade the modal contents. + self.modal_drag_progress = progress; } # } ``` @@ -337,37 +344,37 @@ restart. # impl App { fn view( &self ) -> Element { - let label = match ltk::active_mode() - { - ThemeMode::Light => "Switch to dark", - ThemeMode::Dark => "Switch to light", - }; - button( label ).on_press( Msg::ToggleTheme ).into() + let label = match ltk::active_mode() + { + ThemeMode::Light => "Switch to dark", + ThemeMode::Dark => "Switch to light", + }; + button( label ).on_press( Msg::ToggleTheme ).into() } fn update( &mut self, msg: Msg ) { - match msg - { - Msg::ToggleTheme => - { - let new = match ltk::active_mode() - { - ThemeMode::Light => ThemeMode::Dark, - ThemeMode::Dark => ThemeMode::Light, - }; - ltk::set_active_mode( new ); - // No further action — the next render reads the new mode - // through the slot helpers and recomposes the surface. - } - } + match msg + { + Msg::ToggleTheme => + { + let new = match ltk::active_mode() + { + ThemeMode::Light => ThemeMode::Dark, + ThemeMode::Dark => ThemeMode::Light, + }; + ltk::set_active_mode( new ); + // No further action — the next render reads the new mode + // through the slot helpers and recomposes the surface. + } + } } # } ``` `set_active_mode` mutates a process-global cell; the next `view()` rebuild reads the new mode through the per-slot helpers -([`theme_palette`], [`theme_surface`], [`theme_paint`], etc.) and +(`theme_palette`, `theme_surface`, `theme_paint`, etc.) and renders against the new colours. There is no manual invalidation step. For a full theme swap, load a different `ThemeDocument` and apply it: @@ -375,7 +382,7 @@ For a full theme swap, load a different `ThemeDocument` and apply it: ```rust,no_run # fn _ex() { let doc = ltk::ThemeDocument::find( "midnight" ) - .expect( "midnight theme not installed" ); + .expect( "midnight theme not installed" ); ltk::set_active_document( doc ); # } ``` @@ -401,41 +408,41 @@ when content overflows, and caches decoded icons across frames. # } struct LauncherApp { - apps: Vec, - icon_cache: RefCell>, u32, u32 )>>, + apps: Vec, + icon_cache: RefCell>, u32, u32 )>>, } impl App for LauncherApp { - type Message = Msg; + type Message = Msg; - fn view( &self ) -> Element - { - let mut grid = grid::( 4 ) - .padding( 16.0 ) - .spacing( 12.0 ); + fn view( &self ) -> Element + { + let mut grid = grid::( 4 ) + .padding( 16.0 ) + .spacing( 12.0 ); - for app in &self.apps - { - // Decode icons once on first reference; reuse the Arc on - // every subsequent frame for a pointer copy. - let ( bytes, w, h ) = self - .icon_cache - .borrow_mut() - .entry( app.id.clone() ) - .or_insert_with( || decode_icon( &app.icon_path ) ) - .clone(); + for app in &self.apps + { + // Decode icons once on first reference; reuse the Arc on + // every subsequent frame for a pointer copy. + let ( bytes, w, h ) = self + .icon_cache + .borrow_mut() + .entry( app.id.clone() ) + .or_insert_with( || decode_icon( &app.icon_path ) ) + .clone(); - grid = grid.push( - icon_button( bytes, w, h ) - .on_press( Msg::Launch( app.id.clone() ) ), - ); - } + grid = grid.push( + icon_button( bytes, w, h ) + .on_press( Msg::Launch( app.id.clone() ) ), + ); + } - scroll( grid ).into() - } + scroll( grid ).into() + } - fn update( &mut self, _msg: Msg ) { /* ... */ } + fn update( &mut self, _msg: Msg ) { /* ... */ } } ``` @@ -466,47 +473,47 @@ event loop without busy-polling. # impl App { fn set_channel_sender( &mut self, sender: ChannelSender ) { - // Saved once and never changed. - self.sender = Some( sender.clone() ); + // Saved once and never changed. + self.sender = Some( sender.clone() ); - // Spawn the worker that watches for external events and forwards - // them as messages. Substitute `wait_for_battery_event` with whatever - // blocks for your real source — a D-Bus signal, a file watch, a - // socket read, a timer, etc. - std::thread::spawn( move || - { - loop - { - // Block until the external source produces an event. When it - // arrives, post a message into the loop. - let event = wait_for_battery_event(); - let _ = sender.send( Msg::BatteryChanged( event ) ); - } - } ); + // Spawn the worker that watches for external events and forwards + // them as messages. Substitute `wait_for_battery_event` with whatever + // blocks for your real source — a D-Bus signal, a file watch, a + // socket read, a timer, etc. + std::thread::spawn( move || + { + loop + { + // Block until the external source produces an event. When it + // arrives, post a message into the loop. + let event = wait_for_battery_event(); + let _ = sender.send( Msg::BatteryChanged( event ) ); + } + } ); } fn poll_external( &mut self ) -> Vec { - // For state that doesn't need a dedicated thread (file mtime checks, - // expiry sweeps), drain it here. Called after every Wayland event - // and every poll_interval tick. - let mut msgs = vec![]; - if let Some( osd ) = self.toast.as_ref() - { - if osd.expires_at <= Instant::now() - { - msgs.push( Msg::HideToast ); - } - } - msgs + // For state that doesn't need a dedicated thread (file mtime checks, + // expiry sweeps), drain it here. Called after every Wayland event + // and every poll_interval tick. + let mut msgs = vec![]; + if let Some( osd ) = self.toast.as_ref() + { + if osd.expires_at <= Instant::now() + { + msgs.push( Msg::HideToast ); + } + } + msgs } fn poll_interval( &self ) -> Option { - // Wake every minute to re-check the clock display. Keep this `None` - // unless you actually need a wall-clock tick — it costs battery - // life on mobile targets. - Some( Duration::from_secs( 60 ) ) + // Wake every minute to re-check the clock display. Keep this `None` + // unless you actually need a wall-clock tick — it costs battery + // life on mobile targets. + Some( Duration::from_secs( 60 ) ) } # } ``` @@ -533,14 +540,14 @@ blocking other UI. # #[ derive( Clone ) ] enum Msg {} struct AppState { - toast: Option, - // ... + toast: Option, + // ... } struct Toast { - text: String, - started: Instant, + text: String, + started: Instant, } const TOAST_DURATION: f32 = 2.0; @@ -549,71 +556,72 @@ const TOAST_FADE: f32 = 0.25; impl AppState { # fn main_view( &self ) -> Element { text( "main" ).into() } - // ... + // ... } impl App for AppState { - type Message = Msg; + type Message = Msg; - fn view( &self ) -> Element { self.main_view() } + fn view( &self ) -> Element { self.main_view() } - fn overlays( &self ) -> Vec> - { - let toast = match &self.toast - { - Some( t ) => t, - None => return vec![], - }; + fn overlays( &self ) -> Vec> + { + let toast = match &self.toast + { + Some( t ) => t, + None => return vec![], + }; - let elapsed = toast.started.elapsed().as_secs_f32(); - let alpha = if elapsed >= TOAST_DURATION - { - // Fade-out window: 0.25 s after expiry the alpha hits 0. - ( 1.0 - ( elapsed - TOAST_DURATION ) / TOAST_FADE ).clamp( 0.0, 1.0 ) - } else { 1.0 }; + let elapsed = toast.started.elapsed().as_secs_f32(); + let alpha = if elapsed >= TOAST_DURATION + { + // Fade-out window: 0.25 s after expiry the alpha hits 0. + ( 1.0 - ( elapsed - TOAST_DURATION ) / TOAST_FADE ).clamp( 0.0, 1.0 ) + } else { 1.0 }; - vec![ - OverlaySpec - { - id: OVERLAY_TOAST, - layer: Layer::Overlay, - anchor: Anchor::BOTTOM, - size: ( Length::px( 0.0 ), Length::px( 0.0 ) ), - exclusive_zone: 0, - keyboard_exclusive: false, - input_region: Some( vec![] ), // pass-through - view: container( text( &toast.text ).color( Color::WHITE ) ) - .surface( "surface-panel" ) - .padding( 12.0 ) - .opacity( alpha ) - .into(), - on_dismiss: None, - anchor_widget_id: None, - }, - ] - } + vec![ + OverlaySpec + { + id: OVERLAY_TOAST, + layer: Layer::Overlay, + anchor: Anchor::BOTTOM, + size: ( Length::px( 0.0 ), Length::px( 0.0 ) ), + exclusive_zone: 0, + keyboard_exclusive: false, + input_region: Some( vec![] ), // pass-through + view: container( text( &toast.text ).color( Color::WHITE ) ) + .surface( "surface-panel" ) + .padding( 12.0 ) + .opacity( alpha ) + .into(), + on_dismiss: None, + anchor_widget_id: None, + }, + ] + } - fn update( &mut self, _msg: Msg ) {} + fn update( &mut self, _msg: Msg ) {} - fn is_animating( &self ) -> bool - { - // Redraw at 60 Hz while the toast is visible or fading. - self.toast.is_some() - } + fn is_animating( &self ) -> bool + { + // Redraw every compositor frame (capped at ~30 Hz on the + // software backend) while the toast is visible or fading. + self.toast.is_some() + } - fn poll_external( &mut self ) -> Vec - { - // Drop the toast once the fade window completes. - if let Some( t ) = &self.toast - { - if t.started.elapsed().as_secs_f32() >= TOAST_DURATION + TOAST_FADE - { - self.toast = None; - } - } - vec![] - } + fn poll_external( &mut self ) -> Vec + { + // Drop the toast once the fade window completes. + if let Some( t ) = &self.toast + { + if t.started.elapsed().as_secs_f32() >= TOAST_DURATION + TOAST_FADE + { + self.toast = None; + } + } + vec![] + } } ``` @@ -645,50 +653,50 @@ const FIELD_PASSWORD: WidgetId = WidgetId( "password" ); struct LoginApp { - username: String, - password: String, - pending_focus: Option, - // ... + username: String, + password: String, + pending_focus: Option, + // ... } impl App for LoginApp { - type Message = Msg; + type Message = Msg; - fn view( &self ) -> Element - { - column() - .push( - text_edit( "Username", &self.username ) - .id( FIELD_USERNAME ) - .on_change( |s| Msg::UsernameChanged( s ) ), - ) - .push( - text_edit( "Password", &self.password ) - .id( FIELD_PASSWORD ) - .secure( true ) - .on_change( |s| Msg::PasswordChanged( s ) ), - ) - .into() - } + fn view( &self ) -> Element + { + column() + .push( + text_edit( "Username", &self.username ) + .id( FIELD_USERNAME ) + .on_change( |s| Msg::UsernameChanged( s ) ), + ) + .push( + text_edit( "Password", &self.password ) + .id( FIELD_PASSWORD ) + .secure( true ) + .on_change( |s| Msg::PasswordChanged( s ) ), + ) + .into() + } - fn take_focus_request( &mut self ) -> Option - { - // Returned once; the runtime focuses that widget on the next - // frame. Subsequent calls return None. - self.pending_focus.take() - } + fn take_focus_request( &mut self ) -> Option + { + // Returned once; the runtime focuses that widget on the next + // frame. Subsequent calls return None. + self.pending_focus.take() + } - fn update( &mut self, msg: Msg ) - { - if matches!( msg, Msg::AuthFailed ) - { - // Clear the password and put focus back on the field so the - // user can retype without a click. - self.password.clear(); - self.pending_focus = Some( FIELD_PASSWORD ); - } - } + fn update( &mut self, msg: Msg ) + { + if matches!( msg, Msg::AuthFailed ) + { + // Clear the password and put focus back on the field so the + // user can retype without a click. + self.password.clear(); + self.pending_focus = Some( FIELD_PASSWORD ); + } + } } ``` @@ -726,46 +734,46 @@ top-level enum wraps them. #[derive(Clone)] enum AppMsg { - Nav( Screen ), - Home( HomeMsg ), - Settings( SettingsMsg ), + Nav( Screen ), + Home( HomeMsg ), + Settings( SettingsMsg ), } struct AppState { - current: Screen, - home: HomeState, - settings: SettingsState, + current: Screen, + home: HomeState, + settings: SettingsState, } impl App for AppState { - type Message = AppMsg; + type Message = AppMsg; - fn view( &self ) -> Element - { - let body = match self.current - { - Screen::Home => home_view( &self.home ).map( AppMsg::Home ), - Screen::Settings => settings_view( &self.settings ).map( AppMsg::Settings ), - Screen::About => about_view(), - }; + fn view( &self ) -> Element + { + let body = match self.current + { + Screen::Home => home_view( &self.home ).map( AppMsg::Home ), + Screen::Settings => settings_view( &self.settings ).map( AppMsg::Settings ), + Screen::About => about_view(), + }; - column() - .push( nav_bar( self.current ) ) - .push( body ) - .into() - } + column() + .push( nav_bar( self.current ) ) + .push( body ) + .into() + } - fn update( &mut self, msg: AppMsg ) - { - match msg - { - AppMsg::Nav( s ) => self.current = s, - AppMsg::Home( m ) => home_update( &mut self.home, m ), - AppMsg::Settings( m ) => settings_update( &mut self.settings, m ), - } - } + fn update( &mut self, msg: AppMsg ) + { + match msg + { + AppMsg::Nav( s ) => self.current = s, + AppMsg::Home( m ) => home_update( &mut self.home, m ), + AppMsg::Settings( m ) => settings_update( &mut self.settings, m ), + } + } } ``` @@ -811,46 +819,46 @@ let mut surface = UiSurface::::new( width, height ); loop { - // 1. Drain pending app events from your own input source. - for ev in input_queue.drain() { app.update( ev.into_msg() ); } + // 1. Drain pending app events from your own input source. + for ev in input_queue.drain() { app.update( ev.into_msg() ); } - // 2. Build the tree and render. - let view = app.view(); - let out = surface.render( - &view, - RenderOptions::full_canvas( width, height ) - .background( Color::TRANSPARENT ), - ); + // 2. Build the tree and render. + let view = app.view(); + let out = surface.render( + &view, + RenderOptions::full_canvas( width, height ) + .background( Color::TRANSPARENT ), + ); - // 3. Pull pixels (software backend) or present the FBO (GLES). - match surface.canvas() - { - Canvas::Software( _ ) => - { - let mut buf = vec![ 0u8; ( width * height * 4 ) as usize ]; - surface.canvas().write_to_wayland_buf( &mut buf, false ); - present_argb8888( &buf ); - } - Canvas::Gles( _ ) => - { - // Already drawn into the FBO the embedder owns; commit - // through your own EGL context. - } - } + // 3. Pull pixels (software backend) or present the FBO (GLES). + match surface.canvas() + { + Canvas::Software( _ ) => + { + let mut buf = vec![ 0u8; ( width * height * 4 ) as usize ]; + surface.canvas().write_to_wayland_buf( &mut buf, false ); + present_argb8888( &buf ); + } + Canvas::Gles( _ ) => + { + // Already drawn into the FBO the embedder owns; commit + // through your own EGL context. + } + } - // 4. Use damage rects to feed wl_surface.damage_buffer if you are - // on the software path. - for rect in &out.damage_rects { wl_damage( rect ); } + // 4. Use damage rects to feed wl_surface.damage_buffer if you are + // on the software path. + for rect in &out.damage_rects { wl_damage( rect ); } - // Pointer dispatch: turn a screen-space point into the widget under it. - let hit = surface.hit_test( ltk::Point { x: pos_x, y: pos_y } ); - if let Some( idx ) = hit - { - if let Some( msg ) = surface.handlers( idx ).and_then( |h| h.press_msg() ) - { - app.update( msg ); - } - } + // Pointer dispatch: turn a screen-space point into the widget under it. + let hit = surface.hit_test( ltk::Point { x: pos_x, y: pos_y } ); + if let Some( idx ) = hit + { + if let Some( msg ) = surface.handlers( idx ).and_then( |h| h.press_msg() ) + { + app.update( msg ); + } + } # break; } # } @@ -954,8 +962,9 @@ s.into() To rasterise the result into a caller-owned buffer instead of presenting it, render through a [`core::UiSurface`](#embedding-ltk-without-ltkrun) -and call `Canvas::read_rgba_pixels( &mut buf )` — it returns tightly -packed straight-alpha RGBA8 (top-left row first) from either backend +and call `Canvas::read_rgba_pixels( &mut buf )` — it fills the buffer +with tightly packed straight-alpha RGBA8 (top-left row first) and +returns `Result<(), String>`, on either backend (the software path un-premultiplies for you). Branch on `Canvas::is_software()` when a draw must honour a real path clip on software but only a bounding rect on GLES. diff --git a/docs/onboarding.md b/docs/onboarding.md index d6a402d..e6cc5e4 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -15,8 +15,8 @@ runtime-free UI surfaces. At a high level: -- Implement the [`App`] trait. -- Return an [`Element`] tree from `view()`. +- Implement the `App` trait. +- Return an `Element` tree from `view()`. - React to user input by handling messages in `update()`. - Start the event loop with `ltk::run(app)`. @@ -40,8 +40,8 @@ it assumes: - a running **Wayland** session - Wayland client libraries available through Rust dependencies -- a usable system font such as `google-sora-fonts`, `liberation-fonts` or - `dejavu-fonts` +- a usable system font — on Debian (the crate's own packaging target): + `fonts-sora`, `fonts-liberation` or `fonts-dejavu` - an installed `default` theme, or a development theme directory exposed through `LTK_THEMES_DIR` @@ -55,18 +55,50 @@ The rendering backend is selected automatically: From the repo root: ```bash -cargo run --example showcase +LTK_THEMES_DIR=themes cargo run --example showcase ``` -Other useful examples: +The `LTK_THEMES_DIR=themes` prefix points theme lookup at the in-repo +`themes/` directory (see *Theme and font setup* below); without it, on a +machine without the `default` theme installed, the example runs on the +embedded B/W fallback with a red banner. + +The other examples, same prefix: - `cargo run --example widgets` — broad widget survey -- `cargo run --example inputs` — text entry -- `cargo run --example scroll` — scroll viewport patterns +- `cargo run --example inputs` — plain and secure text fields with a + show/hide-password toggle +- `cargo run --example scroll` — the two main scroll use cases: a long list + and an app-drawer-style grid +- `cargo run --example sliders` — the Glass effect on horizontal and + vertical sliders +- `cargo run --example combo` — select/dropdown with editable query and + multi-select chips +- `cargo run --example pickers` — notebook tabs, date, time and color + pickers +- `cargo run --example dialog` — modal confirm, non-modal pick and the + other dialog shapes +- `cargo run --example carousel` — focused-tile carousel +- `cargo run --example responsive` — fluid vs physical scaling side by side +- `cargo run --example clip_path` — per-path canvas clipping - `cargo run --example mini_shell` — overlays, animation and theme switching All examples require a running Wayland compositor. +## Build and test + +The `Makefile` wraps the cargo invocations the repo expects: + +- `make all` — release build +- `make test` — `cargo test --features test-support`; a bare `cargo test` + fails because the integration tests import the feature-gated + `ltk::test_support` +- `make doctest-md` — typechecks the code snippets in `docs/*.md`, so API + drift surfaces in CI like a normal doctest failure +- `make examples` — runs every example in sequence with + `LTK_THEMES_DIR=themes` +- `make doc` — `cargo doc --no-deps` + ## Theme and font setup `ltk` currently expects a theme named `default`. Lookup order is: @@ -84,13 +116,15 @@ export LTK_THEMES_DIR="$PWD/themes" That makes `ThemeDocument::find("default")` resolve to `$PWD/themes/default/theme.json`. -Font loading is separate from theme lookup. `Canvas` walks a chain of -common system font paths (`fonts-sora`, `fonts-liberation`, `fonts-dejavu`, -`fonts-freefont`, …) and uses the first one it finds. If nothing matches, -it falls back to an embedded Sora Regular (~50 KB, SIL OFL 1.1) shipped -inside the crate, so canvas construction never panics on a system without -the expected fonts. Installing one of the listed packages is still -recommended for richer glyph coverage. +Font loading is separate from theme lookup. `src/system_fonts.rs` walks a +chain of common system font *file paths* — the files installed by the +Debian packages `fonts-sora`, `fonts-liberation`, `fonts-freefont` and +`fonts-dejavu`, plus the equivalent locations other distros use — and +loads the first one it finds. If nothing matches, it falls back to an +embedded Sora Regular (~50 KB, SIL OFL 1.1) shipped inside the crate, so +font resolution never panics on a system without the expected fonts. +Installing one of the listed packages is still recommended for richer +glyph coverage. ## Your first app @@ -186,7 +220,7 @@ The APIs you will usually touch first live here conceptually: - `App` - `Element` -- `button`, `text`, `text_edit`, `image` +- `button`, `text`, `text_edit`, `img_widget` - `column`, `row`, `stack`, `grid`, `spacer` - `container`, `scroll`, `slider`, `toggle`, `checkbox`, `radio` - `Color` @@ -260,8 +294,11 @@ The knobs you will usually override are: - `keyboard_exclusive()` - `background_color()` -For a non-trivial layer-shell example, use `examples/mini_shell.rs` as the -reference entry point. +For a non-trivial multi-surface example, use `examples/mini_shell.rs` as the +reference entry point — note it runs as a regular window (it never overrides +`shell_mode()`) and demonstrates screen routing, coordinated overlays, an +animated OSD and live theme switching. The layer-shell knobs themselves are +exercised by the downstream shell components, not by the in-repo examples. ## The APIs you will touch first @@ -271,7 +308,7 @@ Start here: - `App` - `Element` -- `button`, `text`, `text_edit`, `image` +- `button`, `text`, `text_edit`, `img_widget` - `column`, `row`, `stack`, `grid`, `spacer` - `container`, `scroll`, `slider`, `toggle`, `checkbox`, `radio` - `Color` @@ -398,8 +435,17 @@ None of that blocks learning the toolkit, but it matters when you evaluate ## What to read next +In the README's recommended order — onboarding, then the widget catalogue, +then the cookbook, then architecture: + +- [`docs/widgets.md`](./widgets.md) — per-widget catalogue: what each one + is, when to use it, minimal example +- [`docs/cookbook.md`](./cookbook.md) — concrete recipes: slide-in panels, + password fields, runtime theme toggle, channel-driven state - [`docs/architecture.md`](./architecture.md) — multi-surface patterns, theming, animation and performance +- [`docs/theming.md`](./theming.md) — JSON theme schema, slot conventions, + runtime APIs - [`examples/showcase.rs`](../examples/showcase.rs) — smallest visual tour - [`examples/widgets.rs`](../examples/widgets.rs) — broader widget coverage - [`examples/mini_shell.rs`](../examples/mini_shell.rs) — overlays and shell diff --git a/docs/theming.md b/docs/theming.md index 414c8de..5af83e7 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -1,7 +1,8 @@ # ltk theming -`ltk` reads a JSON theme document at startup and exposes a process-wide -active state for widgets and applications to query. This document +`ltk` loads a JSON theme document lazily — on the first theme accessor +call, not at startup — and exposes a process-wide active state for +widgets and applications to query. This document describes the on-disk format, the runtime APIs, and the slot conventions that built-in widgets expect. @@ -20,6 +21,8 @@ A theme is a directory under one of: 3. `/usr/share/ltk/themes//` — system-wide install path (`ltk-theme-default` Debian package). +The ordered list is queryable at runtime via `ltk::theme::search_paths()`. + The directory tree: ```text @@ -36,15 +39,22 @@ The directory tree: │ │ └── horizontal.svg wordmark (header / sign-in bars) │ └── dark/ │ └── (same set, dark variants) -└── icons/ symbolic + app icons - ├── app-default.svg fallback icon for unknown app ids - ├── apps/ per-application icons (firefox.svg, …) - └── catalogue/ symbolic glyph catalogue - ├── filled/ solid silhouettes — preferred by default - │ └── / general, system, window, … - └── line/ outlined variants (same names) +├── icons/ symbolic + app icons +│ ├── apps/ per-application icons (firefox.svg, …) +│ │ └── default.svg generic app icon shipped with the set +│ └── catalogue/ symbolic glyph catalogue +│ ├── filled/ solid silhouettes — preferred by default +│ │ └── / general, system, window, … +│ └── line/ outlined variants (same names) +├── cursors/ X cursor files (consumed by the +└── cursor.theme compositor, not parsed by ltk itself) ``` +Note a known mismatch: the `theme_app_default_icon()` fallback looks +for `icons/app-default.svg`, a path the shipped default theme does not +provide (its generic icon lives at `icons/apps/default.svg`), so that +fallback never resolves against the default theme. + Branded assets in `branding/` and icons in `icons/` are picked up by convention — see [Branding assets](#branding-assets) and [Icons](#icons) below. Asset paths declared inside `theme.json` (e.g. a @@ -68,12 +78,18 @@ absolute paths work for system fonts but break relocatable installs. } ``` -`theme.id` must match the directory name. `theme.name` is shown in any -theme-picker UI a shell builds on top of `ltk`. +`theme.id` should match the directory name by convention — lookup goes +by directory name and the id inside the JSON is not checked against it. +`theme.name` is shown in any theme-picker UI a shell builds on top of +`ltk`. -The other six sections are described below in order. The parser is strict -(`deny_unknown_fields`): unknown keys at any level are an error so typos -surface immediately. +The other five sections are described below in order. The parser is +strict (`deny_unknown_fields`) at most levels — the document root, +`fonts`, `modes`, gradient stops, shadow entries — so typos there are +an error. It is *not* strict everywhere: unknown keys directly inside a +slot entry or inside a `meta` block are silently ignored, and the +`surface` / `typography` slot bodies lose the check to serde's +`#[serde(flatten)]`. ## `fonts` @@ -100,9 +116,14 @@ weight. The map key (`"sora"`) is the family alias used inside the theme files at render time. The chain is walked in order; the first family that can rasterise the codepoint wins. -If none of the listed `sources` exist on disk, `ltk` falls back to its -embedded Sora Regular (~50 KB, OFL 1.1) and stamps a red banner on every -frame pointing at the missing-theme problem. +Sources that fail to read are skipped with a stderr warning; when the +whole block yields nothing, text falls back to the embedded Sora +Regular (~50 KB, OFL 1.1). Missing fonts never trigger the red warning +banner — that banner is reserved for the case where the `default` +theme document itself cannot be found (see [Common +errors](#common-errors)). The registry that backs this is built by +`ltk::theme::build_font_registry()`, which the draw loop already calls +at canvas creation time. ## `colors` @@ -125,12 +146,20 @@ kebab-case. There is no distinction between "palette" colours (used in many places) and "raw" colours (single-use) — any hex can live here, and single-use literals are equally valid inline. +Inline colour values elsewhere in the document (slot values, gradient +stops, shadow colours, window-controls tokens) additionally accept the +functional forms `rgb(R, G, B)` and `rgba(R, G, B, A)` — R/G/B as +integers or floats in `0..=255`, A as a float in `0.0..=1.0`. The +top-level `colors` map is the asymmetric exception: its entries must +be hex literals; a functional form there is rejected. + ## `gradients` Named paints. Two variants: `linear` and `radial`. Used both as fills for -surfaces and as values referenced from a slot. Stops carry a `pos` (in -`[0, 1]`, but stop positions outside that range are accepted and used for -extrapolation) and a `color` (literal hex or `@reference`). +surfaces and as values referenced from a slot. Stops carry a `pos` +(alias: `position`; in `[0, 1]`, but stop positions outside that range +are accepted and used for extrapolation) and a `color` (literal hex or +`@reference`). ```json "gradients": { @@ -146,10 +175,13 @@ extrapolation) and a `color` (literal hex or `@reference`). } ``` -`space` is either `"srgb"` (perceptually quick, the default) or -`"linear-rgb"` (interpolate in linear-light space, more uniform mid-tones -for high-saturation gradients). `angle_deg` is the conventional CSS -gradient angle (`0` = up, `90` = right, `180` = down, `270` = left). +`space` is `"linear-rgb"` (interpolate in linear-light space — the +default, physically correct and fast), `"srgb"` (interpolate in +gamma-encoded sRGB; reproduces designs authored against it, but +midpoints of saturated gradients look muddy) or `"oklab"` (perceptual; +best for high-chroma brand gradients, slightly more expensive). +`angle_deg` is the conventional CSS gradient angle (`0` = up, `90` = +right, `180` = down, `270` = left). Radial gradients use `center: [x, y]` (relative to the painted rect, both in `[0, 1]`) and `radius: r` (also relative). @@ -174,12 +206,24 @@ Named lists of inset shadows reused across surfaces. Convenient for the Each entry has `offset: [x, y]` (logical pixels), `blur` (Gaussian sigma × 2, matching the CSS convention), optional `spread` (default `0`), -`color` (literal or `@ref`), and `blend` (`normal`, `plus-lighter`, or -`overlay`). +`color` (literal or `@ref`), and `blend` (`normal`, `plus-lighter`, +`overlay`, `multiply`, or `screen`). Reference an entire stack from a slot with `"inset_shadows": "@glass-insets"`, or inline the array if you only use it once. +### Responsive sizing + +Pixel values in a theme are *logical* design pixels: they resolve +through the `Length` / `WidgetScaling` system (`Length::dp` for +constant-physical sizes, `Length::fluid` for surface-proportional +ones, `set_density`, and `Canvas::geom_px` / `Canvas::font_px` at draw +time) — see +[`docs/architecture.md#responsive-sizing`](./architecture.md#responsive-sizing). +For text, the `theme::typography` module ships the `H0`…`BODY_XS` px +constants plus the fluid `h0()`…`body_xs()` `Length` builders that +scale with the surface's smaller dimension. + ## `modes` Two required entries: `light` and `dark`. Each carries the look the @@ -202,7 +246,7 @@ Both are optional. The runtime resolves them in two steps: ``` `path` resolves against the theme directory. `fit` is one of `"cover"`, -`"contain"`, `"stretch"`, `"center"` (default `"cover"`). The wallpaper +`"contain"`, `"stretch"`, `"center"`, `"tile"` (default `"cover"`). The wallpaper bundle helper ([`ltk::WallpaperBundle::for_size`]) returns the right crop for landscape or portrait surfaces, so a single landscape SVG / PNG covers both. @@ -222,6 +266,7 @@ Per-mode tokens for the title-bar control buttons: ```json "window_controls": { + "bar_bg": "@off-white", "icon": "#5F5F68", "hover_bg": "@navy/14", "pressed_bg": "@navy/24", @@ -231,7 +276,11 @@ Per-mode tokens for the title-bar control buttons: } ``` -Consumed by the [`window_button`](../src/widget/window_button.rs) widget +`bar_bg` is the background fill of the server-side-decoration title +bar strip the buttons sit on; the six remaining keys style the buttons +themselves. + +Consumed by the [`window_button`](../src/widget/window_button/) widget through [`theme_window_controls()`]. The actual SVG glyphs (`close`, `maximize`, `minimize`, `restore`) live @@ -249,7 +298,8 @@ The mode's slot table. Each entry is keyed by a stable id; widgets look their slot up by id and the slot's `meta.semantic` field supplies a human-readable hint that's useful in theme inspectors. -Three slot variants: +Six slot variants: `color`, `linear`, `radial`, `shadows`, `surface` +and `typography`. #### `color` @@ -263,7 +313,51 @@ Three slot variants: Plain colour values. `value` is a literal hex or `@reference`. `meta` is optional but conventional: `palette/` for the palette layer and -`effect//` for everything else. +`effect//` for everything else. Besides `semantic`, `meta` +also carries three optional free-form fields the runtime ignores: +`fluent` (equivalent token name in another design system), `usage` +(guidance on where to use the slot) and `note`. + +#### `linear` / `radial` + +```json +"chip-active": { + "type": "linear", + "angle_deg": 90, + "stops": [ + { "pos": 0, "color": "@cyan" }, + { "pos": 1, "color": "@teal" } + ] +} +``` + +Inline gradient slots — the same fields as a [`gradients`](#gradients) +entry (`angle_deg` + `stops` + optional `space` for `linear`; `center` + +`radius` + `stops` + optional `space` for `radial`), declared directly +in the slot table instead of being named and referenced. Resolved by +widgets via [`theme_paint( id )`], which also promotes plain `color` +slots to a solid paint. + +#### `typography` + +```json +"body-m": { + "type": "typography", + "family": { "ref": "sora" }, + "weight": 400, + "size": 16, + "line_height": { "px": 24 } +} +``` + +A resolved text style, looked up via [`theme_text_style( id )`]. +`family.ref` names an entry of the top-level [`fonts`](#fonts) block; +`weight` and `size` are required. `line_height` is either `{ "px": n }` +(absolute) or `{ "mul": n }` (multiplier of the size). Optional fields +with defaults: `style` (`normal` / `italic`), `letter_spacing` +(`0`), `transform` (`none` / `uppercase` / `lowercase` / +`capitalize`), `decoration` (`none` / `underline` / `strikethrough`) +and `color` (a slot-id string). #### `shadows` @@ -287,7 +381,6 @@ Outer drop shadows applied via [`theme_shadows(id)`]. Same field shape as "fill": "@surface-glass-dark", "shadows": "shadows-glass", "inset_shadows": "@glass-insets", - "backdrop": { "blur_px": 22.5 }, "meta": { "semantic": "effect/glass/card" } } ``` @@ -299,18 +392,18 @@ The most expressive slot kind. Composes: - `shadows` — id of a `shadows` slot or an inline list. Optional. - `inset_shadows` — id of an `inset_stacks` entry (`@glass-insets`) or inline list. Optional. -- `backdrop` — `{ "blur_px": <σ × 2> }` for backdrop blur. Optional; - GLES backend renders it, software backend ignores it (documented - parity gap). ## References The `@name` syntax substitutes a palette / gradient / inset-stack value in place of an inline literal: -- `@cyan` — looks up `colors.cyan`, `gradients.cyan` or - `inset_stacks.cyan` (collisions across sections are an error). When the - resolved value is a colour, the alpha channel comes from the original. +- `@cyan` — looks up `gradients.cyan` / `inset_stacks.cyan` first, + then `colors.cyan`. A name defined in both `gradients` and + `inset_stacks` is an error; a name defined in both `colors` and one + of the token sections is *not* — the token silently wins, so avoid + reusing names across sections. When the resolved value is a colour, + the alpha channel comes from the original. - `@cyan/80` — the `/AA` suffix is a colour-only alpha override (two hex digits). Lets a single base `@navy` serve `@navy/14`, `@navy/24`, `@navy/99`, `@navy/D9` etc. without a separate entry per alpha. @@ -339,23 +432,28 @@ of them falls back to embedded defaults. | `accent` | toggle on, slider fill, focus ring | color | | `divider` | separator, toggle off, list item border | color | | `icon` | icon-button glyph colour | color | +| `danger` | destructive / error foreground | color | +| `danger-bg` | soft fill behind error states | color | -**Effects (optional but used by built-in widgets):** +The default theme defines `danger` but not `danger-bg`, which +therefore resolves to the built-in pink-wash default from the palette +projection. + +**Effects (optional):** | id | consumer | type | | --- | --- | --- | -| `shadows-glass` | every surface that opts into elevation | shadows | -| `surface-card` | `Container::surface("surface-card")` | surface | -| `surface-card-flat` | flat variant for software backend | surface | -| `surface-panel` | overlay panels | surface | -| `surface-slider-track` | `Slider` track background | surface | -| `surface-slider-fill` | `Slider` filled portion | surface | -| `surface-slider-track-flat` | software-backend slider track | surface | -| `surface-slider-fill-flat` | software-backend slider fill | surface | -| `surface-toggle-active` | `Toggle` on-state surface | surface | +| `surface-slider-track` | `Slider` / `VSlider` track background | surface | +| `surface-slider-fill` | `Slider` / `VSlider` filled portion | surface | +| `surface-card` | no built-in widget reads it automatically — apps opt in via `Container::surface("surface-card")` | surface | +| `shadows-glass` | referenced from the theme's own surface slots; no direct widget consumer | shadows | +| `surface-panel`, `surface-toggle-active`, `surface-card-flat`, `surface-slider-track-flat`, `surface-slider-fill-flat` | reserved — currently unconsumed by any built-in widget | surface | -The `-flat` variants are used by the software backend, which lacks -backdrop blur; the GLES backend uses the non-flat ones. +Only the slider track / fill pair is looked up by widgets on their +own. The `-flat` variants are *not* wired to the software backend +automatically; a caller can pass one explicitly (e.g. +`Slider::track_surface("surface-slider-track-flat")`), but nothing in +`ltk` selects them per backend. ## Using the theme from app code @@ -403,7 +501,7 @@ fn view( &self ) -> Element | `theme_color( id )` | `Option` | Pull a single colour slot by id when it's not in the palette (`"surface-card-border"`, custom theme tokens). | | `theme_color_or( id, fallback )` | `Color` | Same, with a baked-in default — ergonomic in widget defaults so missing slots don't return `None`. | | `theme_paint( id )` | `Option` | Slot may be a colour or a gradient — promotes a colour to `Paint::Solid` automatically. | -| `theme_surface( id )` | `Option` | Surface slot (fill + shadows + insets + backdrop). | +| `theme_surface( id )` | `Option` | Surface slot (fill + shadows + insets). | | `theme_resolve_surface( id )` | `Option<( Surface, Vec )>` | Same, but pre-resolves a `ShadowsRef::Named` reference to a flat `Vec`. Use this when you call `canvas.fill_surface` directly. | | `theme_shadows( id )` | `Option>` | Outer shadow stack. | | `theme_text_style( id )` | `Option` | Typography slot (size, weight, line-height). | @@ -419,6 +517,10 @@ fn view( &self ) -> Element | `theme_app_icon( name )` / `theme_app_default_icon()` | `Option` | Per-app icons under `icons/apps/`. | | `theme_icon_path( "category/name" )` | `Option` | Catalogue icon path (filled-then-line lookup). | | `theme_icon_rgba( "category/name", size )` | `Option<( Arc>, u32, u32 )>` | Rasterised + cached RGBA. Pair with `theme::tint_symbolic` for chrome glyphs. | +| `theme_icon_tinted( name, size, tint )` | `Option` | One-call rasterise + tint (`theme_icon_rgba` + `tint_symbolic`). | +| `decode_svg_bytes( bytes, size )` | `Option<( Arc>, u32, u32 )>` | Rasterise arbitrary SVG bytes (uncached, no path resolution); the primitive under `theme_icon_rgba`. | +| `active_document()` | `Arc` | The whole loaded document, for slot-typed lookups the per-slot helpers do not cover (e.g. iterating slots). | +| `active_theme_id()` | `String` | Id of the active theme. | | `is_fallback_active()` | `bool` | `true` when the embedded B/W fallback theme is in force (no theme on disk). Useful to disable a theme-picker UI or warn the user. | ### Switching mode or document at runtime @@ -436,6 +538,18 @@ let doc = ltk::ThemeDocument::find( "midnight" ) ltk::set_active_document( doc ); ``` +`ThemeDocument::find( id )` walks the standard search paths; +`ThemeDocument::load_from_dir( dir )` loads a specific directory +(useful for previews or tests). Once loaded, `doc.mode( ThemeMode::Dark +)` projects the per-mode content without installing the document. + +For the phone-style "auto" setting, `ThemePreference` (`Light` / +`Dark` / `Auto`) is the shell-side persisted preference: +`pref.resolve( hour )` maps it to a concrete `ThemeMode`, with `Auto` +delegating to `ThemeMode::from_hour( hour )` — dark between 19:00 and +06:59 local time, light otherwise. The toolkit itself only ever sees +the resolved `ThemeMode`. + The conventional wiring is to dispatch a message from the UI: ```rust,no_run @@ -472,7 +586,7 @@ story is "ltk re-runs `view()` every frame and you read fresh values". - **Do** read palette / surfaces / icons inside `view()`, every frame. - **Do** use `theme_color_or( id, fallback )` for non-palette slots so a custom theme that omits a slot still paints something sane. -- **Do** use `theme_palette().` for the eight common roles — +- **Do** use `theme_palette().` for the ten common roles — the palette is precomputed and cheap; named slot lookups only beat it when you genuinely need a non-canonical token. - **Don't** store `Color`, `Surface`, `Paint`, or icon `PathBuf`s in @@ -622,7 +736,10 @@ Categories under `catalogue/{filled,line}/`: `accessibility`, `actions`, ``). The rasteriser keeps only the alpha channel for symbolic tinting, so the actual RGB of the source doesn't matter — pick `#000000` for consistency with the rest of the - catalogue. + catalogue. Note the catalogue SVGs' viewBoxes are cropped to the + ink, not to a uniform grid: the rasteriser scales the *longest* + edge to the requested size preserving aspect ratio, so a non-square + glyph comes out smaller than `size` on its other axis. 2. Optionally ship the `line/` variant in `catalogue/line//.svg`. 3. Reference it from widget code by its slash-separated stem, no @@ -645,6 +762,9 @@ let ( rgba, w, h ) = ltk::theme::icon_rgba( "window/close", 16 )?; // `ltk::theme_window_controls().icon` instead. let palette = ltk::theme_palette(); let tinted = ltk::theme::tint_symbolic( &rgba, palette.icon ); + +// Or the one-call form combining both steps: +let image = ltk::theme_icon_tinted( "window/close", 16, palette.icon ); # Some( () ) # } ``` @@ -652,7 +772,9 @@ let tinted = ltk::theme::tint_symbolic( &rgba, palette.icon ); The `icon_rgba` + `tint_symbolic` pair is the standard pipeline for catalogue and chrome icons: rasterise once, recolour per mode via palette tokens. Themes ship a single SVG per glyph and the per-mode -look comes from code, not from duplicated assets. +look comes from code, not from duplicated assets. `tint_symbolic` +multiplies the source alpha by `tint.a`, so a translucent tint +attenuates the whole glyph rather than just recolouring it. ## Localisation @@ -679,10 +801,12 @@ date_picker: dow_short_6: "S" ``` -`dow_short_` is indexed from each locale's `first_dow` (Sunday-first -in `en`, Monday-first in `es` / `fr` / `it` / `de` / `pt` / `pt_BR`). -The `Locale` struct ships `first_dow: u8` and the date picker indexes -`dow_short_*` accordingly. +`dow_short_` is indexed from each locale's first day of week +(Sunday-first in `en`, Monday-first in `es` / `fr` / `it` / `de` / +`pt` / `pt_BR`). The `Locale` struct's `first_dow` field is +crate-private; apps pick a start day through the public +`Locale::MONDAY_FIRST` / `Locale::SUNDAY_FIRST` constants and the date +picker indexes `dow_short_*` accordingly. Built-in widgets read these via `rust_i18n::t!( "context_menu.copy" )` at render time, so switching locale at runtime via @@ -729,9 +853,10 @@ A custom theme directory can live anywhere under `icons/catalogue/filled//.svg`. The `icon_path` lookup resolves against whichever theme is active, so a partial catalogue overlays the default cleanly without forking the rest. -- **Build a flat-only theme**: drop every `backdrop` block from the - slots and route every `surface-*-flat` slot to a solid `colors` - reference. Visual parity with the software backend is automatic. +- **Build a flat theme**: route the `surface-*` slots to solid + `colors` references and drop the shadow / inset decorations. A + `color` slot promotes to a plain surface automatically, so the + built-in widgets keep working with the same ids. All three recipes keep the slot ids and reference shapes intact, so the built-in widgets continue to work without code changes. diff --git a/docs/widgets.md b/docs/widgets.md index f450e74..886cf82 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -139,8 +139,8 @@ let bar = window_controls( `focusable( true )` opts the button into the Tab cycle (off by default to match desktop convention). -**See also**: [`crate::theme_window_controls`] for the colour tokens -each mode supplies. +**See also**: `theme_window_controls` for the colour tokens each mode +supplies. ### `list_item` @@ -148,8 +148,7 @@ A row with a primary label, optional subtitle, optional leading icon, optional right-aligned trailing text and/or trailing icon, and a tappable surface. -**When**: settings menus, navigation lists, contact rows. Doubles its -height when a subtitle is set. +**When**: settings menus, navigation lists, contact rows. Grows taller when a subtitle is set. ```rust,no_run # use ltk::{ list_item, ListItem }; @@ -343,11 +342,11 @@ let hint = text( "Swipe up or press Enter to unlock" ) ### `text_edit` -A single-line text input with cursor, Backspace, Enter / Submit, -clipboard support, a `secure( true )` password mode that masks the -visible characters and zeroizes the buffer on drop, and a +A text input with cursor, Backspace, Enter / Submit, clipboard +support, a `secure( true )` password mode that masks the visible +characters and zeroizes the buffer on drop, and a `password_toggle( visible, on_toggle )` builder that pins a show / -hide-password eye icon to the right edge of the field. +hide-password eye icon to the right edge of the field. Single-line by default; `multiline( true )` switches to a multi-row editor whose visible height follows `rows( n )`. **When**: login fields, chat inputs, search boxes. @@ -437,14 +436,12 @@ container( column().push( title ).push( subtitle ) ) ``` Per-edge padding is available with `padding_top` / `padding_right` / -`padding_bottom` / `padding_left`. Per-corner radius takes a [`Corners`] +`padding_bottom` / `padding_left`. Per-corner radius takes a `Corners` struct or a tuple. `max_width(px)` caps the container's outer width — when the parent offers a wider rect, the container reports `min( offered, px )` as its -preferred width instead of stretching to fill. Mirrors the same flag on -[`column`](#column) and [`row`](#row), so a single decorated child no -longer needs a `column`-of-one wrap just to access a width cap. +preferred width instead of stretching to fill. Note the semantics differ from [`column`](#column)'s `max_width`, which caps the *content* width while the column still claims the full available rect ([`row`](#row) has no such flag); the container cap shrinks the widget itself, so a single decorated child gets a real width cap without extra wrapping. **See also**: [`pressable`](#pressable) wrapping a `container` makes a card interactive. @@ -535,8 +532,7 @@ External::cpu( 120.0, 120.0, |canvas, rect| ### `scroll` -A vertically-scrollable viewport. Drag the content to scroll; clipping -is automatic. +A scrollable viewport — vertical by default, with `.horizontal()` and `.both()` builders switching the axis (`ScrollAxis::{ Vertical, Horizontal, Both }`). Drag the content to scroll; clipping is automatic. **When**: lists or grids that may overflow the available height. @@ -662,33 +658,41 @@ of arbitrary content; [`tabs`](#tabs) for a non-touch alternative. ### `spinner` -Indeterminate progress indicator. Animates while in the tree. +Indeterminate progress indicator. The widget is stateless: the application owns the rotation phase and advances it each frame — any monotonically increasing value works, only the fractional part is used. Pair with `App::is_animating` so the run loop keeps requesting redraws while the spinner is on screen. **When**: long-running operations with no known fraction (network calls, indexing, "waiting for compositor"). ```rust,no_run # use ltk::{ spinner, Spinner }; -# fn _ex() -> Spinner { -spinner().size( 24.0 ) -# } +# struct App { spinner_phase: f32 } +# impl App { fn _ex( &self ) -> Spinner { +spinner().phase( self.spinner_phase ).size( 24.0 ) +# }} ``` **See also**: [`progress_bar`](#progress_bar) for determinate progress. ### `toast` -Transient notification that floats over the surface and dismisses -itself after a timeout. +Transient notification pill anchored near the bottom of the surface. Not an `Element` — build it in `App::overlays` and return its `.overlay()` while a toast is pending. Auto-dismissal is the application's responsibility: `duration( secs )` only stores a value read back via `duration_value()`, so the app schedules its own "toast expired" timer and clears the state when it fires. **When**: confirmation snackbars ("Saved"), non-blocking errors, status flashes. ```rust,no_run -# use ltk::{ toast, Toast }; +# use ltk::{ toast, OverlaySpec }; # #[ derive( Clone ) ] enum Msg {} -# fn _ex() -> Toast { -toast( "Saved" ).duration( 3.0 ) +# struct App { toast_message: Option } +# impl App { +fn overlays( &self ) -> Vec> +{ + match &self.toast_message + { + Some( m ) => vec![ toast( m ).duration( 3.0 ).overlay() ], + None => vec![], + } +} # } ``` @@ -696,7 +700,7 @@ toast( "Saved" ).duration( 3.0 ) ### `tooltip` -Anchored hint that appears next to a target widget on hover or focus. +Anchored hint rendered below a target widget. Like [`toast`](#toast) it is not an `Element` — return its `.overlay()` from `App::overlays` while the hint should be visible. Hover detection and the show / hide delay are the application's responsibility; the automatic variant is `Button::tooltip( text )`, which shows the hint by itself after a pointer dwell on the button. **When**: discoverable affordances on icon-only controls, keyboard shortcut reminders, helper text that should not occupy permanent @@ -710,8 +714,8 @@ tooltip( "Ctrl+S", WidgetId( "btn/save" ) ).max_width( 240 ) # } ``` -The widget paints next to the widget that registered the matching -`WidgetId`. See [`docs/cookbook.md`](./cookbook.md) for the full +The overlay anchors below the widget built with the matching +`.id( WidgetId )`. See [`docs/cookbook.md`](./cookbook.md) for the full overlay wiring. **See also**: [`toast`](#toast) for transient self-dismissing @@ -719,26 +723,39 @@ notifications. ### `combo` -Editable single-select with a popup list. Supports type-to-filter -and free-text entry. +Select / dropdown with a popup list — single- or multi-select (`multi_select( true )` adds selection chips), with optional type-to-filter (`searchable( true )`). The widget is a stateless projection over an app-owned `ComboState`, and the app places **two** pieces: the trigger (`Combo::trigger()`) goes in the view tree like any widget, and the open popup is either layered into the same surface via `Combo::popup()` inside a [`stack`](#stack), or returned as a real xdg-popup from `App::overlays` via `Combo::overlay()`. -**When**: pick-one fields with too many options for a row of -[`radio`](#radio) buttons but where a free typed answer is also -valid (autocomplete, country list, theme picker). +**When**: pick-one (or pick-several) fields with too many options for +a row of [`radio`](#radio) buttons (autocomplete, country list, theme +picker). ```rust,no_run -# use ltk::{ combo, Combo, ComboState }; -# #[ derive( Clone ) ] enum Msg { Pick( usize ) } -# fn _ex( state: ComboState ) -> Combo { -let items = vec![ - "One".to_string(), - "Two".to_string(), - "Three".to_string(), -]; -combo( state, items ).on_select_idx( Msg::Pick ) +# use ltk::{ column, combo, Combo, ComboState, Element, OverlaySpec }; +# #[ derive( Clone ) ] enum Msg { Pick( usize ), ToggleOpen, Dismiss } +# struct App { fruits: ComboState } +# impl App { +# fn build_combo( &self ) -> Combo { +# let items = vec![ "One".to_string(), "Two".to_string(), "Three".to_string() ]; +combo( self.fruits.clone(), items ) + .on_toggle_open( Msg::ToggleOpen ) + .on_select_idx( Msg::Pick ) + .on_dismiss( Msg::Dismiss ) +# } +fn view( &self ) -> Element +{ + column().push( self.build_combo().trigger() ).into() +} + +fn overlays( &self ) -> Vec> +{ + self.build_combo().overlay().into_iter().collect() +} # } ``` +`update()` then flips `is_open` on `ToggleOpen` / `Dismiss` and writes +the selection into the `ComboState` on `Pick`. + **See also**: [`radio`](#radio) for small mutually-exclusive sets, [`notebook`](#notebook) for tabbed mode switches. @@ -812,10 +829,12 @@ dialog() `modal( false ) + dismiss_on_scrim( msg )` makes a tap on the dim background fire `msg`; combining `dismiss_on_scrim` with `modal( true )` -panics at lower time (the contracts contradict). `body( elem )` +panics when the dialog is converted to an `Element` — the `.into()` asserts with "dialog: dismiss_on_scrim is not valid when modal=true", since the two contracts contradict. `body( elem )` swaps a custom element in between the subtitle and the action row — the example app at `examples/dialog.rs` uses this for an in-dialog -slider. +slider. `max_width( … )` caps the card width; it accepts any `Length` +and defaults to `Length::fluid( 480.0 )` so the card scales with the +same curve as the stock buttons inside it. **See also**: [`toast`](#toast) for non-blocking transient notifications, [`combo`](#combo) for a single-pick popup that does @@ -895,9 +914,15 @@ the natural content width to the parent (otherwise the column claims the full available width). `center_y( true )` centres the content block vertically when there are no spacers. +Watch the defaults: `column()` starts with **16 px padding** on every +side (and 8 px spacing). A column nested inside an already-padded +container silently doubles the inset — pass `.padding( 0.0 )` when the +parent owns the margin. + ### `row` -Horizontal flow. Mirror of [`column`](#column) on the X axis. +Horizontal flow. Mirror of [`column`](#column) on the X axis — except +its default padding is `0`, not `column`'s 16 px. ```rust,no_run # use ltk::{ flex, row, Element }; diff --git a/examples/carousel.rs b/examples/carousel.rs index b1bd0a4..afdd1ea 100644 --- a/examples/carousel.rs +++ b/examples/carousel.rs @@ -9,11 +9,14 @@ //! //! The carousel widget itself is a stateless layout primitive — the //! `offset` (positive shifts content right) is owned by the host. The -//! example mutates that offset directly when Prev / Next / arrow keys -//! change the focused index; in a real touch-driven app the host would -//! drive it from drag / inertia / snap-ease. +//! example drives it three ways: Prev / Next buttons and arrow keys +//! snap to an index, and a pointer / touch drag pans it live through +//! the `App` horizontal-swipe hooks (`on_swipe_horizontal_progress` +//! for follow-the-finger, `on_swipe_left` / `on_swipe_right` for the +//! commit) — the same pattern crustace's homescreen pager uses. //! -//! Esc quits. Arrow keys = Prev / Next. +//! Esc quits. Arrow keys = Prev / Next. Drag horizontally to pan, or +//! step tile by tile with the mouse wheel. //! //! NOTE: ltk is a Wayland layer-shell toolkit. This example needs a //! running Wayland compositor. @@ -33,17 +36,19 @@ enum Message struct CarouselApp { - focused: usize, - offset: f32, - last_msg: String, + focused: usize, + offset: f32, + last_msg: String, + viewport_w: f32, + wheel_accum: f32, } const TILE_COUNT: usize = 7; -/// Approximate viewport width used to translate "focused index → offset". -/// Real apps would derive this from the laid-out viewport rect; for an -/// example the constant keeps the focus / step math obvious. -const VIEWPORT_W: f32 = 800.0; +/// Horizontal padding around the carousel (the root column's 16 px per +/// side) — subtracted from the surface width to get the carousel's real +/// viewport, so the snap / drag math matches what the widget draws. +const H_PADDING: f32 = 32.0; const FOCUSED_FRAC: f32 = 0.7; const GAP: f32 = 16.0; @@ -61,12 +66,12 @@ impl CarouselApp { fn new() -> Self { - Self { focused: 0, offset: 0.0, last_msg: String::new() } + Self { focused: 0, offset: 0.0, last_msg: String::new(), viewport_w: 800.0 - H_PADDING, wheel_accum: 0.0 } } fn snap_offset_for( &self, focused: usize ) -> f32 { - let stride = VIEWPORT_W * FOCUSED_FRAC + GAP; + let stride = self.viewport_w * FOCUSED_FRAC + GAP; -( focused as f32 ) * stride } } @@ -108,7 +113,7 @@ impl App for CarouselApp let status_line = if self.last_msg.is_empty() { - text( "← / → cycle · click Activate to fire Message::Tile · Esc quits" ) + text( "← / → cycle · drag or wheel to pan · click Activate to fire Message::Tile · Esc quits" ) .size( 12.0 ) .color( secondary ) .align_center() @@ -146,16 +151,18 @@ impl App for CarouselApp if self.focused > 0 { self.focused -= 1; - self.offset = self.snap_offset_for( self.focused ); } + // Always re-snap: a drag committed at the first / last + // tile leaves the strip displaced otherwise. + self.offset = self.snap_offset_for( self.focused ); } Message::Next => { if self.focused + 1 < TILE_COUNT { self.focused += 1; - self.offset = self.snap_offset_for( self.focused ); } + self.offset = self.snap_offset_for( self.focused ); } Message::Tile( i ) => { @@ -174,6 +181,62 @@ impl App for CarouselApp _ => None, } } + + fn on_resize( &mut self, width: u32, _height: u32 ) + { + self.viewport_w = width as f32 - H_PADDING; + // Keep the focused tile centred through window resizes. + self.offset = self.snap_offset_for( self.focused ); + } + + // Wheel / touchpad scroll steps the strip one tile at a time. One + // wheel detent arrives as ~100-150 units (the compositor's ~10-15 + // per detent times the runtime's wheel multiplier), touchpads as a + // continuous stream of small deltas — so accumulate up to a detent + // and step at most once per event, resetting the residue so a + // coarse wheel cannot burst through several tiles. + fn on_pointer_axis( &mut self, _x: f32, _y: f32, dx: f32, dy: f32 ) + { + const DETENT: f32 = 100.0; + // Wheels report one axis at a time; take the dominant one so a + // tilt-wheel or horizontal touchpad flick also pans the strip. + self.wheel_accum += if dx.abs() > dy.abs() { dx } else { dy }; + if self.wheel_accum.abs() < DETENT { return; } + let forward = self.wheel_accum > 0.0; + self.wheel_accum = 0.0; + if forward + { + if self.focused + 1 < TILE_COUNT { self.focused += 1; } + } else if self.focused > 0 { + self.focused -= 1; + } + self.offset = self.snap_offset_for( self.focused ); + } + + // Pointer / touch drag, the same pattern crustace's homescreen pager + // uses: live progress pans the strip, a committed swipe steps the + // focus, and the cancellation sample (0.0) snaps back. + + fn swipe_horizontal_threshold( &self ) -> f32 { 0.35 } + + fn on_swipe_horizontal_progress( &mut self, progress: f32 ) + { + // `progress` is dx / (threshold × width): ±1.0 marks the commit + // threshold. A release without commit delivers one final 0.0. + let dx = progress * ( self.viewport_w + H_PADDING ) * self.swipe_horizontal_threshold(); + let min = self.snap_offset_for( TILE_COUNT - 1 ); + self.offset = ( self.snap_offset_for( self.focused ) + dx ).clamp( min, 0.0 ); + } + + fn on_swipe_left( &mut self ) -> Option + { + Some( Message::Next ) + } + + fn on_swipe_right( &mut self ) -> Option + { + Some( Message::Prev ) + } } fn main() diff --git a/examples/clip_path.rs b/examples/clip_path.rs index 2405617..9962767 100644 --- a/examples/clip_path.rs +++ b/examples/clip_path.rs @@ -17,7 +17,9 @@ enum Msg {} struct Demo; -const CELL: f32 = 220.0; +/// Floor for the square cells — the orientation-derived size never +/// shrinks below this, so the shapes stay readable on tiny windows. +const CELL_MIN: f32 = 120.0; const MARGIN: f32 = 24.0; fn bg() -> Color { Color::rgba( 0.16, 0.18, 0.22, 1.0 ) } @@ -76,9 +78,9 @@ fn triangle_path( r: Rect ) -> Vec /// A cell that paints `bg()` everywhere, then `fg()` clipped to `shape`. The /// previous clip is snapshotted and restored so the clip does not leak to the /// rest of the frame. -fn clipped_cell( shape: fn( Rect ) -> Vec ) -> Element +fn clipped_cell( size: f32, shape: fn( Rect ) -> Vec ) -> Element { - External::cpu( CELL, CELL, move |canvas, rect| + External::cpu( size, size, move |canvas, rect| { canvas.fill_rect( rect, bg(), 0.0 ); let saved = canvas.clip_bounds(); @@ -94,17 +96,45 @@ impl App for Demo fn view( &self ) -> Element { + // Cells side by side in landscape, stacked in portrait; the + // runtime rebuilds the view on every configure, so both the + // arrangement and the cell size follow the window live. The + // square side comes from the axis the cells do NOT flow along + // (the height in landscape, the width in portrait), capped so + // the three of them also fit along the flow axis with the + // paddings and gaps accounted for. + let ( vw, vh ) = ltk::viewport_size(); + let ( vw, vh ) = ( vw as f32, vh as f32 ); + let cells: Element = match ltk::orientation() + { + ltk::Orientation::Landscape => + { + let cell = ( vh * 0.5 ).min( ( vw - 80.0 ) / 3.0 ).max( CELL_MIN ); + row() + .spacing( 16.0 ) + .push( clipped_cell( cell, |r| rounded_rect_path( r, 32.0 ) ) ) + .push( clipped_cell( cell, circle_path ) ) + .push( clipped_cell( cell, triangle_path ) ) + .into() + } + ltk::Orientation::Portrait => + { + let cell = ( vw * 0.55 ).min( ( vh - 130.0 ) / 3.0 ).max( CELL_MIN ); + column() + .padding( 0.0 ) + .spacing( 16.0 ) + .push( clipped_cell( cell, |r| rounded_rect_path( r, 32.0 ) ) ) + .push( clipped_cell( cell, circle_path ) ) + .push( clipped_cell( cell, triangle_path ) ) + .into() + } + }; + column() .spacing( 16.0 ) .padding( 24.0 ) .push( text( "Canvas::set_clip_path — rounded rect, circle, triangle" ) ) - .push( - row() - .spacing( 16.0 ) - .push( clipped_cell( |r| rounded_rect_path( r, 32.0 ) ) ) - .push( clipped_cell( circle_path ) ) - .push( clipped_cell( triangle_path ) ) - ) + .push( cells ) .into() } diff --git a/examples/dialog.rs b/examples/dialog.rs index 8433b04..deedbbd 100644 --- a/examples/dialog.rs +++ b/examples/dialog.rs @@ -13,8 +13,8 @@ //! between the subtitle and the action row. use ltk::{ - App, ButtonVariant, Element, - button, column, dialog, row, slider, spacer, stack, text, + App, ButtonVariant, Element, Keysym, + button, column, dialog, slider, spacer, stack, text, }; #[ derive( Clone, Copy, PartialEq, Eq ) ] @@ -76,7 +76,11 @@ impl App for DialogApp .color( secondary ) .wrap( true ) ); - let openers = row::() + // Stacked, not a row: a centered row overflows both edges as + // soon as the fluid button sizes outgrow the window, while a + // column clamps each button to the available width. + let openers = column::() + .padding( 0.0 ) .spacing( 12.0 ) .push( button::( "Modal confirm" ) @@ -144,6 +148,15 @@ impl App for DialogApp Msg::BrightnessChanged( v ) => { self.brightness = v; } } } + + // Only reached when no dialog is open: with one on screen, the ESC + // dispatch chain fires the scrim's `on_escape` (the dialog's cancel) + // before the app ever sees the key. First ESC closes, second quits. + fn on_key( &mut self, keysym: Keysym ) -> Option + { + if keysym == Keysym::Escape { std::process::exit( 0 ); } + None + } } fn modal_confirm() -> Element diff --git a/examples/responsive.rs b/examples/responsive.rs index fd56ce5..1fec330 100644 --- a/examples/responsive.rs +++ b/examples/responsive.rs @@ -19,7 +19,7 @@ use ltk:: { App, Element, Keysym, ButtonVariant, WidgetScaling, - button, checkbox, column, progress_bar, row, separator, slider, spacer, text, text_edit, toggle, + button, checkbox, column, grid, progress_bar, separator, slider, spacer, text, text_edit, toggle, set_widget_scaling, set_density, density, }; @@ -81,21 +81,22 @@ impl App for ResponsiveApp // Mode + density controls. Explicit sizes here so the controls stay // stable while the demo widgets below react to the mode. - let controls = row::() - .spacing( 12.0 ) + // Mode + density controls. Stock buttons like the demo widgets + // below, so they follow the active mode too. The switch gets the + // full width and the density pair half a cell each: every button + // clamps to its slot instead of overflowing a shared row, and the + // slots leave room for Physical-mode text at raised densities. + let controls = column::() + .padding( 0.0 ) + .spacing( 8.0 ) .push( button::( "Switch mode".to_string() ) .variant( ButtonVariant::Primary ) - .font_size( 16.0 ) - .height( 44.0 ) .on_press( Message::SwitchMode ) ) - .push( button::( "− density".to_string() ) - .font_size( 16.0 ) - .height( 44.0 ) - .on_press( Message::DensityDown ) ) - .push( button::( "+ density".to_string() ) - .font_size( 16.0 ) - .height( 44.0 ) - .on_press( Message::DensityUp ) ); + .push( grid::( 2 ) + .push( button::( "− density".to_string() ) + .on_press( Message::DensityDown ) ) + .push( button::( "+ density".to_string() ) + .on_press( Message::DensityUp ) ) ); // The demo widgets — NO explicit sizes, so they follow the mode. let demo = column::() diff --git a/examples/widgets.rs b/examples/widgets.rs index 4a646a2..ddd532f 100644 --- a/examples/widgets.rs +++ b/examples/widgets.rs @@ -75,15 +75,11 @@ impl App for WidgetsApp let title = text( "ltk widgets" ).size( 24.0 ).color( primary ).align_center(); - // Segmented tab strip — selection just rendered as a banner below. + // Segmented tab strip — each tab shows its own page below. let tabs_strip: Element = tabs( [ "General", "Audio", "Network" ] ) .selected( self.tab ) .on_select( Msg::SelectTab ) .into(); - let tab_label = format!( - "Active tab: {}", - [ "General", "Audio", "Network" ][ self.tab.min( 2 ) ], - ); let toggles = column::() .spacing( 0.0 ) @@ -124,29 +120,44 @@ impl App for WidgetsApp .id( WidgetId( "widgets/note" ) ) .on_change( Msg::NoteChanged ); + // One page per tab, so selecting a tab visibly swaps the content. + let page: Element = match self.tab.min( 2 ) + { + 0 => column::() + .padding( 0.0 ) + .spacing( 12.0 ) + .push( toggles ) + .push( separator() ) + .push( checkboxes ) + .push( separator() ) + .push( radios ) + .into(), + 1 => column::() + .padding( 0.0 ) + .spacing( 12.0 ) + .push( text( vol_label ).size( 13.0 ).color( secondary ) ) + .push( slider( self.volume ).on_change( Msg::SliderChanged ) ) + .push( text( prog_label ).size( 13.0 ).color( secondary ) ) + .push( progress_bar( self.progress ) ) + .into(), + _ => column::() + .padding( 0.0 ) + .spacing( 12.0 ) + .push( text( "Spinner" ).size( 13.0 ).color( secondary ) ) + .push( spin_row ) + .push( separator() ) + .push( text( "Multiline text edit" ).size( 13.0 ).color( secondary ) ) + .push( textarea ) + .into(), + }; + let content = column::() .padding( 32.0 ) .spacing( 12.0 ) .push( title ) .push( tabs_strip ) - .push( text( tab_label ).size( 13.0 ).color( secondary ) ) .push( separator() ) - .push( toggles ) - .push( separator() ) - .push( checkboxes ) - .push( separator() ) - .push( radios ) - .push( separator() ) - .push( text( vol_label ).size( 13.0 ).color( secondary ) ) - .push( slider( self.volume ).on_change( Msg::SliderChanged ) ) - .push( text( prog_label ).size( 13.0 ).color( secondary ) ) - .push( progress_bar( self.progress ) ) - .push( separator() ) - .push( text( "Spinner" ).size( 13.0 ).color( secondary ) ) - .push( spin_row ) - .push( separator() ) - .push( text( "Multiline text edit" ).size( 13.0 ).color( secondary ) ) - .push( textarea ) + .push( page ) .push( spacer() ) .push( text( "Esc = quit" ) diff --git a/src/app.rs b/src/app.rs index 549adb3..6857207 100644 --- a/src/app.rs +++ b/src/app.rs @@ -585,7 +585,9 @@ pub trait App: 'static /// /// Useful for embeddings that take over scrolling for their own /// content — for example forwarding the event to a WPE view that - /// owns a scrollable web page. + /// owns a scrollable web page — or for wheel-driven view state such + /// as a stepped carousel. The runtime rebuilds and repaints after + /// the hook returns, so state mutated here shows immediately. fn on_pointer_axis( &mut self, _x: f32, _y: f32, _dx: f32, _dy: f32 ) {} /// Raw multi-touch callbacks. Default: no-op. diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index 4dceb00..26af6bb 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -419,6 +419,7 @@ impl AppData // configure.new_size is surface-local (logical), so multiply by the // current buffer scale before handing the dimensions to the app. let sf = self.main.scale_factor.max( 1 ) as u32; + crate::types::set_viewport_size( w * sf, h * sf ); self.app.on_scale_changed( sf ); self.app.on_resize( w * sf, h * sf ); // `on_resize` may flip app-state that the view depends on (apps that diff --git a/src/event_loop/handlers.rs b/src/event_loop/handlers.rs index a3e5e84..28de1b4 100644 --- a/src/event_loop/handlers.rs +++ b/src/event_loop/handlers.rs @@ -99,6 +99,7 @@ impl CompositorHandler for AppData // `App::on_resize`. if matches!( focus, super::SurfaceFocus::Main ) { + crate::types::set_viewport_size( pw, ph ); self.app.on_scale_changed( new_factor as u32 ); self.app.on_resize( pw, ph ); self.dirty_caches(); diff --git a/src/input/pointer/scroll.rs b/src/input/pointer/scroll.rs index d327281..5a75ec7 100644 --- a/src/input/pointer/scroll.rs +++ b/src/input/pointer/scroll.rs @@ -83,6 +83,11 @@ impl AppData let dx = horizontal.absolute as f32 * multiplier; let dy = vertical.absolute as f32 * multiplier; self.app.on_pointer_axis( pos.x, pos.y, dx, dy ); + // The hook may mutate view-driving state (a wheel-stepped + // carousel, an embedder-scrolled canvas), so rebuild and + // repaint like the in-viewport branch above does. + self.dirty_caches(); + self.surface_mut( focus ).request_redraw(); } } } diff --git a/src/lib.rs b/src/lib.rs index aca05e0..99935db 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -159,6 +159,13 @@ //! `landscape` % of the **height** when it is landscape (the short side //! of each orientation, but with its own proportion). //! +//! When the *structure* of the layout should change with the +//! orientation — a row of panels in landscape, the same panels stacked +//! in portrait — branch the view on [`orientation()`] (backed by +//! [`viewport_size()`], recorded by the runtime on every configure and +//! sharing `orient`'s square-counts-as-portrait rule). See +//! `examples/clip_path.rs`. +//! //! For images, pair it with //! [`Image::short_side`](widget::image::Image::short_side), which sizes //! the image along the screen's short side and lets the other axis follow @@ -332,6 +339,7 @@ pub use types::{ WidgetScaling, FLUID_MIN, FLUID_MAX }; pub use types::{ fluid_reference, set_fluid_reference }; pub use types::{ density, set_density }; pub use types::{ widget_scaling, set_widget_scaling }; +pub use types::{ Orientation, orientation, viewport_size, set_viewport_size }; pub use widget::{ Element, button, icon_button, text_edit, image as img_widget, text, container }; pub use text_shaping::measure_text; pub use widget::rich_text::{ rich_text, RichText, LinkSpan }; diff --git a/src/theme/accessors.rs b/src/theme/accessors.rs index 3493ded..4426801 100644 --- a/src/theme/accessors.rs +++ b/src/theme/accessors.rs @@ -104,7 +104,7 @@ pub fn window_controls() -> WindowControlsSpec default_window_controls( Palette::from_slots( &mode.slots ) ) } -/// The eight canonical palette slots of the active mode projected as a +/// The ten canonical palette slots of the active mode projected as a /// [`Palette`] struct. This is a one-call shortcut equivalent to /// `Palette::from_slots(&active_document().mode(active_mode()).slots)`, /// covering the common case where a widget needs `text_primary` / diff --git a/src/theme/assets.rs b/src/theme/assets.rs index f962f65..2cd3e0f 100644 --- a/src/theme/assets.rs +++ b/src/theme/assets.rs @@ -312,9 +312,9 @@ pub fn tint_symbolic( rgba: &[u8], tint: Color ) -> Vec /// Process-wide cache of rasterised theme icons, keyed by (absolute path on /// disk, target longest-edge size in physical pixels). Entries are produced -/// by [`icon_rgba`] and never invalidated — the key embeds the absolute path -/// and the icon files are read-only on disk, so a `set_active_document` -/// switch produces fresh keys rather than serving stale data. +/// by [`icon_rgba`]. The key embeds the absolute path and the icon files +/// are read-only on disk, so entries never go stale; [`clear_svg_cache`] +/// empties the map on `set_active_document` purely to drop dead memory. static SVG_CACHE: Mutex>, u32, u32 )>>> = Mutex::new( None ); diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 6b20c6f..5a91567 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -16,10 +16,10 @@ //! /usr/share/ltk/themes// (system overlay, lower priority) //! ~/.local/share/ltk/themes// (user overlay, higher priority) //! theme.json -//! background-light.png -//! background-dark.png -//! fonts/ -//! Sora-Regular.ttf +//! branding/{light,dark}/ wallpaper, lockscreen, logos +//! icons/apps/ per-application icons +//! icons/catalogue/{filled,line}/ symbolic glyph catalogue +//! cursors/ + cursor.theme consumed by the compositor //! ``` //! //! Paths inside `theme.json` are interpreted relative to the theme's @@ -35,11 +35,13 @@ //! [`palette()`], …) cover the common patterns without going through the //! full document. //! -//! There is **no in-code fallback**: if `ensure_active` cannot locate the -//! `default` theme in any search path, the process aborts with a message -//! pointing at the `ltk-theme-default` Debian package (it `Provides: -//! ltk-theme`) or at the `LTK_THEMES_DIR` environment variable for -//! development installations. +//! When `ensure_active` cannot locate the `default` theme in any search +//! path, an embedded B/W fallback document is installed instead and the +//! process keeps running: [`is_fallback_active`] flips on, the draw path +//! stamps a red warning banner, and stderr points at the +//! `ltk-theme-default` Debian package (it `Provides: ltk-theme`) or at +//! the `LTK_THEMES_DIR` environment variable for development +//! installations. use std::sync::{ Arc, OnceLock }; diff --git a/src/theme/palette.rs b/src/theme/palette.rs index bc3808d..833c7b6 100644 --- a/src/theme/palette.rs +++ b/src/theme/palette.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -//! The eight-slot semantic [`Palette`] every widget speaks in terms of, plus +//! The ten-slot semantic [`Palette`] every widget speaks in terms of, plus //! the derived [`WindowControlsSpec`] fallback used when a theme document //! omits the explicit `window_controls` block. @@ -46,10 +46,11 @@ pub struct Palette impl Palette { - /// Project a [`SlotStore`] onto the eight canonical palette fields. + /// Project a [`SlotStore`] onto the ten canonical palette fields. /// Slot ids are the ones declared in the default theme JSON /// (`bg-page`, `surface`, `surface-alt`, `text-primary`, - /// `text-secondary`, `accent`, `divider`, `icon`). Missing slots + /// `text-secondary`, `accent`, `divider`, `icon`, `danger`, + /// `danger-bg`). Missing slots /// fall back to a documented sensible default so downstream widgets /// never see uninitialised colours. Used by [`crate::theme::palette()`] and /// [`crate::theme::window_controls`]. diff --git a/src/theme/slots.rs b/src/theme/slots.rs index a99eab1..4e95011 100644 --- a/src/theme/slots.rs +++ b/src/theme/slots.rs @@ -63,8 +63,8 @@ pub enum Slot Paint { value: Paint, meta: Metadata }, /// An ordered stack of outer shadows, typically an elevation level. Shadows { value: Vec, meta: Metadata }, - /// A composite surface: fill, outer shadows (ref or inline), inset - /// shadows and an optional backdrop. + /// A composite surface: fill, outer shadows (ref or inline) and + /// inset shadows. Surface { value: Surface, meta: Metadata }, /// A resolved text style (family, weight, size, line-height, …). TextStyle { value: TextStyle, meta: Metadata }, diff --git a/src/types.rs b/src/types.rs index 78b03d8..cd831e0 100644 --- a/src/types.rs +++ b/src/types.rs @@ -703,6 +703,48 @@ pub fn density() -> f32 f32::from_bits( DENSITY_BITS.load( Ordering::Relaxed ) ) } +/// Orientation of the main surface, derived from the dimensions recorded +/// by [`set_viewport_size`]. +#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ] +pub enum Orientation +{ + Portrait, + Landscape, +} + +static VIEWPORT_W: AtomicU32 = AtomicU32::new( 0 ); +static VIEWPORT_H: AtomicU32 = AtomicU32::new( 0 ); + +/// Record the main surface's physical dimensions. The runtime calls this +/// on every configure, before `App::on_resize`; embedders driving +/// [`core::UiSurface`](crate::core::UiSurface) directly should call it +/// themselves if they want [`viewport_size`] / [`orientation`] to reflect +/// their surface. +pub fn set_viewport_size( width: u32, height: u32 ) +{ + VIEWPORT_W.store( width, Ordering::Relaxed ); + VIEWPORT_H.store( height, Ordering::Relaxed ); +} + +/// Physical dimensions of the main surface as of the last configure. +/// `( 0, 0 )` before the first one. +pub fn viewport_size() -> ( u32, u32 ) +{ + ( VIEWPORT_W.load( Ordering::Relaxed ), VIEWPORT_H.load( Ordering::Relaxed ) ) +} + +/// Orientation of the main surface: [`Orientation::Landscape`] when wider +/// than tall, [`Orientation::Portrait`] otherwise (square counts as +/// portrait, matching [`Length::orient`]'s resolution rule). Usable +/// straight from `view()` to pick a row or a column arrangement without +/// tracking `on_resize` by hand — the runtime rebuilds the view on every +/// resize, so a layout branched on this follows the window live. +pub fn orientation() -> Orientation +{ + let ( w, h ) = viewport_size(); + if w > h { Orientation::Landscape } else { Orientation::Portrait } +} + /// How a stock widget adapts its intrinsic geometry to the display when the /// app does not override it. The two modes ltk offers, chosen per process /// with [`set_widget_scaling`]: diff --git a/src/widget/dialog/mod.rs b/src/widget/dialog/mod.rs index 84a8e2a..db54e67 100644 --- a/src/widget/dialog/mod.rs +++ b/src/widget/dialog/mod.rs @@ -68,7 +68,7 @@ use crate::layout::column::column; use crate::layout::row::row; use crate::layout::spacer::spacer; use crate::layout::stack::stack; -use crate::types::{ Color, Corners }; +use crate::types::{ Color, Corners, Length }; use super::container::container; use super::pressable::pressable; @@ -80,8 +80,8 @@ mod tests; /// Default scrim opacity over the underlying surface. pub const SCRIM_ALPHA: f32 = 0.45; -/// Default card max-width (logical pixels). Override with -/// [`Dialog::max_width`]. +/// Default card max-width design size, applied as `Length::fluid` so +/// the card tracks the surface. Override with [`Dialog::max_width`]. pub const DEFAULT_MAX_WIDTH: f32 = 480.0; /// Default card corner radius. pub const CARD_RADIUS: f32 = 16.0; @@ -119,7 +119,7 @@ pub struct Dialog /// while the dialog is on screen. Wire this to the same message /// your "Cancel" action button uses. pub( crate ) cancel_msg: Option, - pub( crate ) max_width: f32, + pub( crate ) max_width: Length, } impl Default for Dialog @@ -145,7 +145,7 @@ impl Dialog modal: true, dismiss_msg: None, cancel_msg: None, - max_width: DEFAULT_MAX_WIDTH, + max_width: Length::fluid( DEFAULT_MAX_WIDTH ), } } @@ -209,11 +209,13 @@ impl Dialog self } - /// Override the card's maximum width in logical pixels. Default - /// is `480.0`. - pub fn max_width( mut self, w: f32 ) -> Self + /// Override the card's maximum width. Accepts logical `f32` + /// pixels or any [`Length`]. Default is `Length::fluid( 480.0 )`, + /// so the card scales with the same curve as the stock buttons + /// inside it. + pub fn max_width( mut self, w: impl Into ) -> Self { - self.max_width = w; + self.max_width = w.into(); self } @@ -230,8 +232,11 @@ impl From> for Element let palette = crate::theme::palette(); - // 1. Inner card column: title, subtitle, body, actions. - let mut card_col = column::().spacing( SECTION_GAP ); + // 1. Inner card column: title, subtitle, body, actions. The + // card's interior spacing is CARD_PADDING on the container — + // zero here, or the column's 16 px default would stack on top + // of it and steal width from the actions row. + let mut card_col = column::().spacing( SECTION_GAP ).padding( 0.0 ); if let Some( title ) = d.title { card_col = card_col.push( @@ -286,11 +291,14 @@ impl From> for Element // 4. Center the card on the screen. The outer column claims // the full surface; `center_y` + `align_center_x` keep the // card vertically and horizontally centered, and `max_width` - // caps it at `d.max_width` even on ultra-wide layouts. + // caps it at `d.max_width` even on ultra-wide layouts. The + // explicit padding is the card's minimum margin to the + // surface edges on narrow windows. let centered = column::() .center_y( true ) .align_center_x( true ) .max_width( d.max_width ) + .padding( 16.0 ) .push( card_swallow ); // 5. Scrim — a full-bleed Pressable with the dim layer diff --git a/src/widget/dialog/tests.rs b/src/widget/dialog/tests.rs index 69fadce..4e309fe 100644 --- a/src/widget/dialog/tests.rs +++ b/src/widget/dialog/tests.rs @@ -18,7 +18,7 @@ fn new_defaults_are_modal_with_no_content() assert!( d.actions.is_empty() ); assert!( d.dismiss_msg.is_none() ); assert!( d.cancel_msg.is_none() ); - assert_eq!( d.max_width, DEFAULT_MAX_WIDTH ); + assert_eq!( d.max_width, crate::types::Length::fluid( DEFAULT_MAX_WIDTH ) ); } #[ test ] @@ -77,7 +77,7 @@ fn cancel_builder_records_escape_message() fn max_width_builder_overrides_default() { let d = Dialog::::new().max_width( 720.0 ); - assert_eq!( d.max_width, 720.0 ); + assert_eq!( d.max_width, crate::types::Length::px( 720.0 ) ); } #[ test ] diff --git a/src/widget/list_item/mod.rs b/src/widget/list_item/mod.rs index 55d4462..d510a9e 100644 --- a/src/widget/list_item/mod.rs +++ b/src/widget/list_item/mod.rs @@ -40,7 +40,7 @@ pub struct ListItem /// present). pub( crate ) label: String, /// Optional secondary line drawn below the label in muted colour. - /// Doubles the row height when set. + /// Makes the row taller when set. pub( crate ) subtitle: Option, /// Optional right-aligned text (current setting, badge count). /// Drawn in muted colour. @@ -109,7 +109,7 @@ impl ListItem self } - /// Add a secondary line below the label. Doubles the row height to + /// Add a secondary line below the label. Makes the row taller to /// fit both lines comfortably. pub fn subtitle( mut self, s: impl Into ) -> Self { diff --git a/src/widget/separator/mod.rs b/src/widget/separator/mod.rs index 05781ac..fc1fc34 100644 --- a/src/widget/separator/mod.rs +++ b/src/widget/separator/mod.rs @@ -9,7 +9,7 @@ mod theme; /// A horizontal divider line. /// -/// Renders a 1 px (default) line across the full width of its layout rect, +/// Renders a thin line across the full width of its layout rect, /// with vertical padding above and below. Use to break a column into /// visual sections — between settings groups, list categories or content /// blocks. The line takes the divider colour from the active theme by @@ -44,8 +44,8 @@ pub struct Separator impl Separator { - /// Create a separator with the theme's default divider colour and - /// 1 px thickness. + /// Create a separator with the theme's default divider colour; the + /// thickness follows the widget-scaling default unless overridden. pub fn new() -> Self { Self @@ -115,7 +115,8 @@ impl Separator } } -/// Create a default [`Separator`] (theme divider colour, 1 px thickness). +/// Create a default [`Separator`] (theme divider colour, widget-scaling +/// default thickness unless overridden). /// /// ```rust,no_run /// # use ltk::{ column, separator, text, Element }; diff --git a/src/widget/text_edit/mod.rs b/src/widget/text_edit/mod.rs index e061d45..4f034e5 100644 --- a/src/widget/text_edit/mod.rs +++ b/src/widget/text_edit/mod.rs @@ -143,7 +143,7 @@ pub struct TextEdit /// default for forms but wrong when the field needs to be sized /// to fit a fixed number of glyphs (date / time pickers, inline /// numeric inputs). - pub( crate ) fixed_width: Option, + pub( crate ) fixed_width: Option, /// Font size in pixels for the single-line draw path. `0.0` (the /// Label font size. `None` follows the process [`crate::WidgetScaling`] /// mode at the theme default (via [`crate::Canvas::font_px`]); a @@ -299,10 +299,12 @@ impl TextEdit } /// Override the preferred width reported to the parent layout. - /// Pass `None` (default) to fall back to claiming `max_width`. - pub fn fixed_width( mut self, w: f32 ) -> Self + /// Accepts logical `f32` pixels or any [`Length`] — pair a + /// [`Length::fluid`] width with a fluid font size so box and glyphs + /// scale together. Without this the field claims `max_width`. + pub fn fixed_width( mut self, w: impl Into ) -> Self { - self.fixed_width = Some( w ); + self.fixed_width = Some( w.into() ); self } @@ -442,7 +444,7 @@ impl TextEdit ( max_width, h ) } else { let w = self.fixed_width - .map( |fw| fw.min( max_width ) ) + .map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ).min( max_width ) ) .unwrap_or( max_width ); let h = self.height .map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) ) diff --git a/src/widget/text_edit/tests.rs b/src/widget/text_edit/tests.rs index 5c93c33..a2069be 100644 --- a/src/widget/text_edit/tests.rs +++ b/src/widget/text_edit/tests.rs @@ -469,7 +469,7 @@ fn borderless_builder_toggles_flag() fn fixed_width_builder_stores_value() { let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).fixed_width( 72.0 ); - assert_eq!( t.fixed_width, Some( 72.0 ) ); + assert_eq!( t.fixed_width, Some( crate::types::Length::px( 72.0 ) ) ); } #[ test ] diff --git a/src/widget/time_picker/mod.rs b/src/widget/time_picker/mod.rs index 32352b5..c1b9689 100644 --- a/src/widget/time_picker/mod.rs +++ b/src/widget/time_picker/mod.rs @@ -317,7 +317,7 @@ impl TimePicker let snapshot = value; text_edit::( "", display ) .borderless( true ) - .fixed_width( 72.0 ) + .fixed_width( Length::fluid( 72.0 ) ) .font_size_fluid( theme::VAL_FS ) .align( TextAlign::Center ) .select_on_focus( true )