docs overhaul, orientation API, fluid-sizing fixes, examples made honest
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

Documentation pass: every claim in docs/ and the meta files was audited against the source and the drift fixed — around ninety corrections. CONTRIBUTING and the CI workflow now run cargo test with --features test-support (the gated test_support module made both the documented commands and the CI build fail to compile), make example becomes make examples, make doctest-md and the debhelper requirement of make clean are documented, and patch shape asks for a CHANGELOG entry. theming.md loses the nonexistent surface.backdrop, gains the real gradient defaults (linear-rgb, oklab), the six slot variants including typography, the ten-field palette, a truthful effects-consumer table, the ThemePreference/from_hour API and a responsive-sizing note; the stale docstrings in src/theme that fed the drift are fixed too. architecture.md's "Known gaps" section is rewritten against reality (multi-touch slots, xdg-activation, a11y live regions and SetValue/Increment/Decrement are implemented), gains a module map, subsurfaces and window-lifecycle coverage, and correct crustace/loginmanager paths. widgets.md fixes the ten factual errors (stateless spinner, toast/combo via overlays(), tooltip hover contract, row has no max_width, scroll axes, multiline text_edit, dialog panic wording) and now states the column() 16 px default padding — the recurring ambush — plus row's differing 0 default and dialog's max_width. onboarding, README and cookbook get the remaining sweep: build/test instructions, complete example lists, img_widget, clipping-parity honesty, ~30 Hz software cap, read_rgba_pixels signature, tab indentation in snippets, and rustdoc-style links that rendered literally are gone everywhere. CHANGELOG is restructured per Keep a Changelog with the missing entries (window_resizable, claims_raw_touch, Row::align_top/fill_height, caret fixes, dependency pins) and the pad_v Added/Changed contradiction resolved.
New adaptive-layout API: ltk::orientation() with the Orientation enum, backed by viewport_size()/set_viewport_size — the runtime records the main surface's physical dimensions on every configure, before App::on_resize, so view() can branch a layout on portrait vs landscape without hand-tracking resizes. The portrait rule matches Length::orient (square counts as portrait); embedders driving core::UiSurface call set_viewport_size themselves. Documented in the crate root's responsive-design section and architecture.md.
Fluid-vs-fixed sizing fixes in widgets, all the same disease — fluid content inside a fixed-pixel box. TextEdit::fixed_width takes impl Into<Length> (f32 call sites keep compiling as px) and the time picker's digit fields move to Length::fluid( 72.0 ), matching their fluid font so digits can no longer outgrow the box. Dialog::max_width takes impl Into<Length> with a Length::fluid( 480.0 ) default so the card scales with the stock buttons inside it, and the card's interior no longer stacks the column() default 16 px padding on top of CARD_PADDING — that double inset squeezed the action row until its buttons clipped on narrow windows. App::on_pointer_axis now triggers a view rebuild and repaint; previously state mutated in the hook did not paint until the next unrelated event.
Examples reworked to be honest demos: responsive's mode/density controls become stock buttons in a grid/column so they follow the modes they demonstrate instead of overflowing; dialog's openers stack vertically, and the example gains the app-level ESC handler so the ESC chain closes an open dialog first and quits second; widgets' tab strip now switches real per-tab pages; carousel gains pointer/touch drag through the horizontal-swipe hooks (crustace's pager pattern), one-tile-per-detent mouse wheel, and snap math driven by the real surface width from on_resize instead of a hardcoded 800; clip_path arranges its cells by ltk::orientation() and sizes them from the counter-axis of the flow.
This commit is contained in:
2026-07-30 19:28:26 +02:00
parent 14572ebfb6
commit 1fd697aa6d
33 changed files with 1131 additions and 661 deletions

View File

@@ -56,10 +56,10 @@ jobs:
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
- name: Build - name: Build
run: cargo build --workspace --all-targets run: cargo build --workspace --all-targets --features test-support
- name: Test - 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 # External-Markdown doctests. `cargo test --doc` only sees doctests
# inside `src/`; the cookbook and widget reference under `docs/` # inside `src/`; the cookbook and widget reference under `docs/`

View File

@@ -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::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<Length> )`** — per-item override of the horizontal inset between the row edge and its content; without it the theme default (16 px) applies. - **`ListItem::pad_h( impl Into<Length> )`** — 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. - **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<Length>`, 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). - **`Button::font_size` / `height` / `width`** and **`TextEdit::height`** builders, all `impl Into<Length>`, 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`. - **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). - **`test-support` Cargo feature** gates the `test_support` module so third-party builds never see it (ltk's own `make test` enables it).
### Changed ### 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. - **`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<Length>` (were `f32`), resolved like the button label (font space) / geometry space; the `f32` sentinels are gone (`f32` call sites still compile via `Into<Length>`). - **`TextEdit::font_size`**, **`TextEdit::fixed_width`** and **`Separator::thickness`** now take `impl Into<Length>` (were `f32`), resolved like the button label (font space) / geometry space; the `f32` sentinels are gone (`f32` call sites still compile via `Into<Length>`).
- **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. - **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`). - **Widget struct fields are now `pub( crate )`** (configured through builders), except the value / state types apps read or construct (`Time`, `Date`, `ComboState`).
### Fixed ### 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<Length>` (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. - **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. 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. - Default theme launcher SVGs replaced with renderer-compatible versions.
## [0.1.0] 2026-03-10 ## [0.1.0] - 2026-03-10
### Added
- Initial release. - 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.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 [0.1.0]: https://github.com/liberux/ltk/releases/tag/v0.1.0

View File

@@ -37,20 +37,29 @@ sudo apt-get install \
git clone <repo> git clone <repo>
cd ltk cd ltk
cargo build 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: The `Makefile` wraps the common targets:
```bash ```bash
make all # cargo build --release 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 audit # cargo audit (installs cargo-audit on first run)
make doc # cargo doc --no-deps make doc # cargo doc --no-deps
make example # run every example under examples/ in turn make examples # run every example under examples/ in turn
make clean 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 Running the examples requires a Wayland session and the default theme
on disk: 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 - Keep changes focused: one logical change per pull request. A bug fix
and a refactor go in separate PRs. 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 surface, and a contribution that adds behaviour without test
coverage will get review pushback. See [`tests/`](tests/) for coverage will get review pushback. See [`tests/`](tests/) for
examples of integration tests via `UiSurface`, and the inline 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 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 (`0.x.0 → 0.(x+1).0`); patch versions (`0.x.y → 0.x.(y+1)`) keep
source compatibility. 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. again, but it is faster for both of us if your local run is clean.
## Architectural decisions worth knowing ## Architectural decisions worth knowing
A few patterns recur across the codebase: 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<Length> ) -> Self`).
Chaining works because every builder returns `Self`. Don't introduce Chaining works because every builder returns `Self`. Don't introduce
setters that take `&mut self`. setters that take `&mut self`.
- **Layouts and widgets share `Element<Msg>`.** Anything that converts - **Layouts and widgets share `Element<Msg>`.** Anything that converts
to `Element` can be pushed into any layout. The split between to `Element` can be pushed into any layout. The split between
[`crate::layout`] and [`crate::widget`] is documentation, not [`src/layout/`](src/layout/) and [`src/widget/`](src/widget/) is
architecture. documentation, not architecture.
- **The runtime is single-threaded.** Use `RefCell` for caches inside - **The runtime is single-threaded.** Use `RefCell` for caches inside
`App` state, never `Mutex`. Cross-thread communication goes through `App` state, never `Mutex`. Cross-thread communication goes through
[`ChannelSender`](src/app.rs). [`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 - **Theming is process-global.** There is no per-app theme; the active
document and mode live in a `RwLock<Arc<…>>`. `view()` reads the document and mode live in a `RwLock<Arc<…>>`. `view()` reads the
state, never writes it. Mode flips and document swaps go through 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 - **Per-frame allocations are fine.** Building the `Element` tree on
every render is the supported model. Don't try to retain widgets every render is the supported model. Don't try to retain widgets
across frames. across frames.

View File

@@ -46,6 +46,7 @@ examples:
LTK_THEMES_DIR=themes cargo run --release --example pickers 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 dialog
LTK_THEMES_DIR=themes cargo run --release --example carousel LTK_THEMES_DIR=themes cargo run --release --example carousel
LTK_THEMES_DIR=themes cargo run --release --example clip_path
clean: clean:
dh_clean dh_clean

View File

@@ -87,6 +87,8 @@ Add `ltk` to your `Cargo.toml`:
ltk = { path = "../ltk" } ltk = { path = "../ltk" }
``` ```
If you consume `ltk` from outside this workspace, replace the `path` dependency with a versioned one.
Minimal app: Minimal app:
```rust ```rust
@@ -183,6 +185,8 @@ Useful entry points in this repository:
- `cargo run --example dialog` - `cargo run --example dialog`
- `cargo run --example sliders` - `cargo run --example sliders`
- `cargo run --example pickers` - `cargo run --example pickers`
- `cargo run --example carousel`
- `cargo run --example clip_path`
- `cargo run --example mini_shell` - `cargo run --example mini_shell`
In general: In general:
@@ -199,7 +203,7 @@ Most applications should start with this subset:
- `App` - `App`
- `Element<Msg>` - `Element<Msg>`
- 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` - layouts such as `column`, `row`, `stack`, `grid`, `spacer`
- `Color` - `Color`
- `run` - `run`
@@ -265,9 +269,11 @@ The main rules for downstream applications are:
- keep `view()` pure and cheap - keep `view()` pure and cheap
- do not perform I/O inside `view()` - do not perform I/O inside `view()`
- use `poll_interval()` sparingly - 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 - 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: The library already provides:
- event-driven redraw scheduling - 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 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 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 single-rect / path clipping all paint identically on both paths. The gaps are in
shadow / backdrop pipeline. 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 Effects that currently render only on the **GLES** backend, and degrade on the
**Software** backend: **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 Closing this gap (porting the shadow / inset-shadow pipeline to tiny-skia) is
on the post-v0.1 roadmap. on the roadmap.
## Documentation ## 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/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`. | | [`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. | | `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. | | [`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. | | [`CONTRIBUTING.md`](CONTRIBUTING.md) | Build, test, code style, patch shape. |

View File

@@ -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 first. This document assumes you already know how to run an example and what
kind of application surface you are trying to build. 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: 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. - **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. 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 `ltk::runtime`. If you only want to build a plain app window, stay with
`docs/onboarding.md` and the `ltk::window` surface first. `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 ## Mental model
ltk is Elm-shaped. The application is a value implementing [`App`]; ltk drives the loop and the application reacts. ltk is Elm-shaped. The application is a value implementing `App`; ltk drives the loop and the application reacts.
Every frame: ltk calls `view()` and `overlays()`, lays out the returned tree(s), draws them, and dispatches input events back as `Message` values which are fed to `update()`. There are no retained widgets. `Element<Msg>` is rebuilt from scratch every frame from the application's own state. Every frame: ltk calls `view()` and `overlays()`, lays out the returned tree(s), draws them, and dispatches input events back as `Message` values which are fed to `update()`. There are no retained widgets. `Element<Msg>` is rebuilt from scratch every frame from the application's own state.
@@ -72,23 +96,32 @@ In practice, that model is easiest to adopt in three steps:
**Implement for animations and focus** **Implement for animations and focus**
- `is_animating()` — return `true` while a tween is running; the loop redraws at ~60 Hz. - `is_animating()` — return `true` while a tween is running; the loop redraws on every compositor frame callback (~60 Hz on a typical display, capped at ~30 Hz on the software backend — see *Performance*).
- `take_focus_request()``Option<WidgetId>` — pull-once focus retargeting. - `take_focus_request()``Option<WidgetId>` — pull-once focus retargeting.
- `on_text_input_focused(active)` — surface IME state. - `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. 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: 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::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::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. That is the intended order of adoption for third-party users.
## Surface composition ## Surface composition
The main surface is what `view()` paints. `overlays()` returns a `Vec<OverlaySpec<Msg>>` describing additional layer-shell surfaces that should exist this frame. The runtime diffs that list against the previous frame using [`OverlayId`]: The main surface is what `view()` paints. `overlays()` returns a `Vec<OverlaySpec<Msg>>` describing additional layer-shell surfaces that should exist this frame. The runtime diffs that list against the previous frame using `OverlayId`:
- Same id present last frame and this frame → keep the surface alive, only re-render its `view`. - 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. - New id → create a new layer-shell surface.
@@ -96,7 +129,7 @@ The main surface is what `view()` paints. `overlays()` returns a `Vec<OverlaySpe
This is why crustace declares stable `const OVERLAY_LAUNCHER: OverlayId = OverlayId(1)` etc. at the top of `app.rs`. Don't allocate ids dynamically — diffing relies on stability. This is why crustace declares stable `const OVERLAY_LAUNCHER: OverlayId = OverlayId(1)` etc. at the top of `app.rs`. Don't allocate ids dynamically — diffing relies on stability.
Each overlay carries its own `view`, `anchor`, `size`, `layer`, `keyboard_exclusive`, `input_region`, and `on_dismiss`. The `Message` type is shared with the main app: a button inside an overlay produces the same `Msg` that a button on the main surface would, and `update()` handles both. There is no per-overlay state machine — overlays are pure projections of `App` state. Each overlay carries its own `view`, `anchor`, `anchor_widget_id`, `size`, `layer`, `exclusive_zone`, `keyboard_exclusive`, `input_region`, and `on_dismiss`. The `Message` type is shared with the main app: a button inside an overlay produces the same `Msg` that a button on the main surface would, and `update()` handles both. There is no per-overlay state machine — overlays are pure projections of `App` state.
`on_dismiss` is fired by three independent paths: a `popup_done` event from the compositor (xdg-popup mode); a pointer / touch press on the main surface that does not land on the trigger pointed at by `anchor_widget_id` while the overlay is mapped (covers compositors that route the button to the parent surface instead of breaking the popup grab); and Escape pressed while at least one xdg-popup overlay is open. The application only has to flip its `is_open` flag to `false` in `update()`; the runtime tolerates the message arriving more than once for the same open / close cycle. `on_dismiss` is fired by three independent paths: a `popup_done` event from the compositor (xdg-popup mode); a pointer / touch press on the main surface that does not land on the trigger pointed at by `anchor_widget_id` while the overlay is mapped (covers compositors that route the button to the parent surface instead of breaking the popup grab); and Escape pressed while at least one xdg-popup overlay is open. The application only has to flip its `is_open` flag to `false` in `update()`; the runtime tolerates the message arriving more than once for the same open / close cycle.
@@ -109,6 +142,8 @@ Common patterns:
Overlays do not nest. A "submenu inside the quick settings panel" is just a second overlay with a different id whose `view()` builds the submenu. Crustace uses this for the WiFi and Bluetooth pickers. Overlays do not nest. A "submenu inside the quick settings panel" is just a second overlay with a different id whose `view()` builds the submenu. Crustace uses this for the WiFi and Bluetooth pickers.
Separate from overlays, `subsurfaces()` returns `SubsurfaceSpec` entries: input-transparent `wl_subsurface` children composited over the main surface and diffed by id, like overlays. They are the cheap way to move a pre-rendered element around every frame — the compositor repositions the subsurface instead of the app repainting the whole tree. Loginmanager's slide-to-unlock reveal is the reference.
If your application does not need overlays or layer-shell, you can ignore this If your application does not need overlays or layer-shell, you can ignore this
entire section and stay in the `ltk::window` subset. entire section and stay in the `ltk::window` subset.
@@ -116,8 +151,8 @@ entire section and stay in the `ltk::window` subset.
`ltk::theme` exposes a process-wide active theme. Three layers: `ltk::theme` exposes a process-wide active theme. Three layers:
1. **Document** — a [`ThemeDocument`] loaded from disk (`/usr/share/ltk/themes/<id>/theme.json`). Each document carries a `light` and `dark` [`Mode`] with a typed [`SlotStore`] (colors, paints, shadows, surfaces, text styles), wallpaper/lockscreen/launcher specs and a shared `fonts` block. When the `default` document cannot be located ltk falls back to an embedded B/W theme + embedded Sora Regular font, logs a stderr warning, and stamps every frame with a red banner pointing at the `ltk-theme-default` Debian package so the missing-theme signal is visible without the process aborting. `ltk::is_fallback_active()` exposes the state for apps that want to react programmatically. 1. **Document** — a `ThemeDocument` loaded from disk (`/usr/share/ltk/themes/<id>/theme.json`). Each document carries a `light` and `dark` `Mode` with a typed `SlotStore` (colors, paints, shadows, surfaces, text styles), wallpaper/lockscreen/launcher specs and a shared `fonts` block. When the `default` document cannot be located ltk falls back to an embedded B/W theme + embedded Sora Regular font, logs a stderr warning, and stamps every frame with a red banner pointing at the `ltk-theme-default` Debian package so the missing-theme signal is visible without the process aborting. `ltk::is_fallback_active()` exposes the state for apps that want to react programmatically.
2. **Mode**[`ThemeMode::Light`] or `Dark`; flips which mode of the document is active. 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. 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: 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. 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 ## 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()`: 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 ```rust,no_run
# use std::time::Instant; # 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: A four-button demo can keep all state in one struct and one flat `Msg` enum. Anything bigger needs structure. Conventions used by crustace and loginmanager:
**One module per screen / panel.** Each module owns its sub-state struct and its sub-message enum, and exposes `fn view(...) -> Element<AppMsg>` and `fn update(&mut self, msg: SubMsg)` (or the parent inlines those calls). See `crustace/src/homescreen.rs`, `launcher/`, `notifications.rs`, `powermenu.rs`. **One module per screen / panel.** Each module owns its sub-state struct and its sub-message enum, and exposes `fn view(...) -> Element<AppMsg>` and `fn update(&mut self, msg: SubMsg)` (or the parent inlines those calls). See `crustace/src/homescreen/`, `launcher/`, `quicksettings/`, `powermenu.rs`.
**Wrap sub-messages in the top-level enum.** `enum AppMsg { Home(HomeMsg), Settings(SettingsMsg), Nav(Route), Tick }`. `update()` matches the outer variant, then forwards to the right sub-module. This avoids one-giant-message-enum bloat once the app passes ~30 variants. **Wrap sub-messages in the top-level enum.** `enum AppMsg { Home(HomeMsg), Settings(SettingsMsg), Nav(Route), Tick }`. `update()` matches the outer variant, then forwards to the right sub-module. This avoids one-giant-message-enum bloat once the app passes ~30 variants.
@@ -235,53 +272,53 @@ The cheap things and the expensive things, in rough order:
- *Cheap*: building the `Element<Msg>` tree. It's plain enums and `Vec`s. crustace rebuilds the entire shell every frame and stays idle when nothing changes. - *Cheap*: building the `Element<Msg>` tree. It's plain enums and `Vec`s. crustace rebuilds the entire shell every frame and stays idle when nothing changes.
- *Cheap*: input dispatch. Per-leaf handler snapshots are captured during the layout pass; pointer/key events are O(N_focusable_leaves) lookups, not tree walks. - *Cheap*: input dispatch. Per-leaf handler snapshots are captured during the layout pass; pointer/key events are O(N_focusable_leaves) lookups, not tree walks.
- *Cheap*: `active_document()` / `theme_palette()`. The first returns a clone of an `Arc<ThemeDocument>` from a `RwLock`-protected cell; the second projects the active mode's slot table onto the eight canonical palette fields. - *Cheap*: `active_document()` / `theme_palette()`. The first returns a clone of an `Arc<ThemeDocument>` from a `RwLock`-protected cell; the second projects the active mode's slot table onto the ten canonical palette fields.
- *Avoid in `view()`*: filesystem walks, image decoding, `serde` parsing, regex compilation. Cache the result on the app struct (behind `RefCell` if needed) and look it up. - *Avoid in `view()`*: filesystem walks, image decoding, `serde` parsing, regex compilation. Cache the result on the app struct (behind `RefCell` if needed) and look it up.
- *Avoid in `view()`*: cloning large `Vec<u8>` image buffers. `img_widget` takes an `Arc<Vec<u8>>`; build the `Arc` once at load time and clone *the Arc*, not the bytes. - *Avoid in `view()`*: cloning large `Vec<u8>` image buffers. `img_widget` takes an `Arc<Vec<u8>>`; build the `Arc` once at load time and clone *the Arc*, not the bytes.
- *Avoid `is_animating() = true` when nothing is moving.* It pegs the loop at 60 Hz and burns battery on the mobile target. - *Avoid `is_animating() = true` when nothing is moving.* It pegs the loop at the full animation rate and burns battery on the mobile target.
- *Lower `poll_interval()` is not free.* Crustace polls every 30 s because the clock only shows HH:MM. If your UI shows seconds, `Some(Duration::from_secs(1))` is fine; if it shows nothing time-sensitive, leave it `None`. - *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. - *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. - *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. 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 ## Where to look in the consumer repos
| Pattern | File | | Pattern | File |
| --- | --- | | --- | --- |
| Multi-overlay coordination, overlay id constants | `crustace/src/app.rs` (`overlays()`, lines ~250380) | | 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`) | | 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` | | 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`) | | OSD overlay with auto-expiry | `crustace/src/osd.rs` (`Osd::show` / `tick` / `view`) |
| `keyboard_exclusive` + `take_focus_request` | `loginmanager/src/main.rs` | | `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` | | 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 ## 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. 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. - **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 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. - **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.
- **Live regions**: status messages, notifications and OSDs that should announce themselves on appearance need `Live::{Polite, Assertive}` on the relevant nodes. Not wired in.
- **`Action::SetValue` for sliders and text inputs**: the inbound action handler does not yet translate these requests into the corresponding widget message. Adding them needs the same plumbing as `Click` / `Focus` but with payload extraction from `ActionData`.
Downstream consumers shipping into regulated environments (EN 301 549, WCAG 2.1 AA, EAA) should still treat the integration as a starting point that needs a real audit with assistive technology users — the foundation is in place but the per-widget metadata work is what determines whether Orca actually reads a useful announcement. 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. **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. **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.

View File

@@ -58,11 +58,13 @@ column::<Msg>()
``` ```
Fluid units scale with the screen's pixels, not real-world Fluid units scale with the screen's pixels, not real-world
millimetres. For body text that must stay physically legible and millimetres. For sizes that must stay physically constant across an
honour the user's font-size preference across an open-ended device open-ended device set — touch targets, text that honours the user's
set, prefer `Length::dp` or `Length::em` over raw `vmin`; the 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`) [`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 `view()`-level branching on `surface_width` / `surface_height` for
genuine layout restructuring (sidebar → bottom tabs), not for sizing. genuine layout restructuring (sidebar → bottom tabs), not for sizing.
@@ -87,7 +89,9 @@ fn build_quick_settings_overlay( &self ) -> OverlaySpec<Msg>
{ {
// Compute the slide progress based on a stored start instant. While // Compute the slide progress based on a stored start instant. While
// animating, `is_animating()` returns `true` so the runtime redraws // animating, `is_animating()` returns `true` so the runtime redraws
// at ~60 Hz and reads the new progress every frame. // 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 let progress = match self.qs_started
{ {
Some( t ) => ( t.elapsed().as_secs_f32() / SLIDE_DURATION ).min( 1.0 ), Some( t ) => ( t.elapsed().as_secs_f32() / SLIDE_DURATION ).min( 1.0 ),
@@ -133,9 +137,12 @@ fn is_animating( &self ) -> bool
# } # }
``` ```
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 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 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. when the software path is active.
**See also**: [`Viewport`](./widgets.md#viewport), **See also**: [`Viewport`](./widgets.md#viewport),
@@ -367,7 +374,7 @@ fn update( &mut self, msg: Msg )
`set_active_mode` mutates a process-global cell; the next `view()` `set_active_mode` mutates a process-global cell; the next `view()`
rebuild reads the new mode through the per-slot helpers 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. renders against the new colours. There is no manual invalidation step.
For a full theme swap, load a different `ThemeDocument` and apply it: For a full theme swap, load a different `ThemeDocument` and apply it:
@@ -598,7 +605,8 @@ impl App for AppState
fn is_animating( &self ) -> bool fn is_animating( &self ) -> bool
{ {
// Redraw at 60 Hz while the toast is visible or fading. // Redraw every compositor frame (capped at ~30 Hz on the
// software backend) while the toast is visible or fading.
self.toast.is_some() self.toast.is_some()
} }
@@ -954,8 +962,9 @@ s.into()
To rasterise the result into a caller-owned buffer instead of presenting To rasterise the result into a caller-owned buffer instead of presenting
it, render through a [`core::UiSurface`](#embedding-ltk-without-ltkrun) it, render through a [`core::UiSurface`](#embedding-ltk-without-ltkrun)
and call `Canvas::read_rgba_pixels( &mut buf )` — it returns tightly and call `Canvas::read_rgba_pixels( &mut buf )` — it fills the buffer
packed straight-alpha RGBA8 (top-left row first) from either backend 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 (the software path un-premultiplies for you). Branch on
`Canvas::is_software()` when a draw must honour a real path clip on `Canvas::is_software()` when a draw must honour a real path clip on
software but only a bounding rect on GLES. software but only a bounding rect on GLES.

View File

@@ -15,8 +15,8 @@ runtime-free UI surfaces.
At a high level: At a high level:
- Implement the [`App`] trait. - Implement the `App` trait.
- Return an [`Element<Msg>`] tree from `view()`. - Return an `Element<Msg>` tree from `view()`.
- React to user input by handling messages in `update()`. - React to user input by handling messages in `update()`.
- Start the event loop with `ltk::run(app)`. - Start the event loop with `ltk::run(app)`.
@@ -40,8 +40,8 @@ it assumes:
- a running **Wayland** session - a running **Wayland** session
- Wayland client libraries available through Rust dependencies - Wayland client libraries available through Rust dependencies
- a usable system font such as `google-sora-fonts`, `liberation-fonts` or - a usable system font — on Debian (the crate's own packaging target):
`dejavu-fonts` `fonts-sora`, `fonts-liberation` or `fonts-dejavu`
- an installed `default` theme, or a development theme directory exposed - an installed `default` theme, or a development theme directory exposed
through `LTK_THEMES_DIR` through `LTK_THEMES_DIR`
@@ -55,18 +55,50 @@ The rendering backend is selected automatically:
From the repo root: From the repo root:
```bash ```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 widgets` — broad widget survey
- `cargo run --example inputs`text entry - `cargo run --example inputs`plain and secure text fields with a
- `cargo run --example scroll` — scroll viewport patterns 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 - `cargo run --example mini_shell` — overlays, animation and theme switching
All examples require a running Wayland compositor. 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 ## Theme and font setup
`ltk` currently expects a theme named `default`. Lookup order is: `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 That makes `ThemeDocument::find("default")` resolve to
`$PWD/themes/default/theme.json`. `$PWD/themes/default/theme.json`.
Font loading is separate from theme lookup. `Canvas` walks a chain of Font loading is separate from theme lookup. `src/system_fonts.rs` walks a
common system font paths (`fonts-sora`, `fonts-liberation`, `fonts-dejavu`, chain of common system font *file paths* — the files installed by the
`fonts-freefont`, …) and uses the first one it finds. If nothing matches, Debian packages `fonts-sora`, `fonts-liberation`, `fonts-freefont` and
it falls back to an embedded Sora Regular (~50 KB, SIL OFL 1.1) shipped `fonts-dejavu`, plus the equivalent locations other distros use — and
inside the crate, so canvas construction never panics on a system without loads the first one it finds. If nothing matches, it falls back to an
the expected fonts. Installing one of the listed packages is still embedded Sora Regular (~50 KB, SIL OFL 1.1) shipped inside the crate, so
recommended for richer glyph coverage. 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 ## Your first app
@@ -186,7 +220,7 @@ The APIs you will usually touch first live here conceptually:
- `App` - `App`
- `Element<Msg>` - `Element<Msg>`
- `button`, `text`, `text_edit`, `image` - `button`, `text`, `text_edit`, `img_widget`
- `column`, `row`, `stack`, `grid`, `spacer` - `column`, `row`, `stack`, `grid`, `spacer`
- `container`, `scroll`, `slider`, `toggle`, `checkbox`, `radio` - `container`, `scroll`, `slider`, `toggle`, `checkbox`, `radio`
- `Color` - `Color`
@@ -260,8 +294,11 @@ The knobs you will usually override are:
- `keyboard_exclusive()` - `keyboard_exclusive()`
- `background_color()` - `background_color()`
For a non-trivial layer-shell example, use `examples/mini_shell.rs` as the For a non-trivial multi-surface example, use `examples/mini_shell.rs` as the
reference entry point. 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 ## The APIs you will touch first
@@ -271,7 +308,7 @@ Start here:
- `App` - `App`
- `Element<Msg>` - `Element<Msg>`
- `button`, `text`, `text_edit`, `image` - `button`, `text`, `text_edit`, `img_widget`
- `column`, `row`, `stack`, `grid`, `spacer` - `column`, `row`, `stack`, `grid`, `spacer`
- `container`, `scroll`, `slider`, `toggle`, `checkbox`, `radio` - `container`, `scroll`, `slider`, `toggle`, `checkbox`, `radio`
- `Color` - `Color`
@@ -398,8 +435,17 @@ None of that blocks learning the toolkit, but it matters when you evaluate
## What to read next ## 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, - [`docs/architecture.md`](./architecture.md) — multi-surface patterns,
theming, animation and performance 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/showcase.rs`](../examples/showcase.rs) — smallest visual tour
- [`examples/widgets.rs`](../examples/widgets.rs) — broader widget coverage - [`examples/widgets.rs`](../examples/widgets.rs) — broader widget coverage
- [`examples/mini_shell.rs`](../examples/mini_shell.rs) — overlays and shell - [`examples/mini_shell.rs`](../examples/mini_shell.rs) — overlays and shell

View File

@@ -1,7 +1,8 @@
# ltk theming # ltk theming
`ltk` reads a JSON theme document at startup and exposes a process-wide `ltk` loads a JSON theme document lazily — on the first theme accessor
active state for widgets and applications to query. This document 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 describes the on-disk format, the runtime APIs, and the slot conventions
that built-in widgets expect. that built-in widgets expect.
@@ -20,6 +21,8 @@ A theme is a directory under one of:
3. `/usr/share/ltk/themes/<id>/` — system-wide install path 3. `/usr/share/ltk/themes/<id>/` — system-wide install path
(`ltk-theme-default` Debian package). (`ltk-theme-default` Debian package).
The ordered list is queryable at runtime via `ltk::theme::search_paths()`.
The directory tree: The directory tree:
```text ```text
@@ -36,15 +39,22 @@ The directory tree:
│ │ └── horizontal.svg wordmark (header / sign-in bars) │ │ └── horizontal.svg wordmark (header / sign-in bars)
│ └── dark/ │ └── dark/
│ └── (same set, dark variants) │ └── (same set, dark variants)
── icons/ symbolic + app icons ── icons/ symbolic + app icons
├── app-default.svg fallback icon for unknown app ids ├── apps/ per-application icons (firefox.svg, …)
├── apps/ per-application icons (firefox.svg, …) │ └── default.svg generic app icon shipped with the set
└── catalogue/ symbolic glyph catalogue └── catalogue/ symbolic glyph catalogue
├── filled/ solid silhouettes — preferred by default ├── filled/ solid silhouettes — preferred by default
│ └── <category>/ general, system, window, … │ └── <category>/ general, system, window, …
└── line/ outlined variants (same names) └── 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 Branded assets in `branding/` and icons in `icons/` are picked up by
convention — see [Branding assets](#branding-assets) and convention — see [Branding assets](#branding-assets) and
[Icons](#icons) below. Asset paths declared inside `theme.json` (e.g. a [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.id` should match the directory name by convention — lookup goes
theme-picker UI a shell builds on top of `ltk`. 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 The other five sections are described below in order. The parser is
(`deny_unknown_fields`): unknown keys at any level are an error so typos strict (`deny_unknown_fields`) at most levels — the document root,
surface immediately. `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` ## `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 files at render time. The chain is walked in order; the first family that
can rasterise the codepoint wins. can rasterise the codepoint wins.
If none of the listed `sources` exist on disk, `ltk` falls back to its Sources that fail to read are skipped with a stderr warning; when the
embedded Sora Regular (~50 KB, OFL 1.1) and stamps a red banner on every whole block yields nothing, text falls back to the embedded Sora
frame pointing at the missing-theme problem. 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` ## `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 many places) and "raw" colours (single-use) — any hex can live here, and
single-use literals are equally valid inline. 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` ## `gradients`
Named paints. Two variants: `linear` and `radial`. Used both as fills for 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 surfaces and as values referenced from a slot. Stops carry a `pos`
`[0, 1]`, but stop positions outside that range are accepted and used for (alias: `position`; in `[0, 1]`, but stop positions outside that range
extrapolation) and a `color` (literal hex or `@reference`). are accepted and used for extrapolation) and a `color` (literal hex or
`@reference`).
```json ```json
"gradients": { "gradients": {
@@ -146,10 +175,13 @@ extrapolation) and a `color` (literal hex or `@reference`).
} }
``` ```
`space` is either `"srgb"` (perceptually quick, the default) or `space` is `"linear-rgb"` (interpolate in linear-light space — the
`"linear-rgb"` (interpolate in linear-light space, more uniform mid-tones default, physically correct and fast), `"srgb"` (interpolate in
for high-saturation gradients). `angle_deg` is the conventional CSS gamma-encoded sRGB; reproduces designs authored against it, but
gradient angle (`0` = up, `90` = right, `180` = down, `270` = left). 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 Radial gradients use `center: [x, y]` (relative to the painted rect, both
in `[0, 1]`) and `radius: r` (also relative). 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 Each entry has `offset: [x, y]` (logical pixels), `blur` (Gaussian sigma
× 2, matching the CSS convention), optional `spread` (default `0`), × 2, matching the CSS convention), optional `spread` (default `0`),
`color` (literal or `@ref`), and `blend` (`normal`, `plus-lighter`, or `color` (literal or `@ref`), and `blend` (`normal`, `plus-lighter`,
`overlay`). `overlay`, `multiply`, or `screen`).
Reference an entire stack from a slot with `"inset_shadows": Reference an entire stack from a slot with `"inset_shadows":
"@glass-insets"`, or inline the array if you only use it once. "@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` ## `modes`
Two required entries: `light` and `dark`. Each carries the look the 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"`, `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 bundle helper ([`ltk::WallpaperBundle::for_size`]) returns the right
crop for landscape or portrait surfaces, so a single landscape SVG / PNG crop for landscape or portrait surfaces, so a single landscape SVG / PNG
covers both. covers both.
@@ -222,6 +266,7 @@ Per-mode tokens for the title-bar control buttons:
```json ```json
"window_controls": { "window_controls": {
"bar_bg": "@off-white",
"icon": "#5F5F68", "icon": "#5F5F68",
"hover_bg": "@navy/14", "hover_bg": "@navy/14",
"pressed_bg": "@navy/24", "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()`]. through [`theme_window_controls()`].
The actual SVG glyphs (`close`, `maximize`, `minimize`, `restore`) live 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 their slot up by id and the slot's `meta.semantic` field supplies a
human-readable hint that's useful in theme inspectors. human-readable hint that's useful in theme inspectors.
Three slot variants: Six slot variants: `color`, `linear`, `radial`, `shadows`, `surface`
and `typography`.
#### `color` #### `color`
@@ -263,7 +313,51 @@ Three slot variants:
Plain colour values. `value` is a literal hex or `@reference`. `meta` is Plain colour values. `value` is a literal hex or `@reference`. `meta` is
optional but conventional: `palette/<role>` for the palette layer and optional but conventional: `palette/<role>` for the palette layer and
`effect/<group>/<name>` for everything else. `effect/<group>/<name>` 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` #### `shadows`
@@ -287,7 +381,6 @@ Outer drop shadows applied via [`theme_shadows(id)`]. Same field shape as
"fill": "@surface-glass-dark", "fill": "@surface-glass-dark",
"shadows": "shadows-glass", "shadows": "shadows-glass",
"inset_shadows": "@glass-insets", "inset_shadows": "@glass-insets",
"backdrop": { "blur_px": 22.5 },
"meta": { "semantic": "effect/glass/card" } "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. - `shadows` — id of a `shadows` slot or an inline list. Optional.
- `inset_shadows` — id of an `inset_stacks` entry (`@glass-insets`) or - `inset_shadows` — id of an `inset_stacks` entry (`@glass-insets`) or
inline list. Optional. inline list. Optional.
- `backdrop``{ "blur_px": <σ × 2> }` for backdrop blur. Optional;
GLES backend renders it, software backend ignores it (documented
parity gap).
## References ## References
The `@name` syntax substitutes a palette / gradient / inset-stack value The `@name` syntax substitutes a palette / gradient / inset-stack value
in place of an inline literal: in place of an inline literal:
- `@cyan` — looks up `colors.cyan`, `gradients.cyan` or - `@cyan` — looks up `gradients.cyan` / `inset_stacks.cyan` first,
`inset_stacks.cyan` (collisions across sections are an error). When the then `colors.cyan`. A name defined in both `gradients` and
resolved value is a colour, the alpha channel comes from the original. `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 - `@cyan/80` — the `/AA` suffix is a colour-only alpha override (two hex
digits). Lets a single base `@navy` serve `@navy/14`, `@navy/24`, digits). Lets a single base `@navy` serve `@navy/14`, `@navy/24`,
`@navy/99`, `@navy/D9` etc. without a separate entry per alpha. `@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 | | `accent` | toggle on, slider fill, focus ring | color |
| `divider` | separator, toggle off, list item border | color | | `divider` | separator, toggle off, list item border | color |
| `icon` | icon-button glyph colour | 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 | | id | consumer | type |
| --- | --- | --- | | --- | --- | --- |
| `shadows-glass` | every surface that opts into elevation | shadows | | `surface-slider-track` | `Slider` / `VSlider` track background | surface |
| `surface-card` | `Container::surface("surface-card")` | surface | | `surface-slider-fill` | `Slider` / `VSlider` filled portion | surface |
| `surface-card-flat` | flat variant for software backend | surface | | `surface-card` | no built-in widget reads it automatically — apps opt in via `Container::surface("surface-card")` | surface |
| `surface-panel` | overlay panels | surface | | `shadows-glass` | referenced from the theme's own surface slots; no direct widget consumer | shadows |
| `surface-slider-track` | `Slider` track background | surface | | `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 |
| `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 |
The `-flat` variants are used by the software backend, which lacks Only the slider track / fill pair is looked up by widgets on their
backdrop blur; the GLES backend uses the non-flat ones. 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 ## Using the theme from app code
@@ -403,7 +501,7 @@ fn view( &self ) -> Element<Msg>
| `theme_color( id )` | `Option<Color>` | Pull a single colour slot by id when it's not in the palette (`"surface-card-border"`, custom theme tokens). | | `theme_color( id )` | `Option<Color>` | 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_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<Paint>` | Slot may be a colour or a gradient — promotes a colour to `Paint::Solid` automatically. | | `theme_paint( id )` | `Option<Paint>` | Slot may be a colour or a gradient — promotes a colour to `Paint::Solid` automatically. |
| `theme_surface( id )` | `Option<Surface>` | Surface slot (fill + shadows + insets + backdrop). | | `theme_surface( id )` | `Option<Surface>` | Surface slot (fill + shadows + insets). |
| `theme_resolve_surface( id )` | `Option<( Surface, Vec<Shadow> )>` | Same, but pre-resolves a `ShadowsRef::Named` reference to a flat `Vec`. Use this when you call `canvas.fill_surface` directly. | | `theme_resolve_surface( id )` | `Option<( Surface, Vec<Shadow> )>` | 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<Vec<Shadow>>` | Outer shadow stack. | | `theme_shadows( id )` | `Option<Vec<Shadow>>` | Outer shadow stack. |
| `theme_text_style( id )` | `Option<TextStyle>` | Typography slot (size, weight, line-height). | | `theme_text_style( id )` | `Option<TextStyle>` | Typography slot (size, weight, line-height). |
@@ -419,6 +517,10 @@ fn view( &self ) -> Element<Msg>
| `theme_app_icon( name )` / `theme_app_default_icon()` | `Option<PathBuf>` | Per-app icons under `icons/apps/`. | | `theme_app_icon( name )` / `theme_app_default_icon()` | `Option<PathBuf>` | Per-app icons under `icons/apps/`. |
| `theme_icon_path( "category/name" )` | `Option<PathBuf>` | Catalogue icon path (filled-then-line lookup). | | `theme_icon_path( "category/name" )` | `Option<PathBuf>` | Catalogue icon path (filled-then-line lookup). |
| `theme_icon_rgba( "category/name", size )` | `Option<( Arc<Vec<u8>>, u32, u32 )>` | Rasterised + cached RGBA. Pair with `theme::tint_symbolic` for chrome glyphs. | | `theme_icon_rgba( "category/name", size )` | `Option<( Arc<Vec<u8>>, u32, u32 )>` | Rasterised + cached RGBA. Pair with `theme::tint_symbolic` for chrome glyphs. |
| `theme_icon_tinted( name, size, tint )` | `Option<ImageData>` | One-call rasterise + tint (`theme_icon_rgba` + `tint_symbolic`). |
| `decode_svg_bytes( bytes, size )` | `Option<( Arc<Vec<u8>>, u32, u32 )>` | Rasterise arbitrary SVG bytes (uncached, no path resolution); the primitive under `theme_icon_rgba`. |
| `active_document()` | `Arc<ThemeDocument>` | 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. | | `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 ### Switching mode or document at runtime
@@ -436,6 +538,18 @@ let doc = ltk::ThemeDocument::find( "midnight" )
ltk::set_active_document( doc ); 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: The conventional wiring is to dispatch a message from the UI:
```rust,no_run ```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** read palette / surfaces / icons inside `view()`, every frame.
- **Do** use `theme_color_or( id, fallback )` for non-palette slots so - **Do** use `theme_color_or( id, fallback )` for non-palette slots so
a custom theme that omits a slot still paints something sane. a custom theme that omits a slot still paints something sane.
- **Do** use `theme_palette().<field>` for the eight common roles — - **Do** use `theme_palette().<field>` for the ten common roles —
the palette is precomputed and cheap; named slot lookups only beat the palette is precomputed and cheap; named slot lookups only beat
it when you genuinely need a non-canonical token. it when you genuinely need a non-canonical token.
- **Don't** store `Color`, `Surface`, `Paint`, or icon `PathBuf`s in - **Don't** store `Color`, `Surface`, `Paint`, or icon `PathBuf`s in
@@ -622,7 +736,10 @@ Categories under `catalogue/{filled,line}/`: `accessibility`, `actions`,
`<g fill="#000000">`). The rasteriser keeps only the alpha channel `<g fill="#000000">`). The rasteriser keeps only the alpha channel
for symbolic tinting, so the actual RGB of the source doesn't for symbolic tinting, so the actual RGB of the source doesn't
matter — pick `#000000` for consistency with the rest of the 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 2. Optionally ship the `line/` variant in
`catalogue/line/<category>/<name>.svg`. `catalogue/line/<category>/<name>.svg`.
3. Reference it from widget code by its slash-separated stem, no 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. // `ltk::theme_window_controls().icon` instead.
let palette = ltk::theme_palette(); let palette = ltk::theme_palette();
let tinted = ltk::theme::tint_symbolic( &rgba, palette.icon ); 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( () ) # 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 The `icon_rgba` + `tint_symbolic` pair is the standard pipeline for
catalogue and chrome icons: rasterise once, recolour per mode via catalogue and chrome icons: rasterise once, recolour per mode via
palette tokens. Themes ship a single SVG per glyph and the per-mode 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 ## Localisation
@@ -679,10 +801,12 @@ date_picker:
dow_short_6: "S" dow_short_6: "S"
``` ```
`dow_short_<n>` is indexed from each locale's `first_dow` (Sunday-first `dow_short_<n>` is indexed from each locale's first day of week
in `en`, Monday-first in `es` / `fr` / `it` / `de` / `pt` / `pt_BR`). (Sunday-first in `en`, Monday-first in `es` / `fr` / `it` / `de` /
The `Locale` struct ships `first_dow: u8` and the date picker indexes `pt` / `pt_BR`). The `Locale` struct's `first_dow` field is
`dow_short_*` accordingly. 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" )` Built-in widgets read these via `rust_i18n::t!( "context_menu.copy" )`
at render time, so switching locale at runtime via at render time, so switching locale at runtime via
@@ -729,9 +853,10 @@ A custom theme directory can live anywhere under
`icons/catalogue/filled/<category>/<name>.svg`. The `icon_path` `icons/catalogue/filled/<category>/<name>.svg`. The `icon_path`
lookup resolves against whichever theme is active, so a partial lookup resolves against whichever theme is active, so a partial
catalogue overlays the default cleanly without forking the rest. catalogue overlays the default cleanly without forking the rest.
- **Build a flat-only theme**: drop every `backdrop` block from the - **Build a flat theme**: route the `surface-*` slots to solid
slots and route every `surface-*-flat` slot to a solid `colors` `colors` references and drop the shadow / inset decorations. A
reference. Visual parity with the software backend is automatic. `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 All three recipes keep the slot ids and reference shapes intact, so the
built-in widgets continue to work without code changes. built-in widgets continue to work without code changes.

View File

@@ -139,8 +139,8 @@ let bar = window_controls(
`focusable( true )` opts the button into the Tab cycle (off by default `focusable( true )` opts the button into the Tab cycle (off by default
to match desktop convention). to match desktop convention).
**See also**: [`crate::theme_window_controls`] for the colour tokens **See also**: `theme_window_controls` for the colour tokens each mode
each mode supplies. supplies.
### `list_item` ### `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 optional right-aligned trailing text and/or trailing icon, and a
tappable surface. tappable surface.
**When**: settings menus, navigation lists, contact rows. Doubles its **When**: settings menus, navigation lists, contact rows. Grows taller when a subtitle is set.
height when a subtitle is set.
```rust,no_run ```rust,no_run
# use ltk::{ list_item, ListItem }; # use ltk::{ list_item, ListItem };
@@ -343,11 +342,11 @@ let hint = text( "Swipe up or press Enter to unlock" )
### `text_edit` ### `text_edit`
A single-line text input with cursor, Backspace, Enter / Submit, A text input with cursor, Backspace, Enter / Submit, clipboard
clipboard support, a `secure( true )` password mode that masks the support, a `secure( true )` password mode that masks the visible
visible characters and zeroizes the buffer on drop, and a characters and zeroizes the buffer on drop, and a
`password_toggle( visible, on_toggle )` builder that pins a show / `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. **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` / 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. struct or a tuple.
`max_width(px)` caps the container's outer width — when the parent `max_width(px)` caps the container's outer width — when the parent
offers a wider rect, the container reports `min( offered, px )` as its offers a wider rect, the container reports `min( offered, px )` as its
preferred width instead of stretching to fill. Mirrors the same flag on 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.
[`column`](#column) and [`row`](#row), so a single decorated child no
longer needs a `column`-of-one wrap just to access a width cap.
**See also**: [`pressable`](#pressable) wrapping a `container` makes a **See also**: [`pressable`](#pressable) wrapping a `container` makes a
card interactive. card interactive.
@@ -535,8 +532,7 @@ External::cpu( 120.0, 120.0, |canvas, rect|
### `scroll` ### `scroll`
A vertically-scrollable viewport. Drag the content to scroll; clipping 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.
is automatic.
**When**: lists or grids that may overflow the available height. **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` ### `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 **When**: long-running operations with no known fraction
(network calls, indexing, "waiting for compositor"). (network calls, indexing, "waiting for compositor").
```rust,no_run ```rust,no_run
# use ltk::{ spinner, Spinner }; # use ltk::{ spinner, Spinner };
# fn _ex() -> Spinner { # struct App { spinner_phase: f32 }
spinner().size( 24.0 ) # impl App { fn _ex( &self ) -> Spinner {
# } spinner().phase( self.spinner_phase ).size( 24.0 )
# }}
``` ```
**See also**: [`progress_bar`](#progress_bar) for determinate progress. **See also**: [`progress_bar`](#progress_bar) for determinate progress.
### `toast` ### `toast`
Transient notification that floats over the surface and dismisses 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.
itself after a timeout.
**When**: confirmation snackbars ("Saved"), non-blocking errors, **When**: confirmation snackbars ("Saved"), non-blocking errors,
status flashes. status flashes.
```rust,no_run ```rust,no_run
# use ltk::{ toast, Toast }; # use ltk::{ toast, OverlaySpec };
# #[ derive( Clone ) ] enum Msg {} # #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> Toast<Msg> { # struct App { toast_message: Option<String> }
toast( "Saved" ).duration( 3.0 ) # impl App {
fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
{
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` ### `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 **When**: discoverable affordances on icon-only controls, keyboard
shortcut reminders, helper text that should not occupy permanent 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 The overlay anchors below the widget built with the matching
`WidgetId`. See [`docs/cookbook.md`](./cookbook.md) for the full `.id( WidgetId )`. See [`docs/cookbook.md`](./cookbook.md) for the full
overlay wiring. overlay wiring.
**See also**: [`toast`](#toast) for transient self-dismissing **See also**: [`toast`](#toast) for transient self-dismissing
@@ -719,26 +723,39 @@ notifications.
### `combo` ### `combo`
Editable single-select with a popup list. Supports type-to-filter 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()`.
and free-text entry.
**When**: pick-one fields with too many options for a row of **When**: pick-one (or pick-several) fields with too many options for
[`radio`](#radio) buttons but where a free typed answer is also a row of [`radio`](#radio) buttons (autocomplete, country list, theme
valid (autocomplete, country list, theme picker). picker).
```rust,no_run ```rust,no_run
# use ltk::{ combo, Combo, ComboState }; # use ltk::{ column, combo, Combo, ComboState, Element, OverlaySpec };
# #[ derive( Clone ) ] enum Msg { Pick( usize ) } # #[ derive( Clone ) ] enum Msg { Pick( usize ), ToggleOpen, Dismiss }
# fn _ex( state: ComboState ) -> Combo<Msg> { # struct App { fruits: ComboState }
let items = vec![ # impl App {
"One".to_string(), # fn build_combo( &self ) -> Combo<Msg> {
"Two".to_string(), # let items = vec![ "One".to_string(), "Two".to_string(), "Three".to_string() ];
"Three".to_string(), combo( self.fruits.clone(), items )
]; .on_toggle_open( Msg::ToggleOpen )
combo( state, items ).on_select_idx( Msg::Pick ) .on_select_idx( Msg::Pick )
.on_dismiss( Msg::Dismiss )
# }
fn view( &self ) -> Element<Msg>
{
column().push( self.build_combo().trigger() ).into()
}
fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
{
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, **See also**: [`radio`](#radio) for small mutually-exclusive sets,
[`notebook`](#notebook) for tabbed mode switches. [`notebook`](#notebook) for tabbed mode switches.
@@ -812,10 +829,12 @@ dialog()
`modal( false ) + dismiss_on_scrim( msg )` makes a tap on the dim `modal( false ) + dismiss_on_scrim( msg )` makes a tap on the dim
background fire `msg`; combining `dismiss_on_scrim` with `modal( true )` 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 — 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 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 **See also**: [`toast`](#toast) for non-blocking transient
notifications, [`combo`](#combo) for a single-pick popup that does 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 the full available width). `center_y( true )` centres the content
block vertically when there are no spacers. 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` ### `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 ```rust,no_run
# use ltk::{ flex, row, Element }; # use ltk::{ flex, row, Element };

View File

@@ -9,11 +9,14 @@
//! //!
//! The carousel widget itself is a stateless layout primitive — the //! The carousel widget itself is a stateless layout primitive — the
//! `offset` (positive shifts content right) is owned by the host. The //! `offset` (positive shifts content right) is owned by the host. The
//! example mutates that offset directly when Prev / Next / arrow keys //! example drives it three ways: Prev / Next buttons and arrow keys
//! change the focused index; in a real touch-driven app the host would //! snap to an index, and a pointer / touch drag pans it live through
//! drive it from drag / inertia / snap-ease. //! 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 //! NOTE: ltk is a Wayland layer-shell toolkit. This example needs a
//! running Wayland compositor. //! running Wayland compositor.
@@ -36,14 +39,16 @@ struct CarouselApp
focused: usize, focused: usize,
offset: f32, offset: f32,
last_msg: String, last_msg: String,
viewport_w: f32,
wheel_accum: f32,
} }
const TILE_COUNT: usize = 7; const TILE_COUNT: usize = 7;
/// Approximate viewport width used to translate "focused index → offset". /// Horizontal padding around the carousel (the root column's 16 px per
/// Real apps would derive this from the laid-out viewport rect; for an /// side) — subtracted from the surface width to get the carousel's real
/// example the constant keeps the focus / step math obvious. /// viewport, so the snap / drag math matches what the widget draws.
const VIEWPORT_W: f32 = 800.0; const H_PADDING: f32 = 32.0;
const FOCUSED_FRAC: f32 = 0.7; const FOCUSED_FRAC: f32 = 0.7;
const GAP: f32 = 16.0; const GAP: f32 = 16.0;
@@ -61,12 +66,12 @@ impl CarouselApp
{ {
fn new() -> Self 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 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 -( focused as f32 ) * stride
} }
} }
@@ -108,7 +113,7 @@ impl App for CarouselApp
let status_line = if self.last_msg.is_empty() 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 ) .size( 12.0 )
.color( secondary ) .color( secondary )
.align_center() .align_center()
@@ -146,16 +151,18 @@ impl App for CarouselApp
if self.focused > 0 if self.focused > 0
{ {
self.focused -= 1; 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 => Message::Next =>
{ {
if self.focused + 1 < TILE_COUNT if self.focused + 1 < TILE_COUNT
{ {
self.focused += 1; self.focused += 1;
self.offset = self.snap_offset_for( self.focused );
} }
self.offset = self.snap_offset_for( self.focused );
} }
Message::Tile( i ) => Message::Tile( i ) =>
{ {
@@ -174,6 +181,62 @@ impl App for CarouselApp
_ => None, _ => 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<Message>
{
Some( Message::Next )
}
fn on_swipe_right( &mut self ) -> Option<Message>
{
Some( Message::Prev )
}
} }
fn main() fn main()

View File

@@ -17,7 +17,9 @@ enum Msg {}
struct Demo; 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; const MARGIN: f32 = 24.0;
fn bg() -> Color { Color::rgba( 0.16, 0.18, 0.22, 1.0 ) } fn bg() -> Color { Color::rgba( 0.16, 0.18, 0.22, 1.0 ) }
@@ -76,9 +78,9 @@ fn triangle_path( r: Rect ) -> Vec<PathCmd>
/// A cell that paints `bg()` everywhere, then `fg()` clipped to `shape`. The /// 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 /// previous clip is snapshotted and restored so the clip does not leak to the
/// rest of the frame. /// rest of the frame.
fn clipped_cell( shape: fn( Rect ) -> Vec<PathCmd> ) -> Element<Msg> fn clipped_cell( size: f32, shape: fn( Rect ) -> Vec<PathCmd> ) -> Element<Msg>
{ {
External::cpu( CELL, CELL, move |canvas, rect| External::cpu( size, size, move |canvas, rect|
{ {
canvas.fill_rect( rect, bg(), 0.0 ); canvas.fill_rect( rect, bg(), 0.0 );
let saved = canvas.clip_bounds(); let saved = canvas.clip_bounds();
@@ -94,17 +96,45 @@ impl App for Demo
fn view( &self ) -> Element<Msg> fn view( &self ) -> Element<Msg>
{ {
// 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<Msg> = 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() column()
.spacing( 16.0 ) .spacing( 16.0 )
.padding( 24.0 ) .padding( 24.0 )
.push( text( "Canvas::set_clip_path — rounded rect, circle, triangle" ) ) .push( text( "Canvas::set_clip_path — rounded rect, circle, triangle" ) )
.push( .push( cells )
row()
.spacing( 16.0 )
.push( clipped_cell( |r| rounded_rect_path( r, 32.0 ) ) )
.push( clipped_cell( circle_path ) )
.push( clipped_cell( triangle_path ) )
)
.into() .into()
} }

View File

@@ -13,8 +13,8 @@
//! between the subtitle and the action row. //! between the subtitle and the action row.
use ltk::{ use ltk::{
App, ButtonVariant, Element, App, ButtonVariant, Element, Keysym,
button, column, dialog, row, slider, spacer, stack, text, button, column, dialog, slider, spacer, stack, text,
}; };
#[ derive( Clone, Copy, PartialEq, Eq ) ] #[ derive( Clone, Copy, PartialEq, Eq ) ]
@@ -76,7 +76,11 @@ impl App for DialogApp
.color( secondary ) .color( secondary )
.wrap( true ) ); .wrap( true ) );
let openers = row::<Msg>() // 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::<Msg>()
.padding( 0.0 )
.spacing( 12.0 ) .spacing( 12.0 )
.push( .push(
button::<Msg>( "Modal confirm" ) button::<Msg>( "Modal confirm" )
@@ -144,6 +148,15 @@ impl App for DialogApp
Msg::BrightnessChanged( v ) => { self.brightness = v; } 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<Msg>
{
if keysym == Keysym::Escape { std::process::exit( 0 ); }
None
}
} }
fn modal_confirm() -> Element<Msg> fn modal_confirm() -> Element<Msg>

View File

@@ -19,7 +19,7 @@
use ltk:: use ltk::
{ {
App, Element, Keysym, ButtonVariant, WidgetScaling, 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, set_widget_scaling, set_density, density,
}; };
@@ -81,21 +81,22 @@ impl App for ResponsiveApp
// Mode + density controls. Explicit sizes here so the controls stay // Mode + density controls. Explicit sizes here so the controls stay
// stable while the demo widgets below react to the mode. // stable while the demo widgets below react to the mode.
let controls = row::<Message>() // Mode + density controls. Stock buttons like the demo widgets
.spacing( 12.0 ) // 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::<Message>()
.padding( 0.0 )
.spacing( 8.0 )
.push( button::<Message>( "Switch mode".to_string() ) .push( button::<Message>( "Switch mode".to_string() )
.variant( ButtonVariant::Primary ) .variant( ButtonVariant::Primary )
.font_size( 16.0 )
.height( 44.0 )
.on_press( Message::SwitchMode ) ) .on_press( Message::SwitchMode ) )
.push( grid::<Message>( 2 )
.push( button::<Message>( " density".to_string() ) .push( button::<Message>( " density".to_string() )
.font_size( 16.0 )
.height( 44.0 )
.on_press( Message::DensityDown ) ) .on_press( Message::DensityDown ) )
.push( button::<Message>( " density".to_string() ) .push( button::<Message>( " density".to_string() )
.font_size( 16.0 ) .on_press( Message::DensityUp ) ) );
.height( 44.0 )
.on_press( Message::DensityUp ) );
// The demo widgets — NO explicit sizes, so they follow the mode. // The demo widgets — NO explicit sizes, so they follow the mode.
let demo = column::<Message>() let demo = column::<Message>()

View File

@@ -75,15 +75,11 @@ impl App for WidgetsApp
let title = text( "ltk widgets" ).size( 24.0 ).color( primary ).align_center(); 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<Msg> = tabs( [ "General", "Audio", "Network" ] ) let tabs_strip: Element<Msg> = tabs( [ "General", "Audio", "Network" ] )
.selected( self.tab ) .selected( self.tab )
.on_select( Msg::SelectTab ) .on_select( Msg::SelectTab )
.into(); .into();
let tab_label = format!(
"Active tab: {}",
[ "General", "Audio", "Network" ][ self.tab.min( 2 ) ],
);
let toggles = column::<Msg>() let toggles = column::<Msg>()
.spacing( 0.0 ) .spacing( 0.0 )
@@ -124,29 +120,44 @@ impl App for WidgetsApp
.id( WidgetId( "widgets/note" ) ) .id( WidgetId( "widgets/note" ) )
.on_change( Msg::NoteChanged ); .on_change( Msg::NoteChanged );
let content = column::<Msg>() // One page per tab, so selecting a tab visibly swaps the content.
.padding( 32.0 ) let page: Element<Msg> = match self.tab.min( 2 )
{
0 => column::<Msg>()
.padding( 0.0 )
.spacing( 12.0 ) .spacing( 12.0 )
.push( title )
.push( tabs_strip )
.push( text( tab_label ).size( 13.0 ).color( secondary ) )
.push( separator() )
.push( toggles ) .push( toggles )
.push( separator() ) .push( separator() )
.push( checkboxes ) .push( checkboxes )
.push( separator() ) .push( separator() )
.push( radios ) .push( radios )
.push( separator() ) .into(),
1 => column::<Msg>()
.padding( 0.0 )
.spacing( 12.0 )
.push( text( vol_label ).size( 13.0 ).color( secondary ) ) .push( text( vol_label ).size( 13.0 ).color( secondary ) )
.push( slider( self.volume ).on_change( Msg::SliderChanged ) ) .push( slider( self.volume ).on_change( Msg::SliderChanged ) )
.push( text( prog_label ).size( 13.0 ).color( secondary ) ) .push( text( prog_label ).size( 13.0 ).color( secondary ) )
.push( progress_bar( self.progress ) ) .push( progress_bar( self.progress ) )
.push( separator() ) .into(),
_ => column::<Msg>()
.padding( 0.0 )
.spacing( 12.0 )
.push( text( "Spinner" ).size( 13.0 ).color( secondary ) ) .push( text( "Spinner" ).size( 13.0 ).color( secondary ) )
.push( spin_row ) .push( spin_row )
.push( separator() ) .push( separator() )
.push( text( "Multiline text edit" ).size( 13.0 ).color( secondary ) ) .push( text( "Multiline text edit" ).size( 13.0 ).color( secondary ) )
.push( textarea ) .push( textarea )
.into(),
};
let content = column::<Msg>()
.padding( 32.0 )
.spacing( 12.0 )
.push( title )
.push( tabs_strip )
.push( separator() )
.push( page )
.push( spacer() ) .push( spacer() )
.push( .push(
text( "Esc = quit" ) text( "Esc = quit" )

View File

@@ -585,7 +585,9 @@ pub trait App: 'static
/// ///
/// Useful for embeddings that take over scrolling for their own /// Useful for embeddings that take over scrolling for their own
/// content — for example forwarding the event to a WPE view that /// 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 ) {} fn on_pointer_axis( &mut self, _x: f32, _y: f32, _dx: f32, _dy: f32 ) {}
/// Raw multi-touch callbacks. Default: no-op. /// Raw multi-touch callbacks. Default: no-op.

View File

@@ -419,6 +419,7 @@ impl<A: App> AppData<A>
// configure.new_size is surface-local (logical), so multiply by the // configure.new_size is surface-local (logical), so multiply by the
// current buffer scale before handing the dimensions to the app. // current buffer scale before handing the dimensions to the app.
let sf = self.main.scale_factor.max( 1 ) as u32; 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_scale_changed( sf );
self.app.on_resize( w * sf, h * sf ); self.app.on_resize( w * sf, h * sf );
// `on_resize` may flip app-state that the view depends on (apps that // `on_resize` may flip app-state that the view depends on (apps that

View File

@@ -99,6 +99,7 @@ impl<A: App> CompositorHandler for AppData<A>
// `App::on_resize`. // `App::on_resize`.
if matches!( focus, super::SurfaceFocus::Main ) 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_scale_changed( new_factor as u32 );
self.app.on_resize( pw, ph ); self.app.on_resize( pw, ph );
self.dirty_caches(); self.dirty_caches();

View File

@@ -83,6 +83,11 @@ impl<A: App> AppData<A>
let dx = horizontal.absolute as f32 * multiplier; let dx = horizontal.absolute as f32 * multiplier;
let dy = vertical.absolute as f32 * multiplier; let dy = vertical.absolute as f32 * multiplier;
self.app.on_pointer_axis( pos.x, pos.y, dx, dy ); 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();
} }
} }
} }

View File

@@ -159,6 +159,13 @@
//! `landscape` % of the **height** when it is landscape (the short side //! `landscape` % of the **height** when it is landscape (the short side
//! of each orientation, but with its own proportion). //! 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 //! For images, pair it with
//! [`Image::short_side`](widget::image::Image::short_side), which sizes //! [`Image::short_side`](widget::image::Image::short_side), which sizes
//! the image along the screen's short side and lets the other axis follow //! 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::{ fluid_reference, set_fluid_reference };
pub use types::{ density, set_density }; pub use types::{ density, set_density };
pub use types::{ widget_scaling, set_widget_scaling }; 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 widget::{ Element, button, icon_button, text_edit, image as img_widget, text, container };
pub use text_shaping::measure_text; pub use text_shaping::measure_text;
pub use widget::rich_text::{ rich_text, RichText, LinkSpan }; pub use widget::rich_text::{ rich_text, RichText, LinkSpan };

View File

@@ -104,7 +104,7 @@ pub fn window_controls() -> WindowControlsSpec
default_window_controls( Palette::from_slots( &mode.slots ) ) 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`] struct. This is a one-call shortcut equivalent to
/// `Palette::from_slots(&active_document().mode(active_mode()).slots)`, /// `Palette::from_slots(&active_document().mode(active_mode()).slots)`,
/// covering the common case where a widget needs `text_primary` / /// covering the common case where a widget needs `text_primary` /

View File

@@ -312,9 +312,9 @@ pub fn tint_symbolic( rgba: &[u8], tint: Color ) -> Vec<u8>
/// Process-wide cache of rasterised theme icons, keyed by (absolute path on /// Process-wide cache of rasterised theme icons, keyed by (absolute path on
/// disk, target longest-edge size in physical pixels). Entries are produced /// disk, target longest-edge size in physical pixels). Entries are produced
/// by [`icon_rgba`] and never invalidated — the key embeds the absolute path /// by [`icon_rgba`]. The key embeds the absolute path and the icon files
/// and the icon files are read-only on disk, so a `set_active_document` /// are read-only on disk, so entries never go stale; [`clear_svg_cache`]
/// switch produces fresh keys rather than serving stale data. /// empties the map on `set_active_document` purely to drop dead memory.
static SVG_CACHE: Mutex<Option<HashMap<( PathBuf, u32 ), ( Arc<Vec<u8>>, u32, u32 )>>> static SVG_CACHE: Mutex<Option<HashMap<( PathBuf, u32 ), ( Arc<Vec<u8>>, u32, u32 )>>>
= Mutex::new( None ); = Mutex::new( None );

View File

@@ -16,10 +16,10 @@
//! /usr/share/ltk/themes/<id>/ (system overlay, lower priority) //! /usr/share/ltk/themes/<id>/ (system overlay, lower priority)
//! ~/.local/share/ltk/themes/<id>/ (user overlay, higher priority) //! ~/.local/share/ltk/themes/<id>/ (user overlay, higher priority)
//! theme.json //! theme.json
//! background-light.png //! branding/{light,dark}/ wallpaper, lockscreen, logos
//! background-dark.png //! icons/apps/ per-application icons
//! fonts/ //! icons/catalogue/{filled,line}/ symbolic glyph catalogue
//! Sora-Regular.ttf //! cursors/ + cursor.theme consumed by the compositor
//! ``` //! ```
//! //!
//! Paths inside `theme.json` are interpreted relative to the theme's //! Paths inside `theme.json` are interpreted relative to the theme's
@@ -35,11 +35,13 @@
//! [`palette()`], …) cover the common patterns without going through the //! [`palette()`], …) cover the common patterns without going through the
//! full document. //! full document.
//! //!
//! There is **no in-code fallback**: if `ensure_active` cannot locate the //! When `ensure_active` cannot locate the `default` theme in any search
//! `default` theme in any search path, the process aborts with a message //! path, an embedded B/W fallback document is installed instead and the
//! pointing at the `ltk-theme-default` Debian package (it `Provides: //! process keeps running: [`is_fallback_active`] flips on, the draw path
//! ltk-theme`) or at the `LTK_THEMES_DIR` environment variable for //! stamps a red warning banner, and stderr points at the
//! development installations. //! `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 }; use std::sync::{ Arc, OnceLock };

View File

@@ -1,7 +1,7 @@
// SPDX-License-Identifier: LGPL-2.1-only // SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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 //! the derived [`WindowControlsSpec`] fallback used when a theme document
//! omits the explicit `window_controls` block. //! omits the explicit `window_controls` block.
@@ -46,10 +46,11 @@ pub struct Palette
impl 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 /// Slot ids are the ones declared in the default theme JSON
/// (`bg-page`, `surface`, `surface-alt`, `text-primary`, /// (`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 /// fall back to a documented sensible default so downstream widgets
/// never see uninitialised colours. Used by [`crate::theme::palette()`] and /// never see uninitialised colours. Used by [`crate::theme::palette()`] and
/// [`crate::theme::window_controls`]. /// [`crate::theme::window_controls`].

View File

@@ -63,8 +63,8 @@ pub enum Slot
Paint { value: Paint, meta: Metadata }, Paint { value: Paint, meta: Metadata },
/// An ordered stack of outer shadows, typically an elevation level. /// An ordered stack of outer shadows, typically an elevation level.
Shadows { value: Vec<Shadow>, meta: Metadata }, Shadows { value: Vec<Shadow>, meta: Metadata },
/// A composite surface: fill, outer shadows (ref or inline), inset /// A composite surface: fill, outer shadows (ref or inline) and
/// shadows and an optional backdrop. /// inset shadows.
Surface { value: Surface, meta: Metadata }, Surface { value: Surface, meta: Metadata },
/// A resolved text style (family, weight, size, line-height, …). /// A resolved text style (family, weight, size, line-height, …).
TextStyle { value: TextStyle, meta: Metadata }, TextStyle { value: TextStyle, meta: Metadata },

View File

@@ -703,6 +703,48 @@ pub fn density() -> f32
f32::from_bits( DENSITY_BITS.load( Ordering::Relaxed ) ) 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 /// 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 /// app does not override it. The two modes ltk offers, chosen per process
/// with [`set_widget_scaling`]: /// with [`set_widget_scaling`]:

View File

@@ -68,7 +68,7 @@ use crate::layout::column::column;
use crate::layout::row::row; use crate::layout::row::row;
use crate::layout::spacer::spacer; use crate::layout::spacer::spacer;
use crate::layout::stack::stack; use crate::layout::stack::stack;
use crate::types::{ Color, Corners }; use crate::types::{ Color, Corners, Length };
use super::container::container; use super::container::container;
use super::pressable::pressable; use super::pressable::pressable;
@@ -80,8 +80,8 @@ mod tests;
/// Default scrim opacity over the underlying surface. /// Default scrim opacity over the underlying surface.
pub const SCRIM_ALPHA: f32 = 0.45; pub const SCRIM_ALPHA: f32 = 0.45;
/// Default card max-width (logical pixels). Override with /// Default card max-width design size, applied as `Length::fluid` so
/// [`Dialog::max_width`]. /// the card tracks the surface. Override with [`Dialog::max_width`].
pub const DEFAULT_MAX_WIDTH: f32 = 480.0; pub const DEFAULT_MAX_WIDTH: f32 = 480.0;
/// Default card corner radius. /// Default card corner radius.
pub const CARD_RADIUS: f32 = 16.0; pub const CARD_RADIUS: f32 = 16.0;
@@ -119,7 +119,7 @@ pub struct Dialog<Msg: Clone>
/// while the dialog is on screen. Wire this to the same message /// while the dialog is on screen. Wire this to the same message
/// your "Cancel" action button uses. /// your "Cancel" action button uses.
pub( crate ) cancel_msg: Option<Msg>, pub( crate ) cancel_msg: Option<Msg>,
pub( crate ) max_width: f32, pub( crate ) max_width: Length,
} }
impl<Msg: Clone> Default for Dialog<Msg> impl<Msg: Clone> Default for Dialog<Msg>
@@ -145,7 +145,7 @@ impl<Msg: Clone> Dialog<Msg>
modal: true, modal: true,
dismiss_msg: None, dismiss_msg: None,
cancel_msg: None, cancel_msg: None,
max_width: DEFAULT_MAX_WIDTH, max_width: Length::fluid( DEFAULT_MAX_WIDTH ),
} }
} }
@@ -209,11 +209,13 @@ impl<Msg: Clone> Dialog<Msg>
self self
} }
/// Override the card's maximum width in logical pixels. Default /// Override the card's maximum width. Accepts logical `f32`
/// is `480.0`. /// pixels or any [`Length`]. Default is `Length::fluid( 480.0 )`,
pub fn max_width( mut self, w: f32 ) -> Self /// so the card scales with the same curve as the stock buttons
/// inside it.
pub fn max_width( mut self, w: impl Into<Length> ) -> Self
{ {
self.max_width = w; self.max_width = w.into();
self self
} }
@@ -230,8 +232,11 @@ impl<Msg: Clone + 'static> From<Dialog<Msg>> for Element<Msg>
let palette = crate::theme::palette(); let palette = crate::theme::palette();
// 1. Inner card column: title, subtitle, body, actions. // 1. Inner card column: title, subtitle, body, actions. The
let mut card_col = column::<Msg>().spacing( SECTION_GAP ); // 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::<Msg>().spacing( SECTION_GAP ).padding( 0.0 );
if let Some( title ) = d.title if let Some( title ) = d.title
{ {
card_col = card_col.push( card_col = card_col.push(
@@ -286,11 +291,14 @@ impl<Msg: Clone + 'static> From<Dialog<Msg>> for Element<Msg>
// 4. Center the card on the screen. The outer column claims // 4. Center the card on the screen. The outer column claims
// the full surface; `center_y` + `align_center_x` keep the // the full surface; `center_y` + `align_center_x` keep the
// card vertically and horizontally centered, and `max_width` // 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::<Msg>() let centered = column::<Msg>()
.center_y( true ) .center_y( true )
.align_center_x( true ) .align_center_x( true )
.max_width( d.max_width ) .max_width( d.max_width )
.padding( 16.0 )
.push( card_swallow ); .push( card_swallow );
// 5. Scrim — a full-bleed Pressable with the dim layer // 5. Scrim — a full-bleed Pressable with the dim layer

View File

@@ -18,7 +18,7 @@ fn new_defaults_are_modal_with_no_content()
assert!( d.actions.is_empty() ); assert!( d.actions.is_empty() );
assert!( d.dismiss_msg.is_none() ); assert!( d.dismiss_msg.is_none() );
assert!( d.cancel_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 ] #[ test ]
@@ -77,7 +77,7 @@ fn cancel_builder_records_escape_message()
fn max_width_builder_overrides_default() fn max_width_builder_overrides_default()
{ {
let d = Dialog::<Msg>::new().max_width( 720.0 ); let d = Dialog::<Msg>::new().max_width( 720.0 );
assert_eq!( d.max_width, 720.0 ); assert_eq!( d.max_width, crate::types::Length::px( 720.0 ) );
} }
#[ test ] #[ test ]

View File

@@ -40,7 +40,7 @@ pub struct ListItem<Msg: Clone>
/// present). /// present).
pub( crate ) label: String, pub( crate ) label: String,
/// Optional secondary line drawn below the label in muted colour. /// 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<String>, pub( crate ) subtitle: Option<String>,
/// Optional right-aligned text (current setting, badge count). /// Optional right-aligned text (current setting, badge count).
/// Drawn in muted colour. /// Drawn in muted colour.
@@ -109,7 +109,7 @@ impl<Msg: Clone> ListItem<Msg>
self 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. /// fit both lines comfortably.
pub fn subtitle( mut self, s: impl Into<String> ) -> Self pub fn subtitle( mut self, s: impl Into<String> ) -> Self
{ {

View File

@@ -9,7 +9,7 @@ mod theme;
/// A horizontal divider line. /// 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 /// with vertical padding above and below. Use to break a column into
/// visual sections — between settings groups, list categories or content /// visual sections — between settings groups, list categories or content
/// blocks. The line takes the divider colour from the active theme by /// blocks. The line takes the divider colour from the active theme by
@@ -44,8 +44,8 @@ pub struct Separator
impl Separator impl Separator
{ {
/// Create a separator with the theme's default divider colour and /// Create a separator with the theme's default divider colour; the
/// 1 px thickness. /// thickness follows the widget-scaling default unless overridden.
pub fn new() -> Self pub fn new() -> Self
{ {
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 /// ```rust,no_run
/// # use ltk::{ column, separator, text, Element }; /// # use ltk::{ column, separator, text, Element };

View File

@@ -143,7 +143,7 @@ pub struct TextEdit<Msg: Clone>
/// default for forms but wrong when the field needs to be sized /// default for forms but wrong when the field needs to be sized
/// to fit a fixed number of glyphs (date / time pickers, inline /// to fit a fixed number of glyphs (date / time pickers, inline
/// numeric inputs). /// numeric inputs).
pub( crate ) fixed_width: Option<f32>, pub( crate ) fixed_width: Option<Length>,
/// Font size in pixels for the single-line draw path. `0.0` (the /// Font size in pixels for the single-line draw path. `0.0` (the
/// Label font size. `None` follows the process [`crate::WidgetScaling`] /// Label font size. `None` follows the process [`crate::WidgetScaling`]
/// mode at the theme default (via [`crate::Canvas::font_px`]); a /// mode at the theme default (via [`crate::Canvas::font_px`]); a
@@ -299,10 +299,12 @@ impl<Msg: Clone> TextEdit<Msg>
} }
/// Override the preferred width reported to the parent layout. /// Override the preferred width reported to the parent layout.
/// Pass `None` (default) to fall back to claiming `max_width`. /// Accepts logical `f32` pixels or any [`Length`] — pair a
pub fn fixed_width( mut self, w: f32 ) -> Self /// [`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<Length> ) -> Self
{ {
self.fixed_width = Some( w ); self.fixed_width = Some( w.into() );
self self
} }
@@ -442,7 +444,7 @@ impl<Msg: Clone> TextEdit<Msg>
( max_width, h ) ( max_width, h )
} else { } else {
let w = self.fixed_width 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 ); .unwrap_or( max_width );
let h = self.height let h = self.height
.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) ) .map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )

View File

@@ -469,7 +469,7 @@ fn borderless_builder_toggles_flag()
fn fixed_width_builder_stores_value() fn fixed_width_builder_stores_value()
{ {
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).fixed_width( 72.0 ); 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 ] #[ test ]

View File

@@ -317,7 +317,7 @@ impl<Msg: Clone + 'static> TimePicker<Msg>
let snapshot = value; let snapshot = value;
text_edit::<Msg>( "", display ) text_edit::<Msg>( "", display )
.borderless( true ) .borderless( true )
.fixed_width( 72.0 ) .fixed_width( Length::fluid( 72.0 ) )
.font_size_fluid( theme::VAL_FS ) .font_size_fluid( theme::VAL_FS )
.align( TextAlign::Center ) .align( TextAlign::Center )
.select_on_focus( true ) .select_on_focus( true )