responsive fluid/physical scaling, widget-API stabilization, and perf guardrails
Some checks are pending
CI / build + test (push) Waiting to run
CI / cargo audit (push) Waiting to run

Responsive scaling. ltk now offers two first-class ways to size a UI so it adapts across screens, chosen per process via `WidgetScaling { Fluid, Physical }` (`set_widget_scaling` / `widget_scaling`, default `Fluid`). Fluid sizing (`Length::fluid( px )`) makes a design pixel a proportion of the surface's smaller side, calibrated against a reference width (`set_fluid_reference` / `fluid_reference`, 412 px default) and bounded by `FLUID_MIN` / `FLUID_MAX`; physical sizing (`Length::dp( px )`) is a constant-physical-size pixel scaled by display density (`set_density` / `density`). `Length` gains `orient( portrait, landscape )` — resolve one value in portrait, another in landscape — plus `widget( px )`, which picks fluid or dp per the active mode. Canvas exposes `geom_px` (geometry, resolved in physical layout space) and `font_px` (font size, bridging logical / physical per mode) so widgets and apps share one resolution path. Note the rename: `set_design_reference` / `design_reference` became `set_fluid_reference` / `fluid_reference`, and `Length::dp` changed meaning — the old surface-proportional behaviour now lives on `Length::fluid`.
Widgets. Every stock widget resolves its default geometry and font through the widget-scaling mode instead of frozen pixels, so a whole UI scales coherently without per-call units. New size builders where they were missing: `button` gains `font_size` / `height`, `text_edit` gains `height` / `font_size_fluid`, `separator` gains `pad_v`, and assorted widgets accept a `Length` where they previously took only `f32`.
Overlays. `OverlaySpec::size` is now `( Length, Length )` instead of `( u32, u32 )`, resolved against the main surface when the overlay is materialized, so overlays can scale with the display; `Length::px( … )` reproduces the old fixed sizing.
API stabilization (toward 1.0). Widget struct fields are now `pub( crate )` — they are configured through builders, not field access — except the value / state types apps genuinely read or construct (`Time`, `Date`, `ComboState`), which stay public. The internal `test_support` helpers move behind a `test-support` Cargo feature (off by default, so third-party builds never see them; ltk's own `make test` enables it). `Separator` drops its `0.0`-means-mode sentinel for `Option<Length>`, so an explicit `pad_v( 0.0 )` is a real flush divider distinct from the mode-following default.
Performance guardrails. Opt-in diagnostics via `LTK_PERF_WARN=1` warn about stuck animations, sustained software-render animation, and low `poll_interval`; software-rendered animation is capped near 30 Hz to spare CPU on machines that fall back off EGL. Apps can override the cap with `App::cap_software_animation`.
Docs and build. The two scaling modes are documented in README, onboarding and architecture, with the earlier gradient / backdrop doc drift cleaned up. The Makefile now ships the `locales/` directory into the packaged crate (fixing i18n keys rendering raw for downstreams), builds the new `responsive` example, and runs tests with `--features test-support`.
This commit is contained in:
2026-07-07 17:40:33 +02:00
parent d4d7ee742e
commit ce893ac776
83 changed files with 1850 additions and 526 deletions

View File

@@ -22,6 +22,13 @@ exclude = [
"TODO", "TODO",
] ]
[features]
# Exposes `ltk::test_support` — internal helpers (widget handlers, laid-out
# widget lookup, slider math) used only by ltk's own integration tests. Not
# part of the stable public API; off by default so third-party builds never
# see it. `make test` enables it.
test-support = []
[dependencies] [dependencies]
chrono = { version = "0.4", features = ["clock"] } chrono = { version = "0.4", features = ["clock"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }

View File

@@ -11,7 +11,7 @@ all:
cargo build --release cargo build --release
test: test:
cargo test cargo test --features test-support
# Typecheck the `no_run` snippets in docs/*.md by feeding each markdown # Typecheck the `no_run` snippets in docs/*.md by feeding each markdown
# file to `rustdoc --test`. Catches API drift in cookbook + widget # file to `rustdoc --test`. Catches API drift in cookbook + widget
@@ -29,13 +29,14 @@ doc:
install: doc install: doc
install -d $(REGISTRY) install -d $(REGISTRY)
cp -r src benches Cargo.toml liberux.toml $(REGISTRY)/ cp -r src benches locales Cargo.toml liberux.toml $(REGISTRY)/
cp debian/cargo-checksum.json $(REGISTRY)/.cargo-checksum.json cp debian/cargo-checksum.json $(REGISTRY)/.cargo-checksum.json
install -d $(DOCDIR) install -d $(DOCDIR)
cp -r target/doc/* $(DOCDIR)/ cp -r target/doc/* $(DOCDIR)/
examples: examples:
LTK_THEMES_DIR=themes cargo run --release --example showcase LTK_THEMES_DIR=themes cargo run --release --example showcase
LTK_THEMES_DIR=themes cargo run --release --example responsive
LTK_THEMES_DIR=themes cargo run --release --example inputs LTK_THEMES_DIR=themes cargo run --release --example inputs
LTK_THEMES_DIR=themes cargo run --release --example scroll LTK_THEMES_DIR=themes cargo run --release --example scroll
LTK_THEMES_DIR=themes cargo run --release --example sliders LTK_THEMES_DIR=themes cargo run --release --example sliders

View File

@@ -175,6 +175,7 @@ cargo run --example showcase
Useful entry points in this repository: Useful entry points in this repository:
- `cargo run --example showcase` - `cargo run --example showcase`
- `cargo run --example responsive`
- `cargo run --example widgets` - `cargo run --example widgets`
- `cargo run --example inputs` - `cargo run --example inputs`
- `cargo run --example scroll` - `cargo run --example scroll`
@@ -187,6 +188,7 @@ Useful entry points in this repository:
In general: In general:
- start with `showcase` for a regular app window - start with `showcase` for a regular app window
- use `responsive` to see the fluid vs physical modes on stock widgets
- use `widgets` to see the core controls - use `widgets` to see the core controls
- use `mini_shell` if you need overlays, theme switching, or shell-style - use `mini_shell` if you need overlays, theme switching, or shell-style
composition composition
@@ -211,6 +213,25 @@ More advanced APIs are available when needed:
- `core::UiSurface` - `core::UiSurface`
- runtime theme APIs - runtime theme APIs
## Responsive Design
`ltk` offers **two** first-class ways to make an interface adapt to the
display, chosen per value or per process:
- **Fluid** — sizes are a fraction of the surface, tracking the short side
(width in portrait, height in landscape). Best for full-screen system
surfaces. Written with `Length::vmin` / `orient` / `fluid` and bounded with
`.clamp`.
- **Physical** — sizes stay a constant real-world size across displays (the
mainstream HiDPI `dp` model). Best for conventional windowed apps. Written
with `Length::dp` plus `set_density`.
Stock widgets follow the process-wide mode (`set_widget_scaling`, fluid by
default); an explicit `Length` on a widget always overrides it. The full
mechanics live in [`docs/architecture.md`](docs/architecture.md#responsive-sizing),
a walkthrough in [`docs/onboarding.md`](docs/onboarding.md), and the per-item
reference in the `Length` / `WidgetScaling` rustdoc.
## Windows and Shell Surfaces ## Windows and Shell Surfaces
By default, `ltk` creates a regular `xdg-shell` window. By default, `ltk` creates a regular `xdg-shell` window.
@@ -257,13 +278,17 @@ The library already provides:
## Backend Differences ## Backend Differences
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, yet. The widget tree, layout, hit-testing, text, images, fills, strokes and
clipping and gradients all paint identically on both paths. The gap is in the clipping all paint identically on both paths. The gaps are in gradients and the
shadow / backdrop pipeline. shadow / backdrop pipeline.
Effects that currently render only on the **GLES** backend, and are silent Effects that currently render only on the **GLES** backend, and degrade on the
no-ops on the **Software** backend: **Software** backend:
- **Gradients** (linear and radial, via `Canvas::fill_paint_rect`) — rendered with
dedicated shaders on GLES; on software they collapse to a flat fill from the
first stop (tiny-skia can render gradients natively, but that is not wired up
yet).
- **Outer drop shadows** (`Canvas::fill_shadow_outer`) — themed surfaces that - **Outer drop shadows** (`Canvas::fill_shadow_outer`) — themed surfaces that
declare a `Shadow` slot show the soft halo on GLES and a flat fill on declare a `Shadow` slot show the soft halo on GLES and a flat fill on
software. software.

View File

@@ -151,6 +151,19 @@ For many third-party apps, theming is optional at first. It is reasonable to
start with the default theme and come back to the runtime theme APIs later as start with the default theme and come back to the runtime theme APIs later as
part of the `ltk::runtime` layer. part of the `ltk::runtime` layer.
## Responsive sizing
Every size in a widget tree is a `Length`, resolved to concrete pixels at layout time against the surface. Two coordinate spaces matter. **Geometry** (widths, heights, paddings, gaps, box sizes) is computed in *physical* pixels — the layout root rect is `pw × ph` — so geometry `Length` values resolve against `Canvas::viewport_layout()` (physical). **Font sizes** are the exception: they resolve against `Canvas::viewport_logical()` (physical ÷ `dpi_scale`) and are multiplied by `dpi_scale` again at raster time, so a `vmin` font ends up as a fraction of the *physical* short side regardless of `dpi_scale`. Keep this split in mind when adding a widget: resolve a geometry constant with `Canvas::geom_px(n)` and a font constant with `Canvas::font_px(n)` — the two helpers hide the difference.
`ltk` offers two adaptation strategies, and both live in the same `Length` type so an app can mix them per value:
- **Fluid** (`Length::fluid(n)`, and the raw `vmin` / `vmax` / `vw` / `vh` / `orient` units): surface-proportional. `fluid(n)` reads a single design pixel `n` as `vmin(n / fluid_reference() * 100).clamp(n * FLUID_MIN, n * FLUID_MAX)` — at a surface whose short side equals the reference (412 px by default) it is exactly `n`, and it scales with the short side elsewhere, auto-clamped to `[0.7n, 1.5n]`. This tracks the width in portrait and the height in landscape, because the short side *is* the width in portrait and the height in landscape. `orient(portrait, landscape)` is the escape hatch for a different percentage per orientation.
- **Physical** (`Length::dp(n)`): constant physical size. `dp(n)` is `n × density()`, where `density()` is a process-wide factor (default `1.0`, typically set from the output DPI via `set_density`). It does not scale with the surface, only with pixel density — the mainstream HiDPI `dp`.
Stock widgets do not hard-code either strategy. Each carries a design pixel per dimension (e.g. `button` height 48, font 16) and resolves it through the process-wide `WidgetScaling` mode: `Length::widget(n)` returns `fluid(n)` under `WidgetScaling::Fluid` (the default) or `dp(n)` under `WidgetScaling::Physical`. `set_widget_scaling(mode)` flips it once for the whole app. An explicit `Length` on an individual widget (`button.height(...)`, `text_edit.height(...)`, `font_size(...)`) bypasses the mode entirely — the mode only decides the meaning of the *default* design pixels, never an override the app wrote on purpose.
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.
## 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()`:
@@ -228,10 +241,12 @@ The cheap things and the expensive things, in rough order:
- *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 60 Hz and burns battery on the mobile target.
- *Lower `poll_interval()` is not free.* Crustace polls every 30 s because the clock only shows HH:MM. If your UI shows seconds, `Some(Duration::from_secs(1))` is fine; if it shows nothing time-sensitive, leave it `None`. - *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; both render the same pixels (see the recent commits for the alpha/SDF parity work). There is no API-level difference for the application. - *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`.
## Where to look in the consumer repos ## Where to look in the consumer repos
| Pattern | File | | Pattern | File |

View File

@@ -13,6 +13,7 @@ reference, see [`docs/widgets.md`](./widgets.md). For theme JSON, see
## Table of contents ## Table of contents
- [Responsive sizing across mobile / tablet / desktop](#responsive-sizing-across-mobile--tablet--desktop)
- [Slide-in panel](#slide-in-panel) - [Slide-in panel](#slide-in-panel)
- [Password field with PAM submit](#password-field-with-pam-submit) - [Password field with PAM submit](#password-field-with-pam-submit)
- [Swipe-to-dismiss overlay](#swipe-to-dismiss-overlay) - [Swipe-to-dismiss overlay](#swipe-to-dismiss-overlay)
@@ -28,6 +29,45 @@ reference, see [`docs/widgets.md`](./widgets.md). For theme JSON, see
--- ---
## Responsive sizing across mobile / tablet / desktop
Size everything as a fraction of the surface so one view reads
coherently from a portrait phone to a landscape desktop window,
without per-target forks. The rule is *fluid but clamped*: a `vmin`
percentage tracks the surface's smaller side, and a px `clamp` keeps
it from collapsing on a tiny screen or ballooning on a 4K monitor.
Use `Length::orient( portrait, landscape )` when the *proportion*
itself should differ between orientations, and `Image::short_side` to
size a logo along the screen's short side while preserving its aspect
ratio.
```rust,no_run
# use std::sync::Arc;
# use ltk::{ column, img_widget, text, Length, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( logo: Arc<Vec<u8>>, lw: u32, lh: u32 ) -> Element<Msg> {
column::<Msg>()
.padding( Length::vmin( 4.0 ).clamp( 16.0, 48.0 ) )
.spacing( Length::vmin( 2.0 ).clamp( 8.0, 24.0 ) )
// Logo: 40 % of the width in portrait, 5 % of the height in landscape.
.push( img_widget( logo, lw, lh ).short_side( Length::orient( 40.0, 5.0 ) ) )
// Heading: fluid, but never below 20 px nor above 44 px.
.push( text( "Welcome" ).size( Length::vmin( 6.0 ).clamp( 20.0, 44.0 ) ) )
.into()
# }
```
Fluid units scale with the screen's pixels, not real-world
millimetres. For body text that must stay physically legible and
honour the user's font-size preference across an open-ended device
set, prefer `Length::dp` or `Length::em` over raw `vmin`; the
[`typography`](../src/theme/typography.rs) scale (`h0`…`body_xs`)
gives clamped-`vmin` sizes tuned for running text. Reserve
`view()`-level branching on `surface_width` / `surface_height` for
genuine layout restructuring (sidebar → bottom tabs), not for sizing.
---
## Slide-in panel ## Slide-in panel
A quick-settings or notification panel that slides down from the top of A quick-settings or notification panel that slides down from the top of
@@ -36,7 +76,7 @@ edge does not knife-cut against the layer below.
```rust,no_run ```rust,no_run
# use std::time::Instant; # use std::time::Instant;
# use ltk::{ container, text, viewport, Anchor, Element, Layer, OverlayId, OverlaySpec }; # use ltk::{ container, text, viewport, Anchor, Element, Layer, Length, OverlayId, OverlaySpec };
# const OVERLAY_QS: OverlayId = OverlayId( 1 ); # const OVERLAY_QS: OverlayId = OverlayId( 1 );
# const SLIDE_DURATION: f32 = 0.25; # const SLIDE_DURATION: f32 = 0.25;
# #[ derive( Clone ) ] enum Msg { CloseQs } # #[ derive( Clone ) ] enum Msg { CloseQs }
@@ -71,7 +111,7 @@ fn build_quick_settings_overlay( &self ) -> OverlaySpec<Msg>
id: OVERLAY_QS, id: OVERLAY_QS,
layer: Layer::Overlay, layer: Layer::Overlay,
anchor: Anchor::TOP, anchor: Anchor::TOP,
size: ( self.surface_width, visible_h as u32 ), size: ( Length::px( self.surface_width as f32 ), Length::px( visible_h ) ),
exclusive_zone: 0, exclusive_zone: 0,
keyboard_exclusive: false, keyboard_exclusive: false,
input_region: None, input_region: None,
@@ -211,7 +251,7 @@ A modal panel that closes when the user swipes down past a threshold or
taps outside the panel. taps outside the panel.
```rust,no_run ```rust,no_run
# use ltk::{ column, container, spacer, text, Anchor, Element, Layer, OverlayId, OverlaySpec }; # use ltk::{ column, container, spacer, text, Anchor, Element, Layer, Length, OverlayId, OverlaySpec };
# const OVERLAY_MODAL: OverlayId = OverlayId( 2 ); # const OVERLAY_MODAL: OverlayId = OverlayId( 2 );
# #[ derive( Clone ) ] enum Msg { CloseModal } # #[ derive( Clone ) ] enum Msg { CloseModal }
# struct App { modal_open: bool, modal_drag_progress: f32 } # struct App { modal_open: bool, modal_drag_progress: f32 }
@@ -239,7 +279,7 @@ fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
id: OVERLAY_MODAL, id: OVERLAY_MODAL,
layer: Layer::Overlay, layer: Layer::Overlay,
anchor: Anchor::ALL, anchor: Anchor::ALL,
size: ( 0, 0 ), size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: 0, exclusive_zone: 0,
keyboard_exclusive: false, keyboard_exclusive: false,
input_region: None, // accept input input_region: None, // accept input
@@ -488,7 +528,7 @@ blocking other UI.
```rust,no_run ```rust,no_run
# use std::time::Instant; # use std::time::Instant;
# use ltk::{ container, text, App, Anchor, Color, Element, Layer, OverlayId, OverlaySpec }; # use ltk::{ container, text, App, Anchor, Color, Element, Layer, Length, OverlayId, OverlaySpec };
# const OVERLAY_TOAST: OverlayId = OverlayId( 3 ); # const OVERLAY_TOAST: OverlayId = OverlayId( 3 );
# #[ derive( Clone ) ] enum Msg {} # #[ derive( Clone ) ] enum Msg {}
struct AppState struct AppState
@@ -539,7 +579,7 @@ impl App for AppState
id: OVERLAY_TOAST, id: OVERLAY_TOAST,
layer: Layer::Overlay, layer: Layer::Overlay,
anchor: Anchor::BOTTOM, anchor: Anchor::BOTTOM,
size: ( 0, 0 ), size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: 0, exclusive_zone: 0,
keyboard_exclusive: false, keyboard_exclusive: false,
input_region: Some( vec![] ), // pass-through input_region: Some( vec![] ), // pass-through

View File

@@ -322,6 +322,28 @@ enum AppMsg
This is the pattern used by `examples/mini_shell.rs`. This is the pattern used by `examples/mini_shell.rs`.
## Responsive sizing: fluid vs physical
`ltk` gives you two ways to make an interface adapt to the display, and you can
mix them per value. **Fluid** sizes are a fraction of the surface (best for
full-screen system surfaces); **physical** sizes stay a constant real-world
size (best for conventional windowed apps). In practice you write a size as a
`Length` and bound it with `.clamp`:
```rust
use ltk::{ text, Length };
// Fluid: 6 % of the surface's short side, never below 20 px nor above 44 px.
text("Welcome").size(Length::vmin(6.0).clamp(20.0, 44.0));
```
Stock widgets follow a process-wide mode (`set_widget_scaling`, fluid by
default); an explicit `Length` on a widget overrides it. For the units
(`vmin` / `orient` / `dp` / …), the clamp discipline and how it all resolves,
see the *Responsive sizing* section of
[`architecture.md`](architecture.md#responsive-sizing) and the `Length`
rustdoc.
## Recommended learning order ## Recommended learning order
If you are new to the library, this order minimizes confusion: If you are new to the library, this order minimizes confusion:

View File

@@ -23,7 +23,7 @@ use std::time::{ Duration, Instant };
use ltk:: use ltk::
{ {
App, ButtonVariant, Color, Element, Keysym, OverlayId, OverlaySpec, App, ButtonVariant, Color, Element, Keysym, Length, OverlayId, OverlaySpec,
ThemeMode, ThemeMode,
button, column, container, progress_bar, row, slider, spacer, text, toggle, button, column, container, progress_bar, row, slider, spacer, text, toggle,
}; };
@@ -190,7 +190,7 @@ impl App for AppState
id: OVERLAY_QUICK_SETTINGS, id: OVERLAY_QUICK_SETTINGS,
layer: ltk::Layer::Overlay, layer: ltk::Layer::Overlay,
anchor: ltk::Anchor::ALL, anchor: ltk::Anchor::ALL,
size: ( 0, 0 ), size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: 0, exclusive_zone: 0,
keyboard_exclusive: false, keyboard_exclusive: false,
input_region: None, input_region: None,
@@ -207,7 +207,7 @@ impl App for AppState
id: OVERLAY_CONFIRM, id: OVERLAY_CONFIRM,
layer: ltk::Layer::Overlay, layer: ltk::Layer::Overlay,
anchor: ltk::Anchor::ALL, anchor: ltk::Anchor::ALL,
size: ( 0, 0 ), size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: 0, exclusive_zone: 0,
keyboard_exclusive: false, keyboard_exclusive: false,
input_region: None, input_region: None,
@@ -224,7 +224,7 @@ impl App for AppState
id: OVERLAY_TOAST, id: OVERLAY_TOAST,
layer: ltk::Layer::Overlay, layer: ltk::Layer::Overlay,
anchor: ltk::Anchor::ALL, anchor: ltk::Anchor::ALL,
size: ( 0, 0 ), size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: 0, exclusive_zone: 0,
keyboard_exclusive: false, keyboard_exclusive: false,
// Pass-through: the OSD must not steal taps from anything below. // Pass-through: the OSD must not steal taps from anything below.

156
examples/responsive.rs Normal file
View File

@@ -0,0 +1,156 @@
//! `cargo run --example responsive`
//!
//! Demonstrates ltk's two responsive modes side by side on stock widgets.
//! None of the widgets below set an explicit size — they all follow the
//! process-wide [`ltk::WidgetScaling`] mode:
//!
//! - **Fluid** (the default): sizes are a fraction of the surface, so the
//! whole set grows and shrinks as you resize the window.
//! - **Physical**: sizes are a constant real-world size scaled by
//! [`ltk::density`], independent of the surface.
//!
//! Tap **Switch mode** to flip between them, and ** density** to change
//! the physical density. Watch the button, field, checkbox, switch, slider
//! and progress bar resize (or not) accordingly. Esc exits.
//!
//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running
//! Wayland compositor (e.g. sway, labwc, or a full desktop session).
use ltk::
{
App, Element, Keysym, ButtonVariant, WidgetScaling,
button, checkbox, column, progress_bar, row, separator, slider, spacer, text, text_edit, toggle,
set_widget_scaling, set_density, density,
};
#[ derive( Clone ) ]
enum Message
{
SwitchMode,
DensityUp,
DensityDown,
NameChanged( String ),
ToggleCheck,
ToggleSwitch,
SliderChanged( f32 ),
}
struct ResponsiveApp
{
physical: bool,
name: String,
checked: bool,
switched: bool,
volume: f32,
}
impl ResponsiveApp
{
fn new() -> Self
{
// Start in the default fluid mode with a neutral density.
set_widget_scaling( WidgetScaling::Fluid );
set_density( 1.5 );
Self
{
physical: false,
name: String::new(),
checked: true,
switched: false,
volume: 0.4,
}
}
}
impl App for ResponsiveApp
{
type Message = Message;
fn view( &self ) -> Element<Message>
{
let palette = ltk::theme_palette();
let primary = palette.text_primary;
let secondary = palette.text_secondary;
let mode_label = if self.physical
{
format!( "Mode: Physical (density {:.1})", density() )
} else {
"Mode: Fluid (resize the window to see it scale)".to_string()
};
// Mode + density controls. Explicit sizes here so the controls stay
// stable while the demo widgets below react to the mode.
let controls = row::<Message>()
.spacing( 12.0 )
.push( button::<Message>( "Switch mode".to_string() )
.variant( ButtonVariant::Primary )
.font_size( 16.0 )
.height( 44.0 )
.on_press( Message::SwitchMode ) )
.push( button::<Message>( " density".to_string() )
.font_size( 16.0 )
.height( 44.0 )
.on_press( Message::DensityDown ) )
.push( button::<Message>( " density".to_string() )
.font_size( 16.0 )
.height( 44.0 )
.on_press( Message::DensityUp ) );
// The demo widgets — NO explicit sizes, so they follow the mode.
let demo = column::<Message>()
.spacing( 16.0 )
.max_width( 520.0 )
.push( button::<Message>( "A stock button".to_string() ).variant( ButtonVariant::Secondary ).on_press( Message::SwitchMode ) )
.push( text_edit( "A stock text field".to_string(), self.name.clone() ).on_change( Message::NameChanged ) )
.push( checkbox( self.checked ).label( "A stock checkbox".to_string() ).on_toggle( Message::ToggleCheck ) )
.push( toggle( self.switched ).label( "A stock switch".to_string() ).on_toggle( Message::ToggleSwitch ) )
.push( slider( self.volume ).on_change( Message::SliderChanged ) )
.push( progress_bar( self.volume ) );
column::<Message>()
.padding( 32.0 )
.spacing( 20.0 )
.center_y( true )
.push( text( "ltk responsive modes".to_string() ).size( 26.0 ).color( primary ).align_center() )
.push( text( mode_label ).size( 15.0 ).color( secondary ).align_center() )
.push( controls )
.push( separator() )
.push( demo )
.push( spacer().weight( 1 ) )
.push( text( "Esc to exit".to_string() ).size( 12.0 ).color( secondary ).align_center() )
.into()
}
fn update( &mut self, msg: Message )
{
match msg
{
Message::SwitchMode =>
{
self.physical = !self.physical;
set_widget_scaling( if self.physical { WidgetScaling::Physical } else { WidgetScaling::Fluid } );
}
Message::DensityUp => set_density( ( density() + 0.25 ).min( 4.0 ) ),
Message::DensityDown => set_density( ( density() - 0.25 ).max( 0.5 ) ),
Message::NameChanged( v ) => self.name = v,
Message::ToggleCheck => self.checked = !self.checked,
Message::ToggleSwitch => self.switched = !self.switched,
Message::SliderChanged( v ) => self.volume = v,
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Message>
{
if keysym == Keysym::Escape
{
std::process::exit( 0 );
}
None
}
}
fn main()
{
ltk::run( ResponsiveApp::new() );
}

View File

@@ -151,10 +151,22 @@ impl App for ShowcaseApp
// Viewport-relative image: 30 % of the surface width by 10 % of its // Viewport-relative image: 30 % of the surface width by 10 % of its
// height, so it rescales with the window instead of staying fixed. // height, so it rescales with the window instead of staying fixed.
//
// The second image uses the orientation-aware helpers: `short_side`
// sizes it along the screen's short side (width in portrait, height
// in landscape) while `orient` gives that side a different proportion
// per orientation — 40 % of the width in portrait, 8 % of the height
// in landscape — with the other axis following the source aspect.
let banner = column::<Message>() let banner = column::<Message>()
.spacing( 4.0 ) .spacing( 4.0 )
.push( text( "img_widget().size( vw 30, vh 10 )" ).size( 12.0 ).color( secondary ) ) .push( text( "img_widget().size( vw 30, vh 10 )" ).size( 12.0 ).color( secondary ) )
.push( img_widget( self.image.clone(), IMG_W, IMG_H ).size( Length::vw( 30.0 ), Length::vh( 10.0 ) ) ); .push( img_widget( self.image.clone(), IMG_W, IMG_H ).size( Length::vw( 30.0 ), Length::vh( 10.0 ) ) )
.push(
text( "short_side( orient( 40 %w, 8 %h ) ) — resize / rotate to compare" )
.size( Length::orient( 3.0, 2.2 ).clamp( 11.0, 16.0 ) )
.color( secondary )
)
.push( img_widget( self.image.clone(), IMG_W, IMG_H ).short_side( Length::orient( 40.0, 8.0 ) ) );
let vol_label = format!( "Volume: {:.0}%", self.slider_value * 100.0 ); let vol_label = format!( "Volume: {:.0}%", self.slider_value * 100.0 );

View File

@@ -213,9 +213,14 @@ pub struct OverlaySpec<Message: Clone>
/// Screen edges to anchor to. /// Screen edges to anchor to.
pub anchor: Anchor, pub anchor: Anchor,
/// Desired size `( width, height )` in logical pixels. `0` in either /// Desired size `( width, height )`, resolved to logical pixels against
/// component means "fill available space in that dimension". /// the main surface (output) when the overlay is materialized. A
pub size: ( u32, u32 ), /// [`Length::px`](crate::Length::px) keeps a fixed size; a
/// [`Length::widget`](crate::Length::widget) / `vmin` / `dp` makes the
/// overlay surface scale with the display like any other sized element.
/// A component that resolves to `0` (e.g. `Length::px( 0.0 )`) means
/// "fill available space in that dimension".
pub size: ( crate::types::Length, crate::types::Length ),
/// Exclusive zone in pixels reserved from the anchored edge. /// Exclusive zone in pixels reserved from the anchored edge.
/// `-1` requests focus without reserving space, `0` is the default for /// `-1` requests focus without reserving space, `0` is the default for
@@ -722,6 +727,14 @@ pub trait App: 'static
/// The event loop will keep requesting redraws at ~60 fps until this returns `false`. /// The event loop will keep requesting redraws at ~60 fps until this returns `false`.
fn is_animating( &self ) -> bool { false } fn is_animating( &self ) -> bool { false }
/// Cap [`Self::is_animating`] redraws to ~30 Hz when the **software**
/// renderer is active. Default `true`: 60 fps software rasterization is a
/// common battery sink on mobile (no GPU offload), and halving it is
/// usually imperceptible for the animations software fallback can afford.
/// The GLES backend is never capped. Return `false` to keep the full
/// display rate on software.
fn cap_software_animation( &self ) -> bool { true }
/// Return `true` when a finger-tracked swipe or an [`Self::is_animating`] /// Return `true` when a finger-tracked swipe or an [`Self::is_animating`]
/// slide only repositions the app's input-transparent subsurfaces /// slide only repositions the app's input-transparent subsurfaces
/// ([`Self::subsurfaces`]) while the main surface buffer stays unchanged. /// ([`Self::subsurfaces`]) while the main surface buffer stays unchanged.
@@ -733,17 +746,6 @@ pub trait App: 'static
/// panel over a static main surface. /// panel over a static main surface.
fn subsurface_motion_only( &self ) -> bool { false } fn subsurface_motion_only( &self ) -> bool { false }
/// Return `true` while the next frame should swap the expensive
/// Glass passes for cheap fallbacks — currently the
/// `backdrop-filter` blur drops from a 41-tap kernel to a 9-tap
/// kernel, with the snapshot region shrunk to match. The event
/// loop sets this on the renderer right before drawing through an
/// internal low-quality-paint flag.
///
/// Default: matches [`Self::is_animating`], so a settle animation
/// automatically downgrades. Override to include other "in motion"
/// states the runtime cannot observe — e.g. a finger-tracked
/// Background color for the canvas. Override to make the surface /// Background color for the canvas. Override to make the surface
/// transparent or to deviate from the theme. Default: the active /// transparent or to deviate from the theme. Default: the active
/// theme's `bg` token, so the window matches the rest of the /// theme's `bg` token, so the window matches the rest of the

View File

@@ -453,13 +453,22 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
other.draw( canvas, rect, is_focused, is_hovered, is_pressed, cursor_pos, sel_anchor ); other.draw( canvas, rect, is_focused, is_hovered, is_pressed, cursor_pos, sel_anchor );
if other.is_interactive() if other.is_interactive()
{ {
let mut handlers = other.handlers();
// Horizontal sliders resolve their thumb size through the
// widget-scaling mode here — layout has the canvas — so the
// input path maps clicks against the same thumb the renderer
// drew (see `slider::value_from_x_in_rect`).
if matches!( other, Element::Slider( _ ) )
{
handlers.set_slider_thumb_px( crate::widget::slider::resolved_thumb_px( canvas ) );
}
ctx.widget_rects.push( LaidOutWidget ctx.widget_rects.push( LaidOutWidget
{ {
rect, rect,
flat_idx, flat_idx,
id: widget_id, id: widget_id,
paint_rect: other.paint_bounds( rect ), paint_rect: other.paint_bounds( rect ),
handlers: other.handlers(), handlers,
keyboard_focusable: other.is_focusable(), keyboard_focusable: other.is_focusable(),
cursor: other.cursor_shape(), cursor: other.cursor_shape(),
tooltip: other.tooltip().map( str::to_string ), tooltip: other.tooltip().map( str::to_string ),

View File

@@ -256,6 +256,9 @@ pub struct AppData<A: App>
/// Focus request deferred because the target widget was absent from /// Focus request deferred because the target widget was absent from
/// `widget_rects` (e.g. read_only, not yet rebuilt). Retried next draw. /// `widget_rects` (e.g. read_only, not yet rebuilt). Retried next draw.
pub focus_retry: Option<crate::types::WidgetId>, pub focus_retry: Option<crate::types::WidgetId>,
/// Runtime performance guardrails (stuck-animation / poll-interval
/// diagnostics and the software animation-rate cap). See [`super::perf`].
pub perf: super::perf::PerfState,
} }
impl<A: App> AppData<A> impl<A: App> AppData<A>

View File

@@ -661,10 +661,29 @@ impl<A: App> Dispatch<WlCallback, super::SurfaceFocus> for AppData<A>
} }
} }
else else
{
// Perf guardrails: run the opt-in diagnostics and apply
// the software animation-rate cap (~30 Hz). A throttled
// frame keeps the vsync cadence with a bare callback but
// skips the re-raster.
let software = crate::render::is_software_render();
let cap = state.app.cap_software_animation();
if state.perf.animated_frame( software, cap )
{ {
state.view_dirty = true; state.view_dirty = true;
state.main.request_redraw(); state.main.request_redraw();
} }
else if let Some( wl ) = state.main.surface.try_wl_surface().cloned()
{
let _ = wl.frame( &state.qh, super::SurfaceFocus::Main );
wl.commit();
state.main.frame_pending = true;
}
}
}
else
{
state.perf.animation_stopped();
} }
} }
super::SurfaceFocus::Overlay( id ) => super::SurfaceFocus::Overlay( id ) =>

View File

@@ -1,6 +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>
#[ cfg( feature = "test-support" ) ]
use std::collections::HashSet; use std::collections::HashSet;
use crate::app::{ App, InvalidationScope, SurfaceTarget }; use crate::app::{ App, InvalidationScope, SurfaceTarget };
@@ -62,6 +63,10 @@ pub( super ) fn apply_invalidation<A: App>( data: &mut AppData<A>, scope: Invali
/// ///
/// `added` preserves the order of `next` (so creation order is deterministic); /// `added` preserves the order of `next` (so creation order is deterministic);
/// `removed` is unordered (driven by HashMap iteration in the caller). /// `removed` is unordered (driven by HashMap iteration in the caller).
///
/// Exposed only for `test_support`; gated behind that feature so it does not
/// warn as dead code in ordinary builds.
#[ cfg( feature = "test-support" ) ]
pub fn diff_overlay_ids( pub fn diff_overlay_ids(
current: impl IntoIterator<Item = crate::app::OverlayId>, current: impl IntoIterator<Item = crate::app::OverlayId>,
next: &[ crate::app::OverlayId ], next: &[ crate::app::OverlayId ],

View File

@@ -9,6 +9,7 @@ pub( crate ) mod cursor_shape;
pub( crate ) mod drag; pub( crate ) mod drag;
pub( crate ) mod focus; pub( crate ) mod focus;
mod handlers; mod handlers;
pub( crate ) mod perf;
pub( crate ) mod repeat; pub( crate ) mod repeat;
pub( crate ) mod subsurface; pub( crate ) mod subsurface;
pub( crate ) mod surface; pub( crate ) mod surface;
@@ -25,4 +26,5 @@ pub( crate ) use app_data::AppData;
pub( crate ) use surface::{ FrameState, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState }; pub( crate ) use surface::{ FrameState, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
pub use error::RunError; pub use error::RunError;
pub( crate ) use run::{ run, try_run }; pub( crate ) use run::{ run, try_run };
#[ cfg( feature = "test-support" ) ]
pub use invalidation::diff_overlay_ids; pub use invalidation::diff_overlay_ids;

View File

@@ -24,7 +24,7 @@ use wayland_protocols::xdg::shell::client::xdg_positioner::
}; };
use crate::app::App; use crate::app::App;
use crate::types::Rect; use crate::types::{ Length, Rect };
use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState }; use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
/// Sync `data.overlays` with the app's current `App::overlays()` spec list: /// Sync `data.overlays` with the app's current `App::overlays()` spec list:
@@ -106,6 +106,18 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
}; };
let parent_scale_i = data.main.scale_factor.max( 1 ); let parent_scale_i = data.main.scale_factor.max( 1 );
let parent_scale = parent_scale_i as f32; let parent_scale = parent_scale_i as f32;
// `OverlaySpec::size` carries `Length`s resolved against the main
// surface's physical viewport (the app's layout space), so an overlay
// sized with `Length::widget( … )` scales with the display exactly like
// any other widget. `Length::px( n )` keeps a fixed size.
let main_vp = ( data.main.physical_width() as f32, data.main.physical_height() as f32 );
let resolve_size = move | s: &( Length, Length ) | -> ( u32, u32 )
{
(
s.0.resolve( main_vp, Length::EM_BASE_DEFAULT ).max( 0.0 ).round() as u32,
s.1.resolve( main_vp, Length::EM_BASE_DEFAULT ).max( 0.0 ).round() as u32,
)
};
// `OverlaySpec::size` is physical pixels (the app's layout space); the // `OverlaySpec::size` is physical pixels (the app's layout space); the
// layer-shell `set_size` is logical. They only coincide at scale 1, so // layer-shell `set_size` is logical. They only coincide at scale 1, so
// convert here — without it a scale-2 overlay requests a surface twice // convert here — without it a scale-2 overlay requests a surface twice
@@ -124,6 +136,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
let overlays_m = &mut data.overlays; let overlays_m = &mut data.overlays;
for spec in &specs for spec in &specs
{ {
let resolved_size = resolve_size( &spec.size );
if let Some( ss ) = overlays_m.get_mut( &spec.id ) if let Some( ss ) = overlays_m.get_mut( &spec.id )
{ {
// Already-live overlay: propagate size changes to the layer // Already-live overlay: propagate size changes to the layer
@@ -135,14 +148,14 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
// sends a configure; the usual `on_configure` path picks up the // sends a configure; the usual `on_configure` path picks up the
// new dimensions and drives the redraw. Popups don't grow / // new dimensions and drives the redraw. Popups don't grow /
// shrink mid-life — close and reopen instead. // shrink mid-life — close and reopen instead.
if spec.size != ss.last_requested_size if resolved_size != ss.last_requested_size
{ {
if let SurfaceKind::Layer( ref layer_surface ) = ss.surface if let SurfaceKind::Layer( ref layer_surface ) = ss.surface
{ {
let ( lw, lh ) = to_logical_size( spec.size ); let ( lw, lh ) = to_logical_size( resolved_size );
layer_surface.set_size( lw, lh ); layer_surface.set_size( lw, lh );
layer_surface.commit(); layer_surface.commit();
ss.last_requested_size = spec.size; ss.last_requested_size = resolved_size;
} }
} }
// Compare the anchor at integer logical-pixel resolution: // Compare the anchor at integer logical-pixel resolution:
@@ -173,7 +186,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
if let Ok( positioner ) = XdgPositioner::new( xdg_shell ) if let Ok( positioner ) = XdgPositioner::new( xdg_shell )
{ {
let ( ax, ay, aw, ah ) = new_q; let ( ax, ay, aw, ah ) = new_q;
let ( spec_w, spec_h ) = spec.size; let ( spec_w, spec_h ) = resolved_size;
let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 }; let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 };
let popup_h = spec_h.max( 1 ) as i32; let popup_h = spec_h.max( 1 ) as i32;
positioner.set_size( popup_w, popup_h ); positioner.set_size( popup_w, popup_h );
@@ -236,7 +249,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
let ay = ( anchor_rect.y / parent_scale ).round() as i32; let ay = ( anchor_rect.y / parent_scale ).round() as i32;
let aw = ( anchor_rect.width / parent_scale ).round().max( 1.0 ) as i32; let aw = ( anchor_rect.width / parent_scale ).round().max( 1.0 ) as i32;
let ah = ( anchor_rect.height / parent_scale ).round().max( 1.0 ) as i32; let ah = ( anchor_rect.height / parent_scale ).round().max( 1.0 ) as i32;
let ( spec_w, spec_h ) = spec.size; let ( spec_w, spec_h ) = resolved_size;
let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 }; let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 };
let popup_h = spec_h.max( 1 ) as i32; let popup_h = spec_h.max( 1 ) as i32;
positioner.set_size( popup_w, popup_h ); positioner.set_size( popup_w, popup_h );
@@ -274,7 +287,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
} }
popup.wl_surface().commit(); popup.wl_surface().commit();
let mut ss = SurfaceState::<A::Message>::new( SurfaceKind::Popup( popup ), 0.0, String::new() ); let mut ss = SurfaceState::<A::Message>::new( SurfaceKind::Popup( popup ), 0.0, String::new() );
ss.last_requested_size = spec.size; ss.last_requested_size = resolved_size;
ss.last_popup_anchor = Some( anchor_rect ); ss.last_popup_anchor = Some( anchor_rect );
overlays_m.insert( spec.id, ss ); overlays_m.insert( spec.id, ss );
continue; continue;
@@ -290,7 +303,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
layer: spec.layer.to_wlr_layer(), layer: spec.layer.to_wlr_layer(),
exclusive_zone: spec.exclusive_zone, exclusive_zone: spec.exclusive_zone,
anchor: spec.anchor, anchor: spec.anchor,
size: to_logical_size( spec.size ), size: to_logical_size( resolved_size ),
keyboard_exclusive: spec.keyboard_exclusive, keyboard_exclusive: spec.keyboard_exclusive,
namespace: "ltk-overlay", namespace: "ltk-overlay",
}; };
@@ -305,7 +318,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
// instead of rendering at scale 1 until `scale_factor_changed` // instead of rendering at scale 1 until `scale_factor_changed`
// lands a frame or two later. // lands a frame or two later.
ss.scale_factor = parent_scale_i; ss.scale_factor = parent_scale_i;
ss.last_requested_size = spec.size; ss.last_requested_size = resolved_size;
ss.layer_anchor = Some( spec.anchor ); ss.layer_anchor = Some( spec.anchor );
overlays_m.insert( spec.id, ss ); overlays_m.insert( spec.id, ss );
} }

173
src/event_loop/perf.rs Normal file
View File

@@ -0,0 +1,173 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Runtime performance guardrails.
//!
//! The idle/redraw model is efficient only if the app cooperates — a stuck
//! [`App::is_animating`](crate::App::is_animating), an aggressive
//! [`poll_interval`](crate::App::poll_interval), or continuous animation on
//! the software backend all quietly burn CPU/battery. The docs warn about
//! these, but the runtime can also **detect** them (opt-in dev diagnostics,
//! `LTK_PERF_WARN=1`) and **mitigate** one of them (cap animation to ~30 Hz
//! on the software renderer, opt-out via
//! [`App::cap_software_animation`](crate::App::cap_software_animation)).
use std::sync::OnceLock;
use std::time::{ Duration, Instant };
/// A settled animation should return `false` well before this; staying `true`
/// this long is almost always a forgotten `is_animating = false`.
const STUCK_THRESHOLD: Duration = Duration::from_secs( 10 );
/// Continuous software-rendered animation past this is worth flagging on
/// mobile, where it means sustained CPU with no GPU offload.
const SW_ANIM_THRESHOLD: Duration = Duration::from_secs( 3 );
/// Minimum gap between software-backend animation re-rasters (≈ 30 Hz).
const SOFTWARE_ANIM_MIN_INTERVAL: Duration = Duration::from_millis( 33 );
/// A `poll_interval` shorter than this defeats the event-driven idle model.
const POLL_WARN_THRESHOLD: Duration = Duration::from_millis( 100 );
/// `true` when `LTK_PERF_WARN` is set to a non-empty, non-`0` value. Cached.
fn perf_warn_enabled() -> bool
{
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init( ||
std::env::var( "LTK_PERF_WARN" )
.map( |v| !v.is_empty() && v != "0" )
.unwrap_or( false )
)
}
/// Per-runtime performance-guard state. Lives on `AppData`.
pub struct PerfState
{
/// When the current continuous animation began (`None` while idle).
animating_since: Option<Instant>,
/// Last software-backend animation re-raster, for the 30 Hz cap.
last_anim_draw: Instant,
warned_stuck: bool,
warned_software_anim: bool,
warned_poll: bool,
}
impl PerfState
{
pub fn new() -> Self
{
Self
{
animating_since: None,
// Start in the past so the first animated frame is never throttled.
// `checked_sub` guards against underflow shortly after boot.
last_anim_draw: Instant::now().checked_sub( SOFTWARE_ANIM_MIN_INTERVAL ).unwrap_or_else( Instant::now ),
warned_stuck: false,
warned_software_anim: false,
warned_poll: false,
}
}
/// Reset when the app stops animating, so the next animation starts fresh
/// (and can warn again if it, too, gets stuck).
pub fn animation_stopped( &mut self )
{
self.animating_since = None;
self.warned_stuck = false;
self.warned_software_anim = false;
}
/// Called on every animated main-surface frame callback. Runs the opt-in
/// diagnostics and applies the software animation-rate cap. Returns `true`
/// if this frame should actually re-raster, `false` to throttle-skip it
/// (the caller keeps the vsync cadence with a bare frame callback).
pub fn animated_frame( &mut self, software: bool, cap_software: bool ) -> bool
{
let since = *self.animating_since.get_or_insert_with( Instant::now );
if perf_warn_enabled()
{
if !self.warned_stuck && since.elapsed() >= STUCK_THRESHOLD
{
eprintln!(
"[ltk][perf] App::is_animating() has stayed true for {}s — a settled \
animation must return false, or the loop redraws at the display rate \
forever (battery drain).",
STUCK_THRESHOLD.as_secs(),
);
self.warned_stuck = true;
}
if software && !self.warned_software_anim && since.elapsed() >= SW_ANIM_THRESHOLD
{
eprintln!(
"[ltk][perf] continuous animation on the software renderer — sustained \
CPU with no GPU offload (costly on mobile). Prefer event-driven redraws, \
or a GLES-capable compositor.",
);
self.warned_software_anim = true;
}
}
if software && cap_software && self.last_anim_draw.elapsed() < SOFTWARE_ANIM_MIN_INTERVAL
{
return false;
}
self.last_anim_draw = Instant::now();
true
}
/// Warn once (under `LTK_PERF_WARN`) if the app's `poll_interval` is short
/// enough to defeat the idle model.
pub fn warn_poll_interval( &mut self, dur: Duration )
{
if perf_warn_enabled() && !self.warned_poll && dur < POLL_WARN_THRESHOLD
{
eprintln!(
"[ltk][perf] poll_interval() of {}ms defeats the event-driven idle model — \
wake the loop from your worker with set_channel_sender instead of polling.",
dur.as_millis(),
);
self.warned_poll = true;
}
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn gles_animation_is_never_capped()
{
let mut p = PerfState::new();
// Two back-to-back frames on the GPU backend both re-raster.
assert!( p.animated_frame( false, true ) );
assert!( p.animated_frame( false, true ) );
}
#[ test ]
fn software_animation_is_capped_between_rerasters()
{
let mut p = PerfState::new();
// First software frame draws; an immediate second is throttled
// (< 33 ms since the last re-raster).
assert!( p.animated_frame( true, true ) );
assert!( !p.animated_frame( true, true ) );
}
#[ test ]
fn software_cap_opt_out_keeps_full_rate()
{
let mut p = PerfState::new();
assert!( p.animated_frame( true, false ) );
assert!( p.animated_frame( true, false ) );
}
#[ test ]
fn stopping_resets_stuck_warning_state()
{
let mut p = PerfState::new();
let _ = p.animated_frame( false, true );
assert!( p.animating_since.is_some() );
p.animation_stopped();
assert!( p.animating_since.is_none() );
}
}

View File

@@ -331,6 +331,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
overlays_dirty: true, overlays_dirty: true,
first_frame_committed: false, first_frame_committed: false,
focus_retry: None, focus_retry: None,
perf: super::perf::PerfState::new(),
}; };
// Register a calloop channel so the app can send messages from any thread. // Register a calloop channel so the app can send messages from any thread.
@@ -360,6 +361,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
// The timer fires independently of Wayland events, waking the event loop on schedule. // The timer fires independently of Wayland events, waking the event loop on schedule.
if let Some( dur ) = data.app.poll_interval() if let Some( dur ) = data.app.poll_interval()
{ {
data.perf.warn_poll_interval( dur );
event_loop.handle() event_loop.handle()
.insert_source( .insert_source(
Timer::from_duration( dur ), Timer::from_duration( dur ),

View File

@@ -4,7 +4,7 @@
use super::app_data::AppData; use super::app_data::AppData;
use super::surface::SurfaceFocus; use super::surface::SurfaceFocus;
use crate::app::App; use crate::app::App;
use crate::types::Rect; use crate::types::{ Length, Rect };
pub const TOOLTIP_DELAY: std::time::Duration = std::time::Duration::from_millis( 600 ); pub const TOOLTIP_DELAY: std::time::Duration = std::time::Duration::from_millis( 600 );
@@ -132,7 +132,7 @@ impl<A: App> AppData<A>
id, id,
layer: crate::app::Layer::Overlay, layer: crate::app::Layer::Overlay,
anchor: crate::app::Anchor::ALL, anchor: crate::app::Anchor::ALL,
size: ( 0, 0 ), size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: -1, exclusive_zone: -1,
keyboard_exclusive: false, keyboard_exclusive: false,
input_region: Some( Vec::new() ), input_region: Some( Vec::new() ),

View File

@@ -43,17 +43,17 @@ use crate::widget::Element;
/// ``` /// ```
pub struct Column<Msg: Clone> pub struct Column<Msg: Clone>
{ {
pub children: Vec<Element<Msg>>, pub( crate ) children: Vec<Element<Msg>>,
/// Vertical gap between children. Stored as [`Length`] so a `Vmin(2.0)` /// Vertical gap between children. Stored as [`Length`] so a `Vmin(2.0)`
/// or `Em(0.5)` gap scales with the viewport instead of freezing at a /// or `Em(0.5)` gap scales with the viewport instead of freezing at a
/// px constant. /// px constant.
pub spacing: Length, pub( crate ) spacing: Length,
/// Padding on all sides. Same [`Length`] semantics as `spacing`. /// Padding on all sides. Same [`Length`] semantics as `spacing`.
pub padding: Length, pub( crate ) padding: Length,
pub align_center_x: bool, pub( crate ) align_center_x: bool,
pub center_y: bool, pub( crate ) center_y: bool,
pub max_width: Option<Length>, pub( crate ) max_width: Option<Length>,
pub fit_content: bool, pub( crate ) fit_content: bool,
} }
impl<Msg: Clone> Column<Msg> impl<Msg: Clone> Column<Msg>

View File

@@ -41,12 +41,12 @@ use crate::widget::Element;
/// ``` /// ```
pub struct Row<Msg: Clone> pub struct Row<Msg: Clone>
{ {
pub children: Vec<Element<Msg>>, pub( crate ) children: Vec<Element<Msg>>,
/// Horizontal gap between children. [`Length`]; default `8.0` px. /// Horizontal gap between children. [`Length`]; default `8.0` px.
pub spacing: Length, pub( crate ) spacing: Length,
/// Padding on all sides. [`Length`]; default `0.0` px. /// Padding on all sides. [`Length`]; default `0.0` px.
pub padding: Length, pub( crate ) padding: Length,
pub align_right: bool, pub( crate ) align_right: bool,
} }
impl<Msg: Clone> Row<Msg> impl<Msg: Clone> Row<Msg>

View File

@@ -70,15 +70,15 @@ use crate::widget::Element;
pub struct Spacer pub struct Spacer
{ {
/// Relative weight of this spacer (default 1). /// Relative weight of this spacer (default 1).
pub weight: u32, pub( crate ) weight: u32,
/// Fixed height (overrides flexible behavior in a column). Accepts any /// Fixed height (overrides flexible behavior in a column). Accepts any
/// [`Length`] — pass an `f32`/`i32`/`u32` for the px case (kept for /// [`Length`] — pass an `f32`/`i32`/`u32` for the px case (kept for
/// backward compatibility with existing call sites), or /// backward compatibility with existing call sites), or
/// `Length::vmin( … )` etc. for viewport-relative gaps. /// `Length::vmin( … )` etc. for viewport-relative gaps.
pub fixed_height: Option<Length>, pub( crate ) fixed_height: Option<Length>,
/// Fixed width (overrides flexible behavior in a row). Same length-type /// Fixed width (overrides flexible behavior in a row). Same length-type
/// semantics as [`Self::fixed_height`]. /// semantics as [`Self::fixed_height`].
pub fixed_width: Option<Length>, pub( crate ) fixed_width: Option<Length>,
} }
impl Spacer impl Spacer
@@ -222,4 +222,23 @@ mod tests
assert_eq!( s.resolved_height( &canvas ), None ); assert_eq!( s.resolved_height( &canvas ), None );
assert_eq!( s.resolved_width( &canvas ), None ); assert_eq!( s.resolved_width( &canvas ), None );
} }
#[ test ]
fn builder_stores_weight_and_fixed_dimensions()
{
let d = spacer();
assert_eq!( d.weight, 1 );
assert!( d.fixed_height.is_none() );
assert!( d.fixed_width.is_none() );
let h = spacer().height( 24.0 );
assert_eq!( h.fixed_height, Some( Length::px( 24.0 ) ) );
assert!( h.fixed_width.is_none() );
let w = spacer().width( 12.0 );
assert_eq!( w.fixed_width, Some( Length::px( 12.0 ) ) );
assert!( w.fixed_height.is_none() );
assert_eq!( spacer().weight( 5 ).weight, 5 );
}
} }

View File

@@ -56,10 +56,10 @@ pub struct Stack<Msg: Clone>
/// `Option<Rect>` overrides alignment/sizing with an exact rect (see /// `Option<Rect>` overrides alignment/sizing with an exact rect (see
/// [`push_placed`](Self::push_placed)); the 8th clips the child's draw to a /// [`push_placed`](Self::push_placed)); the 8th clips the child's draw to a
/// rect (Android clipChildren — see [`push_placed_clipped`](Self::push_placed_clipped)). /// rect (Android clipChildren — see [`push_placed_clipped`](Self::push_placed_clipped)).
pub children: Vec<( Element<Msg>, HAlign, VAlign, f32, f32, f32, Option<Rect>, Option<Rect> )>, pub( crate ) children: Vec<( Element<Msg>, HAlign, VAlign, f32, f32, f32, Option<Rect>, Option<Rect> )>,
/// When `true`, [`preferred_size`](Self::preferred_size) reports the max of /// When `true`, [`preferred_size`](Self::preferred_size) reports the max of
/// children's intrinsic widths and heights instead of `(max_width, tallest)`. /// children's intrinsic widths and heights instead of `(max_width, tallest)`.
pub fit_content: bool, pub( crate ) fit_content: bool,
} }
impl<Msg: Clone> Stack<Msg> impl<Msg: Clone> Stack<Msg>

View File

@@ -32,18 +32,18 @@ use crate::widget::Element;
pub struct WrapGrid<Msg: Clone> pub struct WrapGrid<Msg: Clone>
{ {
/// Child widgets laid out in row-major order. /// Child widgets laid out in row-major order.
pub children: Vec<Element<Msg>>, pub( crate ) children: Vec<Element<Msg>>,
/// Number of columns per row. /// Number of columns per row.
pub columns: usize, pub( crate ) columns: usize,
/// Horizontal gap between cells. /// Horizontal gap between cells.
pub spacing_x: Length, pub( crate ) spacing_x: Length,
/// Vertical gap between rows. /// Vertical gap between rows.
pub spacing_y: Length, pub( crate ) spacing_y: Length,
/// Padding on all sides. /// Padding on all sides.
pub padding: Length, pub( crate ) padding: Length,
/// When `true`, a partial last row is centred horizontally within /// When `true`, a partial last row is centred horizontally within
/// the grid's content rect instead of being left-aligned. /// the grid's content rect instead of being left-aligned.
pub centre_last_row: bool, pub( crate ) centre_last_row: bool,
} }
impl<Msg: Clone> WrapGrid<Msg> impl<Msg: Clone> WrapGrid<Msg>

View File

@@ -112,34 +112,108 @@
//! //!
//! - [`Color`], [`Rect`], [`Point`], [`Size`], [`Corners`], [`WidgetId`]. //! - [`Color`], [`Rect`], [`Point`], [`Size`], [`Corners`], [`WidgetId`].
//! - [`Length`] — a size/distance that may be absolute pixels //! - [`Length`] — a size/distance that may be absolute pixels
//! ([`LengthBase::Px`]) or relative to the surface viewport //! ([`LengthBase::Px`]), relative to the surface viewport
//! ([`LengthBase::Vw`] / [`LengthBase::Vh`] / [`LengthBase::Vmin`] / //! ([`LengthBase::Vw`] / [`LengthBase::Vh`] / [`LengthBase::Vmin`] /
//! [`LengthBase::Vmax`]) or to the root font size ([`LengthBase::Em`]). //! [`LengthBase::Vmax`] / [`LengthBase::Orient`]) or to the root font
//! Every setter that takes a size, padding, spacing or font height //! size ([`LengthBase::Em`]). Every setter that takes a size, padding,
//! now accepts `impl Into<Length>`, so legacy `.size( 24.0 )` keeps //! spacing or font height now accepts `impl Into<Length>`, so legacy
//! working while new code can write `.size( Length::vmin( 4.0 ) //! `.size( 24.0 )` keeps working while new code can write
//! .clamp( 16.0, 32.0 ) )` for a typeface that scales with the screen. //! `.size( Length::vmin( 4.0 ).clamp( 16.0, 32.0 ) )` for a typeface
//! that scales with the screen. See **Responsive design** below.
//! //!
//! See [`types`] for the full module with `//!` description. //! See [`types`] for the full module with `//!` description.
//! //!
//! ## Designing for multiple resolutions //! ## Responsive design: one UI from phone to desktop
//! //!
//! ltk supports three approaches to making a layout adapt to the screen, //! A core goal of ltk is that a **single view** reads coherently across
//! ordered from most to least common: //! a portrait phone, a tablet and a landscape desktop window — no
//! per-target forks, no media-query soup. The mechanism is **fluid
//! sizing**: express sizes as a fraction of the surface so the whole
//! design breathes with the screen, instead of freezing at one pixel
//! size that only looks right on one device.
//! //!
//! 1. **Relative [`Length`] values** for font sizes, padding, spacing and //! ### The core rule — fluid, but clamped
//! spacer height/width. Default to `Length::vmin( pct ).clamp( lo, hi )`: //!
//! the percentage tracks the surface's smaller side (so portrait and //! Default to `Length::vmin( pct ).clamp( lo, hi )` for every font size,
//! landscape behave coherently), and the px clamps protect both ends //! padding, spacing and spacer:
//! of the spectrum from breaking the design. //!
//! 2. **Responsive typographic scale** via [`theme::typography::h0`]… //! - the percentage tracks the surface's **smaller** side, so portrait
//! [`theme::typography::body_xs`] — pre-calibrated [`Length`] values //! and landscape stay coherent — an element keeps the same fraction of
//! coherent with the default Sora-based theme. Calibrated so a //! the narrow axis whichever way the device is held;
//! 1000-px smaller side reproduces the legacy px constants exactly. //! - the px `clamp` bounds the fluid range so the design never collapses
//! 3. **`view()`-level branching on `surface_width` / `surface_height`** //! on a watch-sized surface nor balloons on a 4K monitor.
//! when the layout structure itself must change (e.g. sidebar → //!
//! bottom-tabs below a breakpoint). Keep this for genuine restructuring, //! The clamp is not optional polish: it is what turns *pure* proportional
//! not for sizing — `Length` covers sizing. //! sizing (fragile at the extremes) into *bounded* proportional sizing
//! (robust everywhere). Treat "always clamp a fluid value" as the rule,
//! not the exception.
//!
//! ### Orientation-dependent proportion — [`Length::orient`]
//!
//! Sometimes the right *proportion* differs by orientation, not merely
//! the reference axis. A logo may want 40 % of the width in portrait —
//! there is vertical room to spare — but only 5 % of the height in
//! landscape, where vertical room is scarce.
//! [`Length::orient`]`( portrait, landscape )` expresses exactly that:
//! `portrait` % of the **width** when the surface is portrait,
//! `landscape` % of the **height** when it is landscape (the short side
//! of each orientation, but with its own proportion).
//!
//! For images, pair it with
//! [`Image::short_side`](widget::image::Image::short_side), which sizes
//! the image along the screen's short side and lets the other axis follow
//! the source aspect ratio:
//!
//! ```rust,no_run
//! # use std::sync::Arc;
//! # use ltk::{ img_widget, Length, Element };
//! # #[ derive( Clone ) ] enum Msg {}
//! # fn _ex( rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
//! // 40 % of the width in portrait, 5 % of the height in landscape.
//! img_widget( rgba, w, h ).short_side( Length::orient( 40.0, 5.0 ) ).into()
//! # }
//! ```
//!
//! ### When constant *physical* size matters instead
//!
//! Fluid units scale with the screen's pixels, not with real-world
//! millimetres — and legibility is a function of physical (angular) size,
//! not of what fraction of the screen a glyph fills. When a size must stay
//! a **constant physical size** across very different displays, use the
//! other mode: [`Length::dp`] (a density-independent pixel — `n ×`
//! [`density`], the mainstream HiDPI unit), or [`LengthBase::Em`] for text
//! relative to the root font size. The pre-calibrated
//! [`theme::typography`] scale
//! ([`theme::typography::h0`]…[`theme::typography::body_xs`]) is built on
//! clamped `vmin`, so it stays fluid while respecting a readable px floor
//! and ceiling — a good default for running text.
//!
//! ### The two modes, and choosing per app
//!
//! ltk offers both strategies as first-class citizens and lets the app
//! pick — per value, or process-wide for every stock widget:
//!
//! - **Fluid** — [`Length::fluid`] and the raw `vmin` / `vmax` / `vw` /
//! `vh` / `orient` units. Surface-proportional; tracks the short side
//! (width in portrait, height in landscape). Best for full-screen system
//! surfaces on known hardware (lock screen, greeter, splash, kiosk,
//! launcher).
//! - **Physical** — [`Length::dp`] plus [`set_density`] / [`density`].
//! Constant real-world size, HiDPI-aware. Best for conventional windowed
//! apps across an open-ended device set.
//!
//! Stock widgets carry one design pixel per dimension and resolve it
//! through the process-wide [`WidgetScaling`] mode
//! ([`set_widget_scaling`]): [`WidgetScaling::Fluid`] (the default) reads
//! it as [`Length::fluid`], [`WidgetScaling::Physical`] as [`Length::dp`].
//! An explicit [`Length`] on a widget always overrides the mode.
//!
//! ### Structural changes — branch in `view()`
//!
//! When the layout *structure* must change (sidebar → bottom tabs, two
//! columns → one) rather than merely resize, branch in `view()` on
//! `surface_width` / `surface_height`. Keep this for genuine
//! restructuring; [`Length`] covers all pure sizing.
//! //!
//! ## Runtime-free embedding //! ## Runtime-free embedding
//! //!
@@ -187,6 +261,12 @@
// English entry. // English entry.
rust_i18n::i18n!( "locales", fallback = "en" ); rust_i18n::i18n!( "locales", fallback = "en" );
/// Serialises tests across modules that read or mutate the process-wide
/// density / [`WidgetScaling`] globals, so parallel test threads never
/// observe each other's transient state.
#[ cfg( test ) ]
pub( crate ) static TEST_GLOBALS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new( () );
pub mod types; pub mod types;
pub( crate ) mod render; pub( crate ) mod render;
pub( crate ) mod system_fonts; pub( crate ) mod system_fonts;
@@ -248,7 +328,10 @@ pub use render::Canvas;
pub use wallpaper::{ WallpaperBundle, ImageData }; pub use wallpaper::{ WallpaperBundle, ImageData };
pub use chassis::{ set_default_theme, theme_logo_rgba, theme_icon_tinted, branding_bundle_or_solid, wallpaper_bundle_or_solid, backdrop }; pub use chassis::{ set_default_theme, theme_logo_rgba, theme_icon_tinted, branding_bundle_or_solid, wallpaper_bundle_or_solid, backdrop };
pub use types::{ Color, Corners, CursorShape, Length, LengthBase, PathCmd, Point, Rect, Size, WidgetId }; pub use types::{ Color, Corners, CursorShape, Length, LengthBase, PathCmd, Point, Rect, Size, WidgetId };
pub use types::{ design_reference, set_design_reference }; pub use types::{ WidgetScaling, FLUID_MIN, FLUID_MAX };
pub use types::{ fluid_reference, set_fluid_reference };
pub use types::{ density, set_density };
pub use types::{ widget_scaling, set_widget_scaling };
pub use 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 };
@@ -468,13 +551,15 @@ pub mod runtime
/// ///
/// **Not part of the stable public API.** Anything in here may change between /// **Not part of the stable public API.** Anything in here may change between
/// patch releases without notice. Hidden from generated docs via /// patch releases without notice. Hidden from generated docs via
/// `#[doc(hidden)]` for the same reason. /// `#[doc(hidden)]`, and gated behind the `test-support` feature so it is
/// absent from third-party builds entirely (ltk's own `make test` enables it).
#[ cfg( feature = "test-support" ) ]
#[ doc( hidden ) ] #[ doc( hidden ) ]
pub mod test_support pub mod test_support
{ {
pub use crate::tree::{ find_widget_at, find_widget, find_handlers, next_focusable_index }; pub use crate::tree::{ find_widget_at, find_widget, find_handlers, next_focusable_index };
pub use crate::widget::{ LaidOutWidget, WidgetHandlers }; pub use crate::widget::{ LaidOutWidget, WidgetHandlers };
pub use crate::widget::slider::{ value_from_x_in_rect, value_from_pos_in_rect, SliderAxis }; pub use crate::widget::slider::{ value_from_x_in_rect, value_from_pos_in_rect, thumb_design_px, SliderAxis };
pub use crate::widget::vslider::value_from_y_in_rect; pub use crate::widget::vslider::value_from_y_in_rect;
pub use crate::event_loop::diff_overlay_ids; pub use crate::event_loop::diff_overlay_ids;
} }

View File

@@ -39,7 +39,7 @@ use tiny_skia::{ Mask, Pixmap };
use crate::gles_render::{ BorrowedGlesTexture, GlesCanvas, GlesVersion }; use crate::gles_render::{ BorrowedGlesTexture, GlesCanvas, GlesVersion };
use crate::theme::{ FontRegistry, FontStyle, InsetShadow, Paint as ThemePaint, Shadow }; use crate::theme::{ FontRegistry, FontStyle, InsetShadow, Paint as ThemePaint, Shadow };
use crate::types::{ Color, Corners, Rect }; use crate::types::{ Color, Corners, Length, Rect, WidgetScaling };
pub( crate ) mod setup; pub( crate ) mod setup;
pub( crate ) mod clip; pub( crate ) mod clip;
@@ -239,6 +239,45 @@ impl Canvas
( pw as f32, ph as f32 ) ( pw as f32, ph as f32 )
} }
/// Resolve a stock-widget **geometry** design pixel (height, padding,
/// box size, gap…) through the process-wide [`crate::WidgetScaling`]
/// mode, into a concrete physical-pixel value for the layout tree.
/// Widgets call this instead of using a raw `theme::` constant so their
/// intrinsic geometry follows the app's chosen adaptation strategy
/// ([`crate::WidgetScaling::Fluid`] → surface-proportional,
/// [`crate::WidgetScaling::Physical`] → constant physical size). Geometry
/// lives in physical space, so this resolves against
/// [`Self::viewport_layout`].
pub fn geom_px( &self, design_px: f32 ) -> f32
{
Length::widget( design_px ).resolve( self.viewport_layout(), Length::EM_BASE_DEFAULT )
}
/// Resolve a stock-widget **font** design pixel through the process-wide
/// [`crate::WidgetScaling`] mode. Font sizes are handed to the raster
/// path pre-`dpi_scale`, so this bridges the logical/physical split for
/// each mode: in [`crate::WidgetScaling::Fluid`] it resolves the fluid
/// size against [`Self::viewport_logical`] (so `× dpi_scale` at raster
/// yields a surface-proportional physical size); in
/// [`crate::WidgetScaling::Physical`] it divides the density-scaled size
/// by `dpi_scale` (so `× dpi_scale` yields a constant physical size).
pub fn font_px( &self, design_px: f32 ) -> f32
{
match crate::types::widget_scaling()
{
WidgetScaling::Fluid =>
{
Length::fluid( design_px ).resolve( self.viewport_logical(), Length::EM_BASE_DEFAULT )
}
WidgetScaling::Physical =>
{
let scale = self.dpi_scale();
let scale = if scale > 0.0 { scale } else { 1.0 };
design_px * crate::types::density() / scale
}
}
}
/// Borrow the GLES texture backing this canvas, when the canvas /// Borrow the GLES texture backing this canvas, when the canvas
/// is GPU-backed. /// is GPU-backed.
pub fn borrowed_gles_texture( &self ) -> Option<BorrowedGlesTexture> pub fn borrowed_gles_texture( &self ) -> Option<BorrowedGlesTexture>
@@ -805,6 +844,45 @@ mod viewport_tests
c.set_dpi_scale( 2.0 ); c.set_dpi_scale( 2.0 );
assert_eq!( c.viewport_layout(), ( 720.0, 1440.0 ) ); assert_eq!( c.viewport_layout(), ( 720.0, 1440.0 ) );
} }
#[ test ]
fn geom_px_resolves_widget_design_pixel_per_mode()
{
use crate::types::{ set_widget_scaling, set_density, WidgetScaling, Length };
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
// Fluid (default): geom_px == fluid( n ) against the physical viewport.
set_widget_scaling( WidgetScaling::Fluid );
let c = Canvas::new( 412, 900 );
assert_eq!( c.geom_px( 48.0 ), Length::fluid( 48.0 ).resolve( ( 412.0, 900.0 ), Length::EM_BASE_DEFAULT ) );
// Physical: geom_px == n * density, independent of surface size.
set_widget_scaling( WidgetScaling::Physical );
set_density( 2.0 );
assert_eq!( c.geom_px( 48.0 ), 96.0 );
set_density( 1.0 );
set_widget_scaling( WidgetScaling::Fluid );
}
#[ test ]
fn font_px_is_constant_physical_in_physical_mode()
{
use crate::types::{ set_widget_scaling, set_density, WidgetScaling };
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
// Physical mode divides by dpi_scale so `× dpi_scale` at raster yields
// a constant physical size (n * density).
set_widget_scaling( WidgetScaling::Physical );
set_density( 2.0 );
let mut c = Canvas::new( 720, 1440 );
c.set_dpi_scale( 2.0 );
// 16 * 2 (density) / 2 (dpi_scale) = 16 logical → 32 physical after raster.
assert_eq!( c.font_px( 16.0 ), 16.0 );
set_density( 1.0 );
set_widget_scaling( WidgetScaling::Fluid );
}
} }
#[ cfg( test ) ] #[ cfg( test ) ]

View File

@@ -24,7 +24,7 @@
//! `ltk::Rect`, …) so application code rarely needs the `ltk::types::` //! `ltk::Rect`, …) so application code rarely needs the `ltk::types::`
//! prefix. //! prefix.
use std::sync::atomic::{ AtomicU32, Ordering }; use std::sync::atomic::{ AtomicU8, AtomicU32, Ordering };
/// An RGBA color with floating-point channels in the range `[0.0, 1.0]`. /// An RGBA color with floating-point channels in the range `[0.0, 1.0]`.
#[ derive( Debug, Clone, Copy, PartialEq ) ] #[ derive( Debug, Clone, Copy, PartialEq ) ]
@@ -468,6 +468,15 @@ pub enum LengthBase
Vmin( f32 ), Vmin( f32 ),
/// Percentage of the viewport's **larger** dimension. /// Percentage of the viewport's **larger** dimension.
Vmax( f32 ), Vmax( f32 ),
/// Orientation-dependent percentage of the viewport's **short** side,
/// with a different proportion per orientation. In portrait (width ≤
/// height) it resolves to `portrait` % of the **width**; in landscape
/// (width > height) to `landscape` % of the **height**. Both axes are
/// the short side of their orientation, but the design proportion
/// differs — e.g. a logo that wants 40 % of the width when there is
/// vertical room to spare, but only 5 % of the (scarce) height when
/// laid out landscape.
Orient { portrait: f32, landscape: f32 },
/// Multiple of the root font size (typographic hierarchy: a heading /// Multiple of the root font size (typographic hierarchy: a heading
/// of `Em(2.0)` is twice the body size, regardless of viewport). /// of `Em(2.0)` is twice the body size, regardless of viewport).
Em( f32 ), Em( f32 ),
@@ -485,6 +494,15 @@ impl LengthBase
LengthBase::Vh( pct ) => vh * pct / 100.0, LengthBase::Vh( pct ) => vh * pct / 100.0,
LengthBase::Vmin( pct ) => vw.min( vh ) * pct / 100.0, LengthBase::Vmin( pct ) => vw.min( vh ) * pct / 100.0,
LengthBase::Vmax( pct ) => vw.max( vh ) * pct / 100.0, LengthBase::Vmax( pct ) => vw.max( vh ) * pct / 100.0,
LengthBase::Orient { portrait, landscape } =>
{
if vw <= vh
{
vw * portrait / 100.0
} else {
vh * landscape / 100.0
}
}
LengthBase::Em( mul ) => em_base * mul, LengthBase::Em( mul ) => em_base * mul,
} }
} }
@@ -539,15 +557,58 @@ impl Length
pub const fn vmax( v: f32 ) -> Self { Self::from_base( LengthBase::Vmax( v ) ) } pub const fn vmax( v: f32 ) -> Self { Self::from_base( LengthBase::Vmax( v ) ) }
pub const fn em( v: f32 ) -> Self { Self::from_base( LengthBase::Em( v ) ) } pub const fn em( v: f32 ) -> Self { Self::from_base( LengthBase::Em( v ) ) }
/// "Design pixel": `px` interpreted at the reference vmin set via /// Orientation-aware size: `portrait` % of the **width** when the
/// [`set_design_reference`] (defaults to 412 px — the eydos mobile /// viewport is portrait, `landscape` % of the **height** when it is
/// reference width). The result is a `Vmin` value clamped to /// landscape. See [`LengthBase::Orient`]. Chain `.clamp( lo, hi )` to
/// `[px * 0.7, px * 1.5]`, so the layout scales with the screen /// bound the result in px as with any relative length.
/// without collapsing on tiny surfaces or ballooning on 4K. pub const fn orient( portrait: f32, landscape: f32 ) -> Self
{
Self::from_base( LengthBase::Orient { portrait, landscape } )
}
/// **Fluid** design pixel (the [`WidgetScaling::Fluid`] mode). `px` is
/// the size at the reference surface set via [`set_fluid_reference`]
/// (defaults to 412 px — the eydos mobile reference width); the value
/// then scales as a fraction of the surface's **short** side (width in
/// portrait, height in landscape) and is auto-clamped to
/// `[px * `[`FLUID_MIN`]`, px * `[`FLUID_MAX`]`]` so it neither
/// collapses on a tiny surface nor balloons on a 4K one. A single
/// design number therefore yields a surface-proportional size with no
/// per-call percentages — this is how stock widgets stay fluid by
/// default. For explicit control use [`Length::vmin`] /
/// [`Length::orient`] with your own [`Length::clamp`].
pub fn fluid( px: f32 ) -> Self
{
let r = fluid_reference();
Length::vmin( px / r * 100.0 ).clamp( px * FLUID_MIN, px * FLUID_MAX )
}
/// **Density-independent** pixel (the [`WidgetScaling::Physical`] mode).
/// `px` is multiplied by the process [`density`] (derived from the
/// output's DPI, or set with [`set_density`]) to yield a **constant
/// physical size** across displays — the mainstream `dp` of Android /
/// Flutter / CSS. Unlike [`Length::fluid`] it does **not** scale with
/// the surface size, only with pixel density. Density defaults to
/// `1.0`, so `dp( n )` == `n` px until a density is set.
pub fn dp( px: f32 ) -> Self pub fn dp( px: f32 ) -> Self
{ {
let r = design_reference(); Length::px( px * density() )
Length::vmin( px / r * 100.0 ).clamp( px * 0.7, px * 1.5 ) }
/// Resolve a stock-widget design pixel through the process-wide
/// [`widget_scaling`] mode: [`Length::fluid`] in [`WidgetScaling::Fluid`]
/// (the default), [`Length::dp`] in [`WidgetScaling::Physical`]. Widgets
/// route their intrinsic geometry / font constants through this (see
/// [`crate::Canvas::geom_px`] / [`crate::Canvas::font_px`]) so a single
/// process-level switch picks the adaptation strategy for every stock
/// widget at once, while explicit [`Length`] overrides still win.
pub fn widget( px: f32 ) -> Self
{
match widget_scaling()
{
WidgetScaling::Fluid => Length::fluid( px ),
WidgetScaling::Physical => Length::dp( px ),
}
} }
/// Resolve to a concrete logical-pixel value given a viewport and an /// Resolve to a concrete logical-pixel value given a viewport and an
@@ -599,21 +660,91 @@ impl Length
} }
} }
static DESIGN_REFERENCE_BITS: AtomicU32 = AtomicU32::new( 412.0_f32.to_bits() ); /// Lower auto-clamp factor of [`Length::fluid`]: a fluid value never
/// resolves below `px * FLUID_MIN`, so it stays usable on a tiny surface.
pub const FLUID_MIN: f32 = 0.7;
/// Upper auto-clamp factor of [`Length::fluid`]: a fluid value never
/// resolves above `px * FLUID_MAX`, so it stays tasteful on a huge surface.
pub const FLUID_MAX: f32 = 1.5;
/// Set the reference vmin width that [`Length::dp`] interprets `px` against. static FLUID_REFERENCE_BITS: AtomicU32 = AtomicU32::new( 412.0_f32.to_bits() );
/// Call once at startup (e.g. before [`crate::run`]) to align the design
/// scale to the surface mock-up the app was designed for. /// Set the reference surface (short-side px) that [`Length::fluid`]
pub fn set_design_reference( reference_vmin: f32 ) /// interprets its design pixels against. Call once at startup (e.g. before
/// [`crate::run`]) to align the fluid scale to the surface mock-up the app
/// was designed for. Default: 412 px.
pub fn set_fluid_reference( reference_vmin: f32 )
{ {
DESIGN_REFERENCE_BITS.store( reference_vmin.to_bits(), Ordering::Relaxed ); FLUID_REFERENCE_BITS.store( reference_vmin.to_bits(), Ordering::Relaxed );
} }
/// Current value used by [`Length::dp`] — the px width at which `dp(n)` /// Current reference used by [`Length::fluid`] — the short-side px at which
/// resolves to `n` logical pixels. /// `fluid( n )` resolves to `n` px before clamping.
pub fn design_reference() -> f32 pub fn fluid_reference() -> f32
{ {
f32::from_bits( DESIGN_REFERENCE_BITS.load( Ordering::Relaxed ) ) f32::from_bits( FLUID_REFERENCE_BITS.load( Ordering::Relaxed ) )
}
static DENSITY_BITS: AtomicU32 = AtomicU32::new( 1.0_f32.to_bits() );
/// Set the process-wide pixel density used by [`Length::dp`] (the
/// [`WidgetScaling::Physical`] mode). Typically derived from the output's
/// physical DPI so `dp` sizes stay physically constant across displays.
/// Default: `1.0`.
pub fn set_density( d: f32 )
{
DENSITY_BITS.store( d.max( 0.0 ).to_bits(), Ordering::Relaxed );
}
/// Current pixel density — the factor [`Length::dp`] multiplies its design
/// pixels by. `1.0` until [`set_density`] is called.
pub fn density() -> f32
{
f32::from_bits( DENSITY_BITS.load( Ordering::Relaxed ) )
}
/// How a stock widget adapts its intrinsic geometry to the display when the
/// app does not override it. The two modes ltk offers, chosen per process
/// with [`set_widget_scaling`]:
///
/// - [`WidgetScaling::Fluid`] — sizes scale as a fraction of the surface
/// (via [`Length::fluid`]): the design breathes with the screen, and a
/// size tracks the **short** side (width in portrait, height in
/// landscape). The default.
/// - [`WidgetScaling::Physical`] — sizes stay a constant physical size
/// (via [`Length::dp`] and [`density`]), the mainstream HiDPI model.
///
/// Both leave explicit [`Length`] overrides (`vmin` / `orient` / `dp` / …)
/// on individual widgets untouched — the mode only picks the meaning of the
/// theme's default design pixels.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum WidgetScaling
{
/// Surface-proportional defaults. See [`Length::fluid`].
Fluid,
/// Constant-physical-size defaults. See [`Length::dp`].
Physical,
}
static WIDGET_SCALING_BITS: AtomicU8 = AtomicU8::new( 0 );
/// Set the process-wide [`WidgetScaling`] mode for stock-widget defaults.
/// Call once at startup. Default: [`WidgetScaling::Fluid`].
pub fn set_widget_scaling( mode: WidgetScaling )
{
let v = match mode { WidgetScaling::Fluid => 0, WidgetScaling::Physical => 1 };
WIDGET_SCALING_BITS.store( v, Ordering::Relaxed );
}
/// Current [`WidgetScaling`] mode. [`WidgetScaling::Fluid`] until
/// [`set_widget_scaling`] changes it.
pub fn widget_scaling() -> WidgetScaling
{
match WIDGET_SCALING_BITS.load( Ordering::Relaxed )
{
1 => WidgetScaling::Physical,
_ => WidgetScaling::Fluid,
}
} }
impl From<f32> for Length impl From<f32> for Length
@@ -640,6 +771,7 @@ impl From<LengthBase> for Length
mod length_tests mod length_tests
{ {
use super::Length; use super::Length;
use crate::TEST_GLOBALS_LOCK as GLOBALS_LOCK;
#[ test ] #[ test ]
fn px_is_passthrough() fn px_is_passthrough()
@@ -667,6 +799,17 @@ mod length_tests
assert_eq!( Length::vmax( 10.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 80.0 ); assert_eq!( Length::vmax( 10.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 80.0 );
} }
#[ test ]
fn orient_uses_width_pct_in_portrait_and_height_pct_in_landscape()
{
// Portrait 1080×2400: 40 % of the width.
assert_eq!( Length::orient( 40.0, 5.0 ).resolve( ( 1080.0, 2400.0 ), 16.0 ), 432.0 );
// Landscape 2400×1080: 5 % of the height.
assert_eq!( Length::orient( 40.0, 5.0 ).resolve( ( 2400.0, 1080.0 ), 16.0 ), 54.0 );
// Square viewport counts as portrait (width ≤ height).
assert_eq!( Length::orient( 10.0, 20.0 ).resolve( ( 500.0, 500.0 ), 16.0 ), 50.0 );
}
#[ test ] #[ test ]
fn em_uses_em_base() fn em_uses_em_base()
{ {
@@ -695,4 +838,63 @@ mod length_tests
let l: Length = 24.0_f32.into(); let l: Length = 24.0_f32.into();
assert_eq!( l.base, super::LengthBase::Px( 24.0 ) ); assert_eq!( l.base, super::LengthBase::Px( 24.0 ) );
} }
#[ test ]
fn fluid_equals_design_px_at_reference_surface()
{
// At a surface whose short side is the 412 px reference, fluid( n ) == n.
assert_eq!( Length::fluid( 48.0 ).resolve( ( 412.0, 900.0 ), 16.0 ), 48.0 );
}
#[ test ]
fn fluid_scales_with_surface_and_auto_clamps()
{
// Twice the reference short side → would double, but the +50 % cap
// (48 * FLUID_MAX = 72) holds it.
assert_eq!( Length::fluid( 48.0 ).resolve( ( 824.0, 1600.0 ), 16.0 ), 72.0 );
// A tiny surface → the -30 % floor (48 * FLUID_MIN = 33.6) holds it.
assert_eq!( Length::fluid( 48.0 ).resolve( ( 200.0, 400.0 ), 16.0 ), 33.6 );
// Fluid tracks the short side: same result portrait or landscape.
let p = Length::fluid( 48.0 ).resolve( ( 412.0, 1000.0 ), 16.0 );
let l = Length::fluid( 48.0 ).resolve( ( 1000.0, 412.0 ), 16.0 );
assert_eq!( p, l );
}
#[ test ]
fn dp_is_identity_at_default_density()
{
let _g = GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
// Density defaults to 1.0, so dp( n ) resolves to n regardless of viewport.
assert_eq!( super::density(), 1.0 );
assert_eq!( Length::dp( 48.0 ).resolve( ( 412.0, 900.0 ), 16.0 ), 48.0 );
assert_eq!( Length::dp( 48.0 ).resolve( ( 3840.0, 2160.0 ), 16.0 ), 48.0 );
}
// Serialised: this is the only test that mutates the process-wide density
// and widget-scaling globals, so it owns them start-to-finish and restores
// the defaults, keeping the other (read-only-default) tests deterministic.
#[ test ]
fn density_and_widget_scaling_modes()
{
use super::{ density, set_density, widget_scaling, set_widget_scaling, WidgetScaling };
let _g = GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
// Defaults.
assert_eq!( density(), 1.0 );
assert_eq!( widget_scaling(), WidgetScaling::Fluid );
assert_eq!( Length::widget( 48.0 ), Length::fluid( 48.0 ) );
// Density scales dp.
set_density( 3.0 );
assert_eq!( Length::dp( 48.0 ).resolve( ( 412.0, 900.0 ), 16.0 ), 144.0 );
// Physical mode routes widget() through dp.
set_widget_scaling( WidgetScaling::Physical );
assert_eq!( Length::widget( 48.0 ), Length::dp( 48.0 ) );
// Restore defaults for the rest of the suite.
set_density( 1.0 );
set_widget_scaling( WidgetScaling::Fluid );
}
} }

View File

@@ -32,12 +32,12 @@ mod tests;
pub struct AnchoredOverlay<Msg: Clone> pub struct AnchoredOverlay<Msg: Clone>
{ {
/// The element to draw at the anchored position. /// The element to draw at the anchored position.
pub child: Box<Element<Msg>>, pub( crate ) child: Box<Element<Msg>>,
/// Stable identifier of the widget whose rect provides the anchor. /// Stable identifier of the widget whose rect provides the anchor.
pub anchor_id: WidgetId, pub( crate ) anchor_id: WidgetId,
/// Vertical pixel gap between the bottom of the anchor and the top /// Vertical pixel gap between the bottom of the anchor and the top
/// of the child. /// of the child.
pub gap: f32, pub( crate ) gap: f32,
} }
impl<Msg: Clone> AnchoredOverlay<Msg> impl<Msg: Clone> AnchoredOverlay<Msg>

View File

@@ -2,7 +2,7 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc; use std::sync::Arc;
use crate::types::{ Color, Rect, WidgetId }; use crate::types::{ Color, Length, Rect, WidgetId };
use crate::render::Canvas; use crate::render::Canvas;
use super::Element; use super::Element;
@@ -47,34 +47,45 @@ pub enum ButtonContent
pub struct Button<Msg: Clone> pub struct Button<Msg: Clone>
{ {
/// The visual content of this button. /// The visual content of this button.
pub content: ButtonContent, pub( crate ) content: ButtonContent,
/// Message emitted when the button is pressed, or `None` if disabled. /// Message emitted when the button is pressed, or `None` if disabled.
pub on_press: Option<Msg>, pub( crate ) on_press: Option<Msg>,
/// Message emitted when the user holds the button for /// Message emitted when the user holds the button for
/// [`App::long_press_duration`](crate::app::App::long_press_duration) /// [`App::long_press_duration`](crate::app::App::long_press_duration)
/// without moving past the tolerance, OR when the user right-clicks /// without moving past the tolerance, OR when the user right-clicks
/// with the mouse. `None` leaves the button without a context-menu /// with the mouse. `None` leaves the button without a context-menu
/// equivalent. The fire does NOT by itself put the gesture into /// equivalent. The fire does NOT by itself put the gesture into
/// drag mode — that is governed by [`Self::on_drag_start`]. /// drag mode — that is governed by [`Self::on_drag_start`].
pub on_long_press: Option<Msg>, pub( crate ) on_long_press: Option<Msg>,
/// Drag-arm message. Fires when the press transitions into a drag: /// Drag-arm message. Fires when the press transitions into a drag:
/// touch on hold-timer expiry (in addition to `on_long_press`), /// touch on hold-timer expiry (in addition to `on_long_press`),
/// mouse on motion past the drag-promotion threshold (without /// mouse on motion past the drag-promotion threshold (without
/// firing the menu). Independent of `on_long_press` so a button /// firing the menu). Independent of `on_long_press` so a button
/// can open a menu without becoming draggable, or be draggable /// can open a menu without becoming draggable, or be draggable
/// without showing a menu. /// without showing a menu.
pub on_drag_start: Option<Msg>, pub( crate ) on_drag_start: Option<Msg>,
/// Visual variant controlling colors and borders. /// Visual variant controlling colors and borders.
pub variant: ButtonVariant, pub( crate ) variant: ButtonVariant,
/// Width and height in pixels for icon buttons. Defaults to `48.0`. /// Width and height in pixels for icon buttons. The `0.0` default
pub icon_size: f32, /// follows the process [`crate::WidgetScaling`] mode (via
/// [`crate::Canvas::geom_px`]); any positive value pins an explicit size.
pub( crate ) icon_size: f32,
/// Optional label font size for text buttons. `None` uses the theme's
/// default (`theme::FONT_SIZE`); a [`Length`] scales the label with the
/// surface (e.g. `Length::vmin( 2.2 ).clamp( 14.0, 22.0 )`).
pub( crate ) font_size: Option<Length>,
/// Optional height for text buttons. `None` uses the theme's default
/// (`theme::HEIGHT`); a [`Length`] scales the button box with the
/// surface so it does not stay frozen while the rest of a fluid layout
/// grows. Resolved in physical layout space, like all geometry.
pub( crate ) height: Option<Length>,
/// Optional stable identifier for focus management. /// Optional stable identifier for focus management.
pub id: Option<WidgetId>, pub( crate ) id: Option<WidgetId>,
/// Whether this button participates in keyboard focus (Tab). Default: `true`. /// Whether this button participates in keyboard focus (Tab). Default: `true`.
pub focusable: bool, pub( crate ) focusable: bool,
/// Override the pointer cursor shape on hover. `None` falls back /// Override the pointer cursor shape on hover. `None` falls back
/// to the `Pointer` (hand) default for clickable widgets. /// to the `Pointer` (hand) default for clickable widgets.
pub cursor: Option<crate::types::CursorShape>, pub( crate ) cursor: Option<crate::types::CursorShape>,
/// When `true`, holding the button down auto-fires the /// When `true`, holding the button down auto-fires the
/// `on_press` message: one immediate fire on press, then an /// `on_press` message: one immediate fire on press, then an
/// initial delay (≈ 500 ms — same as the keyboard) followed by /// initial delay (≈ 500 ms — same as the keyboard) followed by
@@ -83,8 +94,8 @@ pub struct Button<Msg: Clone>
/// target). The runtime cancels the timer on release, on touch /// target). The runtime cancels the timer on release, on touch
/// cancel, and on long-press promotion. Default `false` — most /// cancel, and on long-press promotion. Default `false` — most
/// buttons fire on tap only. /// buttons fire on tap only.
pub repeating: bool, pub( crate ) repeating: bool,
pub tooltip: Option<String>, pub( crate ) tooltip: Option<String>,
} }
impl<Msg: Clone> Button<Msg> impl<Msg: Clone> Button<Msg>
@@ -99,7 +110,9 @@ impl<Msg: Clone> Button<Msg>
on_long_press: None, on_long_press: None,
on_drag_start: None, on_drag_start: None,
variant: ButtonVariant::Primary, variant: ButtonVariant::Primary,
icon_size: theme::HEIGHT, icon_size: 0.0,
font_size: None,
height: None,
id: None, id: None,
focusable: true, focusable: true,
cursor: None, cursor: None,
@@ -134,7 +147,9 @@ impl<Msg: Clone> Button<Msg>
on_long_press: None, on_long_press: None,
on_drag_start: None, on_drag_start: None,
variant: ButtonVariant::Tertiary, variant: ButtonVariant::Tertiary,
icon_size: theme::HEIGHT, icon_size: 0.0,
font_size: None,
height: None,
id: None, id: None,
focusable: true, focusable: true,
cursor: None, cursor: None,
@@ -222,6 +237,52 @@ impl<Msg: Clone> Button<Msg>
self self
} }
/// Set the label font size for text buttons. Accepts logical `f32`
/// pixels or any [`Length`] (e.g. `Length::vmin( 2.2 ).clamp( 14.0,
/// 22.0 )` to scale with the surface). No-op for icon buttons.
pub fn font_size( mut self, size: impl Into<Length> ) -> Self
{
self.font_size = Some( size.into() );
self
}
/// Set the button height for text buttons. Accepts logical `f32` pixels
/// or any [`Length`] (e.g. `Length::vmin( 7.0 ).clamp( 40.0, 64.0 )` to
/// scale the box with the surface). No-op for icon buttons, which are
/// sized by [`Self::icon_size`].
pub fn height( mut self, h: impl Into<Length> ) -> Self
{
self.height = Some( h.into() );
self
}
/// Resolve the label font size against the canvas viewport, matching how
/// [`text`](crate::text) sizes its glyphs. An explicit override bypasses
/// the mode; the default follows the process [`crate::WidgetScaling`].
fn label_font_size( &self, canvas: &Canvas ) -> f32
{
self.font_size
.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) )
.unwrap_or_else( || canvas.font_px( theme::FONT_SIZE ) )
}
/// Resolve the button height against the physical layout viewport (like
/// all geometry). An explicit override bypasses the mode; the default
/// follows the process [`crate::WidgetScaling`].
fn resolved_height( &self, canvas: &Canvas ) -> f32
{
self.height
.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
.unwrap_or_else( || canvas.geom_px( theme::HEIGHT ) )
}
/// Resolve the icon-button size: a positive [`Self::icon_size`] pins it,
/// the `0.0` sentinel follows the widget-scaling mode.
fn resolved_icon_size( &self, canvas: &Canvas ) -> f32
{
if self.icon_size > 0.0 { self.icon_size } else { canvas.geom_px( theme::HEIGHT ) }
}
/// Assign a stable identifier for focus management. /// Assign a stable identifier for focus management.
pub fn id( mut self, id: WidgetId ) -> Self pub fn id( mut self, id: WidgetId ) -> Self
{ {
@@ -268,13 +329,13 @@ impl<Msg: Clone> Button<Msg>
{ {
ButtonContent::Text( label ) => ButtonContent::Text( label ) =>
{ {
let text_w = canvas.measure_text( label, theme::FONT_SIZE ); let text_w = canvas.measure_text( label, self.label_font_size( canvas ) );
let w = (text_w + theme::PAD_H * 2.0).min( max_width ); let w = (text_w + canvas.geom_px( theme::PAD_H ) * 2.0).min( max_width );
( w, theme::HEIGHT ) ( w, self.resolved_height( canvas ) )
} }
ButtonContent::Icon { .. } => ButtonContent::Icon { .. } =>
{ {
let s = self.icon_size.min( max_width ); let s = self.resolved_icon_size( canvas ).min( max_width );
( s, s ) ( s, s )
} }
} }
@@ -302,7 +363,8 @@ impl<Msg: Clone> Button<Msg>
fn draw_text_button( &self, canvas: &mut Canvas, rect: Rect, focused: bool, label: &str ) fn draw_text_button( &self, canvas: &mut Canvas, rect: Rect, focused: bool, label: &str )
{ {
let is_disabled = self.on_press.is_none(); let is_disabled = self.on_press.is_none();
let text_y = rect.y + (rect.height + theme::FONT_SIZE) / 2.0 - 2.0; let fs = self.label_font_size( canvas );
let text_y = rect.y + (rect.height + fs) / 2.0 - 2.0;
match self.variant match self.variant
{ {
@@ -326,12 +388,12 @@ impl<Msg: Clone> Button<Msg>
theme::RADIUS + theme::FOCUS_W + 2.0, theme::RADIUS + theme::FOCUS_W + 2.0,
); );
} }
let text_w = canvas.measure_text( label, theme::FONT_SIZE ); let text_w = canvas.measure_text( label, fs );
canvas.draw_text( canvas.draw_text(
label, label,
rect.x + (rect.width - text_w) / 2.0, rect.x + (rect.width - text_w) / 2.0,
text_y, text_y,
theme::FONT_SIZE, fs,
text_c, text_c,
); );
} }
@@ -352,12 +414,12 @@ impl<Msg: Clone> Button<Msg>
theme::RADIUS + theme::FOCUS_W + 2.0, theme::RADIUS + theme::FOCUS_W + 2.0,
); );
} }
let text_w = canvas.measure_text( label, theme::FONT_SIZE ); let text_w = canvas.measure_text( label, fs );
canvas.draw_text( canvas.draw_text(
label, label,
rect.x + (rect.width - text_w) / 2.0, rect.x + (rect.width - text_w) / 2.0,
text_y, text_y,
theme::FONT_SIZE, fs,
text_c, text_c,
); );
} }
@@ -369,12 +431,12 @@ impl<Msg: Clone> Button<Msg>
let ring = rect.expand( 2.0 ); let ring = rect.expand( 2.0 );
canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, theme::RADIUS ); canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, theme::RADIUS );
} }
let text_w = canvas.measure_text( label, theme::FONT_SIZE ); let text_w = canvas.measure_text( label, fs );
canvas.draw_text( canvas.draw_text(
label, label,
rect.x + (rect.width - text_w) / 2.0, rect.x + (rect.width - text_w) / 2.0,
text_y, text_y,
theme::FONT_SIZE, fs,
text_c, text_c,
); );
} }
@@ -446,6 +508,8 @@ impl<Msg: Clone> Button<Msg>
on_drag_start: self.on_drag_start.map( |m| ( *f )( m ) ), on_drag_start: self.on_drag_start.map( |m| ( *f )( m ) ),
variant: self.variant, variant: self.variant,
icon_size: self.icon_size, icon_size: self.icon_size,
font_size: self.font_size,
height: self.height,
id: self.id, id: self.id,
focusable: self.focusable, focusable: self.focusable,
cursor: self.cursor, cursor: self.cursor,

View File

@@ -99,3 +99,54 @@ fn repeating_builder_sets_flag()
let b = b.repeating( false ); let b = b.repeating( false );
assert!( !b.repeating ); assert!( !b.repeating );
} }
#[ test ]
fn font_size_none_by_default_follows_widget_scaling()
{
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
let b = Button::<()>::new( "ok".into() );
let canvas = Canvas::new( 400, 800 );
assert!( b.font_size.is_none() );
// The default routes through the widget-scaling mode, not a raw constant.
assert_eq!( b.label_font_size( &canvas ), canvas.font_px( theme::FONT_SIZE ) );
}
#[ test ]
fn font_size_builder_resolves_length_against_viewport()
{
// vmin on a 400×800 surface → 10 % of the smaller side (400) = 40 px.
let b = Button::<()>::new( "ok".into() ).font_size( Length::vmin( 10.0 ) );
let canvas = Canvas::new( 400, 800 );
assert_eq!( b.label_font_size( &canvas ), 40.0 );
}
#[ test ]
fn font_size_survives_map_msg()
{
let f: super::super::MapFn<i32, ()> = std::sync::Arc::new( |_| () );
let b: Button<()> = Button::<i32>::new( "ok".into() )
.font_size( Length::px( 21.0 ) )
.map_msg( &f );
assert_eq!( b.font_size, Some( Length::px( 21.0 ) ) );
}
#[ test ]
fn height_none_by_default_follows_widget_scaling()
{
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
let b = Button::<()>::new( "ok".into() );
let canvas = Canvas::new( 400, 800 );
assert!( b.height.is_none() );
// The default routes through the widget-scaling mode, not a raw constant.
assert_eq!( b.preferred_size( 1000.0, &canvas ).1, canvas.geom_px( theme::HEIGHT ) );
}
#[ test ]
fn height_builder_scales_button_box_with_surface()
{
// vmin height on a 400×800 surface → 10 % of the smaller side (400) = 40 px,
// resolved in physical layout space (not divided by dpi_scale).
let b = Button::<()>::new( "ok".into() ).height( Length::vmin( 10.0 ) );
let canvas = Canvas::new( 400, 800 );
assert_eq!( b.preferred_size( 1000.0, &canvas ).1, 40.0 );
}

View File

@@ -32,19 +32,19 @@ mod tests;
pub struct Carousel<Msg: Clone> pub struct Carousel<Msg: Clone>
{ {
/// Child widgets in display order, left-to-right. /// Child widgets in display order, left-to-right.
pub children: Vec<Element<Msg>>, pub( crate ) children: Vec<Element<Msg>>,
/// Optional stable identifier — used by the host as the key for its /// Optional stable identifier — used by the host as the key for its
/// drag / inertia / snap state. /// drag / inertia / snap state.
pub id: Option<WidgetId>, pub( crate ) id: Option<WidgetId>,
/// Each child's width as a fraction of the viewport width. 0.8 leaves /// Each child's width as a fraction of the viewport width. 0.8 leaves
/// 10% on each side for the neighbours to peek out. /// 10% on each side for the neighbours to peek out.
pub focused_width_frac: f32, pub( crate ) focused_width_frac: f32,
/// Horizontal gap between adjacent children, in logical pixels. /// Horizontal gap between adjacent children, in logical pixels.
pub gap: f32, pub( crate ) gap: f32,
/// Logical-pixel offset applied to every child. Positive values shift /// Logical-pixel offset applied to every child. Positive values shift
/// children to the right (revealing later tiles). The host clamps, /// children to the right (revealing later tiles). The host clamps,
/// snaps and animates this value. /// snaps and animates this value.
pub offset: f32, pub( crate ) offset: f32,
} }
impl<Msg: Clone> Carousel<Msg> impl<Msg: Clone> Carousel<Msg>

View File

@@ -37,13 +37,13 @@ pub struct Checkbox<Msg: Clone>
{ {
/// Current checked state. Drawn from this field every frame; the /// Current checked state. Drawn from this field every frame; the
/// runtime never mutates it. /// runtime never mutates it.
pub checked: bool, pub( crate ) checked: bool,
/// Message emitted on activation. `None` leaves the checkbox inert. /// Message emitted on activation. `None` leaves the checkbox inert.
pub on_toggle: Option<Msg>, pub( crate ) on_toggle: Option<Msg>,
/// Optional label drawn to the right of the box. /// Optional label drawn to the right of the box.
pub label: Option<String>, pub( crate ) label: Option<String>,
/// Optional stable identifier for focus management. /// Optional stable identifier for focus management.
pub id: Option<WidgetId>, pub( crate ) id: Option<WidgetId>,
} }
impl<Msg: Clone> Checkbox<Msg> impl<Msg: Clone> Checkbox<Msg>
@@ -83,14 +83,15 @@ impl<Msg: Clone> Checkbox<Msg>
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{ {
let box_size = canvas.geom_px( theme::BOX_SIZE );
let w = if let Some( ref label ) = self.label let w = if let Some( ref label ) = self.label
{ {
let text_w = canvas.measure_text( label, theme::FONT_SIZE ); let text_w = canvas.measure_text( label, canvas.font_px( theme::FONT_SIZE ) );
( theme::BOX_SIZE + theme::GAP + text_w ).min( max_width ) ( box_size + canvas.geom_px( theme::GAP ) + text_w ).min( max_width )
} else { } else {
theme::BOX_SIZE.min( max_width ) box_size.min( max_width )
}; };
( w, theme::HEIGHT ) ( w, canvas.geom_px( theme::HEIGHT ) )
} }
/// Focus ring on the box extends `FOCUS_W + 2 + FOCUS_W/2 ≈ 6.5 px` beyond /// Focus ring on the box extends `FOCUS_W + 2 + FOCUS_W/2 ≈ 6.5 px` beyond
@@ -102,21 +103,22 @@ impl<Msg: Clone> Checkbox<Msg>
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool ) pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool )
{ {
let box_y = rect.y + ( rect.height - theme::BOX_SIZE ) / 2.0; let box_size = canvas.geom_px( theme::BOX_SIZE );
let box_y = rect.y + ( rect.height - box_size ) / 2.0;
let box_rect = Rect let box_rect = Rect
{ {
x: rect.x, x: rect.x,
y: box_y, y: box_y,
width: theme::BOX_SIZE, width: box_size,
height: theme::BOX_SIZE, height: box_size,
}; };
if self.checked if self.checked
{ {
canvas.fill_rect( box_rect, theme::box_checked(), theme::RADIUS ); canvas.fill_rect( box_rect, theme::box_checked(), theme::RADIUS );
let cx = rect.x + theme::BOX_SIZE / 2.0; let cx = rect.x + box_size / 2.0;
let cy = box_y + theme::BOX_SIZE / 2.0; let cy = box_y + box_size / 2.0;
let s = theme::BOX_SIZE * 0.3; let s = box_size * 0.3;
canvas.draw_line( cx - s, cy, cx - s * 0.3, cy + s * 0.7, theme::check_color(), theme::CHECK_W ); canvas.draw_line( cx - s, cy, cx - s * 0.3, cy + s * 0.7, theme::check_color(), theme::CHECK_W );
canvas.draw_line( cx - s * 0.3, cy + s * 0.7, cx + s, cy - s * 0.5, theme::check_color(), theme::CHECK_W ); canvas.draw_line( cx - s * 0.3, cy + s * 0.7, cx + s, cy - s * 0.5, theme::check_color(), theme::CHECK_W );
} else { } else {
@@ -131,9 +133,10 @@ impl<Msg: Clone> Checkbox<Msg>
if let Some( ref label ) = self.label if let Some( ref label ) = self.label
{ {
let text_x = rect.x + theme::BOX_SIZE + theme::GAP; let fs = canvas.font_px( theme::FONT_SIZE );
let text_y = rect.y + ( rect.height + theme::FONT_SIZE ) / 2.0 - 2.0; let text_x = rect.x + box_size + canvas.geom_px( theme::GAP );
canvas.draw_text( label, text_x, text_y, theme::FONT_SIZE, theme::label_color() ); let text_y = rect.y + ( rect.height + fs ) / 2.0 - 2.0;
canvas.draw_text( label, text_x, text_y, fs, theme::label_color() );
} }
} }

View File

@@ -24,7 +24,7 @@
use std::sync::Arc; use std::sync::Arc;
use crate::types::Color; use crate::types::{ Color, Length };
use crate::layout::column::column; 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;
@@ -114,12 +114,12 @@ pub fn parse_hex( s: &str ) -> Option<Color>
/// a continuous hue strip. /// a continuous hue strip.
pub struct ColorPicker<Msg: Clone> pub struct ColorPicker<Msg: Clone>
{ {
pub value: Color, pub( crate ) value: Color,
pub on_change: Option<Arc<dyn Fn( Color ) -> Msg>>, pub( crate ) on_change: Option<Arc<dyn Fn( Color ) -> Msg>>,
/// When `true` the alpha slider is shown and the hex input /// When `true` the alpha slider is shown and the hex input
/// accepts `#RRGGBBAA`. Default: `false` — most "pick a theme /// accepts `#RRGGBBAA`. Default: `false` — most "pick a theme
/// colour" flows are opaque. /// colour" flows are opaque.
pub show_alpha: bool, pub( crate ) show_alpha: bool,
} }
impl<Msg: Clone + 'static> ColorPicker<Msg> impl<Msg: Clone + 'static> ColorPicker<Msg>
@@ -163,7 +163,7 @@ impl<Msg: Clone + 'static> ColorPicker<Msg>
.radius( 12.0 ) .radius( 12.0 )
.padding( 0.0 ) .padding( 0.0 )
.into(); .into();
let mut preview_row = row::<Msg>().spacing( theme::SPACING ).push( let mut preview_row = row::<Msg>().spacing( Length::widget( theme::SPACING ) ).push(
container::<Msg>( swatch ) container::<Msg>( swatch )
.padding( 0.0 ) .padding( 0.0 )
.radius( 12.0 ), .radius( 12.0 ),
@@ -208,7 +208,7 @@ impl<Msg: Clone + 'static> ColorPicker<Msg>
} ); } );
} }
column::<Msg>().spacing( 4.0 ) column::<Msg>().spacing( 4.0 )
.push( text( label ).size( theme::LABEL_FS ).color( theme::text_muted() ) ) .push( text( label ).size( Length::widget( theme::LABEL_FS ) ).color( theme::text_muted() ) )
.push( s ) .push( s )
.into() .into()
}; };
@@ -218,7 +218,7 @@ impl<Msg: Clone + 'static> ColorPicker<Msg>
let b_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, v, value.a ) ); let b_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, v, value.a ) );
let a_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, value.b, v ) ); let a_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, value.b, v ) );
let mut sliders = column::<Msg>().spacing( theme::SPACING ) let mut sliders = column::<Msg>().spacing( Length::widget( theme::SPACING ) )
.push( chan_slider( "R", value.r, r_build ) ) .push( chan_slider( "R", value.r, r_build ) )
.push( chan_slider( "G", value.g, g_build ) ) .push( chan_slider( "G", value.g, g_build ) )
.push( chan_slider( "B", value.b, b_build ) ); .push( chan_slider( "B", value.b, b_build ) );
@@ -267,18 +267,18 @@ impl<Msg: Clone + 'static> ColorPicker<Msg>
} ); } );
} }
let hue_row: Element<Msg> = column::<Msg>().spacing( 4.0 ) let hue_row: Element<Msg> = column::<Msg>().spacing( 4.0 )
.push( text( "Hue" ).size( theme::LABEL_FS ).color( theme::text_muted() ) ) .push( text( "Hue" ).size( Length::widget( theme::LABEL_FS ) ).color( theme::text_muted() ) )
.push( hue_slider ) .push( hue_slider )
.into(); .into();
let body = column::<Msg>().spacing( theme::SPACING * 2.0 ) let body = column::<Msg>().spacing( Length::widget( theme::SPACING * 2.0 ) )
.push( preview_row ) .push( preview_row )
.push( sliders ) .push( sliders )
.push( hue_row ); .push( hue_row );
container::<Msg>( body ) container::<Msg>( body )
.background( theme::surface_alt() ) .background( theme::surface_alt() )
.padding( theme::PADDING ) .padding( Length::widget( theme::PADDING ) )
.radius( theme::RADIUS ) .radius( theme::RADIUS )
.into() .into()
} }

View File

@@ -100,7 +100,7 @@ use std::collections::hash_map::DefaultHasher;
use std::hash::{ Hash, Hasher }; use std::hash::{ Hash, Hasher };
use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec }; use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec };
use crate::types::WidgetId; use crate::types::{ Length, WidgetId };
use super::Element; use super::Element;
@@ -792,7 +792,7 @@ impl<Msg: Clone + 'static> Combo<Msg>
// `size.0 == 0` asks the runtime to size the popup to the // `size.0 == 0` asks the runtime to size the popup to the
// trigger pill width (the canonical select / dropdown // trigger pill width (the canonical select / dropdown
// behaviour). Height stays capped at `popup_max_height`. // behaviour). Height stays capped at `popup_max_height`.
size: ( 0, self.popup_max_height as u32 ), size: ( Length::px( 0.0 ), Length::px( self.popup_max_height ) ),
exclusive_zone: 0, exclusive_zone: 0,
keyboard_exclusive: false, keyboard_exclusive: false,
input_region: None, input_region: None,

View File

@@ -53,17 +53,17 @@ mod tests;
/// ``` /// ```
pub struct Container<Msg: Clone> pub struct Container<Msg: Clone>
{ {
pub child: Box<Element<Msg>>, pub( crate ) child: Box<Element<Msg>>,
/// Optional background paint — flat colour, linear or radial /// Optional background paint — flat colour, linear or radial
/// gradient. Constructed via [`Container::background`], which /// gradient. Constructed via [`Container::background`], which
/// accepts anything `Into<Paint>` (a plain [`Color`] gets /// accepts anything `Into<Paint>` (a plain [`Color`] gets
/// promoted to [`Paint::Solid`] via the trait impl). /// promoted to [`Paint::Solid`] via the trait impl).
pub background: Option<Paint>, pub( crate ) background: Option<Paint>,
/// Slot id of a themed surface (resolved via /// Slot id of a themed surface (resolved via
/// [`crate::theme::resolve_surface`]). When set, takes precedence /// [`crate::theme::resolve_surface`]). When set, takes precedence
/// over `background` and paints the full Glass stack instead of a /// over `background` and paints the full Glass stack instead of a
/// flat colour fill. /// flat colour fill.
pub surface: Option<String>, pub( crate ) surface: Option<String>,
/// Per-corner radii applied to every painted layer of the /// Per-corner radii applied to every painted layer of the
/// container chrome — flat fill, themed surface (gradient + outer /// container chrome — flat fill, themed surface (gradient + outer
/// shadows + insets + backdrop blur). Stored as [`Corners`] so /// shadows + insets + backdrop blur). Stored as [`Corners`] so
@@ -71,33 +71,33 @@ pub struct Container<Msg: Clone>
/// panel pinned to the screen bottom, a side panel pinned to the /// panel pinned to the screen bottom, a side panel pinned to the
/// left edge, …) without hitting the renderer with an offset /// left edge, …) without hitting the renderer with an offset
/// trick. /// trick.
pub corners: Corners, pub( crate ) corners: Corners,
/// Padding on the top edge — gap between the container's top boundary /// Padding on the top edge — gap between the container's top boundary
/// and its child. Stored as a [`Length`] so it can scale with the /// and its child. Stored as a [`Length`] so it can scale with the
/// viewport via [`Length::dp`] / [`Length::vmin`]. /// viewport via [`Length::fluid`] / [`Length::vmin`].
pub pad_top: Length, pub( crate ) pad_top: Length,
/// Padding on the right edge. /// Padding on the right edge.
pub pad_right: Length, pub( crate ) pad_right: Length,
/// Padding on the bottom edge. /// Padding on the bottom edge.
pub pad_bottom: Length, pub( crate ) pad_bottom: Length,
/// Padding on the left edge. /// Padding on the left edge.
pub pad_left: Length, pub( crate ) pad_left: Length,
pub opacity: f32, pub( crate ) opacity: f32,
/// Optional `( color, width_px )` border stroke painted around the /// Optional `( color, width_px )` border stroke painted around the
/// container's rounded rectangle, after the fill / surface and /// container's rounded rectangle, after the fill / surface and
/// before the child draws. `None` leaves the chrome flat. /// before the child draws. `None` leaves the chrome flat.
pub border: Option<( Color, f32 )>, pub( crate ) border: Option<( Color, f32 )>,
/// Optional hard cap on the container's outer width. When the parent /// Optional hard cap on the container's outer width. When the parent
/// offers more, the container reports its preferred width as /// offers more, the container reports its preferred width as
/// `min( offered, max_width )` so it does not stretch to fill. /// `min( offered, max_width )` so it does not stretch to fill.
/// Mirrors the same flag on [`Column`](crate::layout::column::Column) /// Mirrors the same flag on [`Column`](crate::layout::column::Column)
/// and [`Row`](crate::layout::row::Row). /// and [`Row`](crate::layout::row::Row).
pub max_width: Option<f32>, pub( crate ) max_width: Option<f32>,
/// When true, the contents of this container are announced by /// When true, the contents of this container are announced by
/// assistive technologies as a `Live::Polite` region — useful for /// assistive technologies as a `Live::Polite` region — useful for
/// toasts, status banners and OSDs that need to be read on /// toasts, status banners and OSDs that need to be read on
/// appearance even when the user has not navigated to them. /// appearance even when the user has not navigated to them.
pub a11y_live: bool, pub( crate ) a11y_live: bool,
} }
impl<Msg: Clone> Container<Msg> impl<Msg: Clone> Container<Msg>

View File

@@ -133,7 +133,7 @@ pub struct Locale
{ {
/// First day of the week (0 = Sunday, 1 = Monday). Default 1 /// First day of the week (0 = Sunday, 1 = Monday). Default 1
/// (Monday) — the convention used across most of Europe. /// (Monday) — the convention used across most of Europe.
pub first_dow: u8, pub( crate ) first_dow: u8,
} }
impl Locale impl Locale
@@ -193,14 +193,14 @@ fn dow_short( dow: u8 ) -> String
/// Calendar date selector. /// Calendar date selector.
pub struct DatePicker<Msg: Clone> pub struct DatePicker<Msg: Clone>
{ {
pub value: Date, pub( crate ) value: Date,
pub view_year: i32, pub( crate ) view_year: i32,
pub view_month: u8, pub( crate ) view_month: u8,
pub today: Option<Date>, pub( crate ) today: Option<Date>,
pub on_change: Option<Arc<dyn Fn( Date ) -> Msg>>, pub( crate ) on_change: Option<Arc<dyn Fn( Date ) -> Msg>>,
pub on_navigate: Option<Arc<dyn Fn( i32, u8 ) -> Msg>>, pub( crate ) on_navigate: Option<Arc<dyn Fn( i32, u8 ) -> Msg>>,
pub locale: Locale, pub( crate ) locale: Locale,
pub width: Option<f32>, pub( crate ) width: Option<f32>,
} }
impl<Msg: Clone + 'static> DatePicker<Msg> impl<Msg: Clone + 'static> DatePicker<Msg>

View File

@@ -103,23 +103,23 @@ pub const SUBTITLE_SIZE: f32 = 14.0;
/// and action buttons. /// and action buttons.
pub struct Dialog<Msg: Clone> pub struct Dialog<Msg: Clone>
{ {
pub title: Option<String>, pub( crate ) title: Option<String>,
pub subtitle: Option<String>, pub( crate ) subtitle: Option<String>,
/// User-supplied body element rendered between the subtitle and /// User-supplied body element rendered between the subtitle and
/// the action row. Use this for sliders, lists, spinners or any /// the action row. Use this for sliders, lists, spinners or any
/// other custom content. /// other custom content.
pub body: Option<Box<Element<Msg>>>, pub( crate ) body: Option<Box<Element<Msg>>>,
pub actions: Vec<Element<Msg>>, pub( crate ) actions: Vec<Element<Msg>>,
pub modal: bool, pub( crate ) modal: bool,
/// Optional message dispatched when the user taps the scrim /// Optional message dispatched when the user taps the scrim
/// (strictly outside the card). Always `None` when [`Self::modal`] /// (strictly outside the card). Always `None` when [`Self::modal`]
/// is `true`. Construction panics if both are set together. /// is `true`. Construction panics if both are set together.
pub dismiss_msg: Option<Msg>, pub( crate ) dismiss_msg: Option<Msg>,
/// Optional message dispatched when the user presses `Escape` /// Optional message dispatched when the user presses `Escape`
/// 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 cancel_msg: Option<Msg>, pub( crate ) cancel_msg: Option<Msg>,
pub max_width: f32, pub( crate ) max_width: f32,
} }
impl<Msg: Clone> Default for Dialog<Msg> impl<Msg: Clone> Default for Dialog<Msg>

View File

@@ -66,8 +66,8 @@ impl<Msg: Clone> Element<Msg>
Element::VSlider( s ) => s.preferred_size( max_width, canvas ), Element::VSlider( s ) => s.preferred_size( max_width, canvas ),
Element::Container( c ) => c.preferred_size( max_width, canvas ), Element::Container( c ) => c.preferred_size( max_width, canvas ),
Element::Toggle( t ) => t.preferred_size( max_width, canvas ), Element::Toggle( t ) => t.preferred_size( max_width, canvas ),
Element::Separator( s ) => s.preferred_size( max_width ), Element::Separator( s ) => s.preferred_size( max_width, canvas ),
Element::ProgressBar( p ) => p.preferred_size( max_width ), Element::ProgressBar( p ) => p.preferred_size( max_width, canvas ),
Element::Checkbox( c ) => c.preferred_size( max_width, canvas ), Element::Checkbox( c ) => c.preferred_size( max_width, canvas ),
Element::Radio( r ) => r.preferred_size( max_width, canvas ), Element::Radio( r ) => r.preferred_size( max_width, canvas ),
Element::ListItem( l ) => l.preferred_size( max_width, canvas ), Element::ListItem( l ) => l.preferred_size( max_width, canvas ),
@@ -75,7 +75,7 @@ impl<Msg: Clone> Element<Msg>
Element::Pressable( p ) => p.preferred_size( max_width, canvas ), Element::Pressable( p ) => p.preferred_size( max_width, canvas ),
Element::Flex( f ) => f.preferred_size( max_width, canvas ), Element::Flex( f ) => f.preferred_size( max_width, canvas ),
Element::AnchoredOverlay( a ) => a.child.preferred_size( max_width, canvas ), Element::AnchoredOverlay( a ) => a.child.preferred_size( max_width, canvas ),
Element::Spinner( s ) => s.preferred_size( max_width ), Element::Spinner( s ) => s.preferred_size( max_width, canvas ),
Element::External( e ) => e.preferred_size( max_width ), Element::External( e ) => e.preferred_size( max_width ),
Element::Carousel( c ) => c.preferred_size( max_width, canvas ), Element::Carousel( c ) => c.preferred_size( max_width, canvas ),
} }
@@ -329,6 +329,9 @@ impl<Msg: Clone> Element<Msg>
on_change: s.on_change.clone(), on_change: s.on_change.clone(),
axis: slider::SliderAxis::Horizontal, axis: slider::SliderAxis::Horizontal,
value: s.value, value: s.value,
// Design-px fallback; the layout pass overwrites this with
// the widget-scaling-resolved size via `set_slider_thumb_px`.
thumb_px: slider::thumb_design_px(),
} }
} }
Element::VSlider( s ) => Element::VSlider( s ) =>
@@ -338,6 +341,9 @@ impl<Msg: Clone> Element<Msg>
on_change: s.on_change.clone(), on_change: s.on_change.clone(),
axis: slider::SliderAxis::Vertical, axis: slider::SliderAxis::Vertical,
value: s.value, value: s.value,
// The vertical axis maps against the full rect height and
// takes no thumb inset, so this is unused.
thumb_px: 0.0,
} }
} }
_ => WidgetHandlers::None, _ => WidgetHandlers::None,

View File

@@ -65,13 +65,13 @@ use crate::types::Rect;
pub struct External pub struct External
{ {
/// Reserved width in logical pixels. /// Reserved width in logical pixels.
pub width: f32, pub( crate ) width: f32,
/// Reserved height in logical pixels. /// Reserved height in logical pixels.
pub height: f32, pub( crate ) height: f32,
/// Source of the GL texture sampled at draw time. /// Source of the GL texture sampled at draw time.
pub source: ExternalSource, pub( crate ) source: ExternalSource,
/// Opacity multiplier in `[0.0, 1.0]`. /// Opacity multiplier in `[0.0, 1.0]`.
pub opacity: f32, pub( crate ) opacity: f32,
} }
/// Backends an [`External`] widget can pull pixels from. /// Backends an [`External`] widget can pull pixels from.

View File

@@ -36,8 +36,8 @@ use super::Element;
/// yet implemented. /// yet implemented.
pub struct Flex<Msg: Clone> pub struct Flex<Msg: Clone>
{ {
pub child: Box<Element<Msg>>, pub( crate ) child: Box<Element<Msg>>,
pub weight: u32, pub( crate ) weight: u32,
} }
impl<Msg: Clone> Flex<Msg> impl<Msg: Clone> Flex<Msg>

View File

@@ -78,6 +78,10 @@ pub enum WidgetHandlers<Msg: Clone>
on_change: Option<Arc<dyn Fn( f32 ) -> Msg>>, on_change: Option<Arc<dyn Fn( f32 ) -> Msg>>,
axis: slider::SliderAxis, axis: slider::SliderAxis,
value: f32, value: f32,
/// Thumb size resolved through the widget-scaling mode at layout
/// time. Feeds `value_from_pos_in_rect` so a click maps against the
/// same thumb the renderer drew. Unused by the vertical axis.
thumb_px: f32,
}, },
} }
@@ -138,9 +142,9 @@ impl<Msg: Clone> Clone for WidgetHandlers<Msg>
password_toggle_msg: password_toggle_msg.clone(), password_toggle_msg: password_toggle_msg.clone(),
} }
} }
WidgetHandlers::Slider { on_change, axis, value } => WidgetHandlers::Slider { on_change, axis, value, thumb_px } =>
{ {
WidgetHandlers::Slider { on_change: on_change.clone(), axis: *axis, value: *value } WidgetHandlers::Slider { on_change: on_change.clone(), axis: *axis, value: *value, thumb_px: *thumb_px }
} }
} }
} }
@@ -256,14 +260,26 @@ impl<Msg: Clone> WidgetHandlers<Msg>
{ {
match self match self
{ {
WidgetHandlers::Slider { axis, .. } => WidgetHandlers::Slider { axis, thumb_px, .. } =>
{ {
slider::value_from_pos_in_rect( rect, pos, *axis ) slider::value_from_pos_in_rect( rect, pos, *axis, *thumb_px )
} }
_ => 0.0, _ => 0.0,
} }
} }
/// Store the widget-scaling-resolved thumb size into a horizontal
/// [`WidgetHandlers::Slider`] snapshot. Called by the layout pass (which
/// carries a canvas) so the input path maps clicks against the same
/// thumb the renderer drew. A no-op for every other variant.
pub( crate ) fn set_slider_thumb_px( &mut self, px: f32 )
{
if let WidgetHandlers::Slider { thumb_px, .. } = self
{
*thumb_px = px;
}
}
/// `true` when this is a [`WidgetHandlers::Button`] whose source /// `true` when this is a [`WidgetHandlers::Button`] whose source
/// widget opted into press-and-hold repeat. The runtime reads /// widget opted into press-and-hold repeat. The runtime reads
/// this on press to decide whether to fire `press_msg` /// this on press to decide whether to fire `press_msg`

View File

@@ -30,17 +30,22 @@ use crate::render::Canvas;
pub struct Image pub struct Image
{ {
/// Raw RGBA pixel data (4 bytes per pixel, straight alpha). /// Raw RGBA pixel data (4 bytes per pixel, straight alpha).
pub rgba: Arc<Vec<u8>>, pub( crate ) rgba: Arc<Vec<u8>>,
/// Pixel width of the source image. /// Pixel width of the source image.
pub width: u32, pub( crate ) width: u32,
/// Pixel height of the source image. /// Pixel height of the source image.
pub height: u32, pub( crate ) height: u32,
/// When `true` the image scales to fill the available width (cover mode). /// When `true` the image scales to fill the available width (cover mode).
pub cover: bool, pub( crate ) cover: bool,
/// Optional explicit display size (Length values, resolved at layout time). /// Optional explicit display size (Length values, resolved at layout time).
pub display_size: Option<( Length, Length )>, pub( crate ) display_size: Option<( Length, Length )>,
/// Optional extent along the viewport's **short** side (width in portrait,
/// height in landscape), with the other axis following the source aspect
/// ratio. Resolved at layout time. Takes precedence over `display_size`
/// and `cover`.
pub( crate ) short_side: Option<Length>,
/// Opacity multiplier in `[0.0, 1.0]`. Default: `1.0`. /// Opacity multiplier in `[0.0, 1.0]`. Default: `1.0`.
pub opacity: f32, pub( crate ) opacity: f32,
} }
impl Image impl Image
@@ -50,7 +55,7 @@ impl Image
/// `width` and `height` must match the dimensions of `rgba`. /// `width` and `height` must match the dimensions of `rgba`.
pub fn new( rgba: Arc<Vec<u8>>, width: u32, height: u32 ) -> Self pub fn new( rgba: Arc<Vec<u8>>, width: u32, height: u32 ) -> Self
{ {
Self { rgba, width, height, cover: false, display_size: None, opacity: 1.0 } Self { rgba, width, height, cover: false, display_size: None, short_side: None, opacity: 1.0 }
} }
/// Load an image from a file path. Supports PNG, JPEG, and other formats /// Load an image from a file path. Supports PNG, JPEG, and other formats
@@ -59,7 +64,7 @@ impl Image
{ {
let img = image::open( path )?.into_rgba8(); let img = image::open( path )?.into_rgba8();
let ( width, height ) = img.dimensions(); let ( width, height ) = img.dimensions();
Ok( Self { rgba: Arc::new( img.into_raw() ), width, height, cover: false, display_size: None, opacity: 1.0 } ) Ok( Self { rgba: Arc::new( img.into_raw() ), width, height, cover: false, display_size: None, short_side: None, opacity: 1.0 } )
} }
/// Scale the image to fill the available width, preserving aspect ratio (cover mode). /// Scale the image to fill the available width, preserving aspect ratio (cover mode).
@@ -77,6 +82,18 @@ impl Image
self self
} }
/// Size the image by its extent along the viewport's **short** side —
/// the width in portrait, the height in landscape — with the other axis
/// derived from the source aspect ratio. Pairs with
/// [`Length::orient`](crate::Length::orient) to express a rule like "40 %
/// of the width in portrait, 5 % of the height in landscape" in one call:
/// `img_widget( rgba, w, h ).short_side( Length::orient( 40.0, 5.0 ) )`.
pub fn short_side( mut self, extent: impl Into<Length> ) -> Self
{
self.short_side = Some( extent.into() );
self
}
/// Set the opacity multiplier. Clamped to `[0.0, 1.0]`. /// Set the opacity multiplier. Clamped to `[0.0, 1.0]`.
pub fn opacity( mut self, o: f32 ) -> Self pub fn opacity( mut self, o: f32 ) -> Self
{ {
@@ -88,6 +105,22 @@ impl Image
/// `canvas` is used to resolve viewport-relative [`Length`] values. /// `canvas` is used to resolve viewport-relative [`Length`] values.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{ {
if let Some( extent ) = &self.short_side
{
let ( vw, vh ) = canvas.viewport_layout();
let s = extent.resolve( ( vw, vh ), Length::EM_BASE_DEFAULT ).max( 0.0 );
let sw = self.width as f32;
let sh = self.height as f32;
if sw <= 0.0 || sh <= 0.0 { return ( s, s ); }
// Portrait: short side is the width → `s` sets the width.
// Landscape: short side is the height → `s` sets the height.
if vw <= vh
{
return ( s, s * sh / sw );
} else {
return ( s * sw / sh, s );
}
}
if let Some( ( w, h ) ) = &self.display_size if let Some( ( w, h ) ) = &self.display_size
{ {
let vp = canvas.viewport_layout(); let vp = canvas.viewport_layout();

View File

@@ -105,6 +105,26 @@ fn preferred_size_cover_and_default_match_for_uniform_scaling()
assert_eq!( normal.preferred_size( 200.0, &c ), covered.preferred_size( 200.0, &c ) ); assert_eq!( normal.preferred_size( 200.0, &c ), covered.preferred_size( 200.0, &c ) );
} }
#[ test ]
fn short_side_sizes_by_width_in_portrait_keeping_aspect()
{
// 200×100 source, portrait 480×900 → short side is the width.
// orient( 40, 5 ) portrait → 40 % of 480 = 192 width, height 192*(100/200)=96.
let img = Image::new( solid_rgba( 200, 100 ), 200, 100 ).short_side( Length::orient( 40.0, 5.0 ) );
let canvas = Canvas::new( 480, 900 );
assert_eq!( img.preferred_size( 1000.0, &canvas ), ( 192.0, 96.0 ) );
}
#[ test ]
fn short_side_sizes_by_height_in_landscape_keeping_aspect()
{
// 200×100 source, landscape 900×480 → short side is the height.
// orient( 40, 5 ) landscape → 5 % of 480 = 24 height, width 24*(200/100)=48.
let img = Image::new( solid_rgba( 200, 100 ), 200, 100 ).short_side( Length::orient( 40.0, 5.0 ) );
let canvas = Canvas::new( 900, 480 );
assert_eq!( img.preferred_size( 1000.0, &canvas ), ( 48.0, 24.0 ) );
}
// ── Arc sharing ─────────────────────────────────────────────────────────── // ── Arc sharing ───────────────────────────────────────────────────────────
#[ test ] #[ test ]

View File

@@ -38,27 +38,27 @@ pub struct ListItem<Msg: Clone>
{ {
/// Primary label (always visible, top-aligned when a subtitle is /// Primary label (always visible, top-aligned when a subtitle is
/// present). /// present).
pub 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. /// Doubles the row height when set.
pub subtitle: Option<String>, pub( crate ) subtitle: Option<String>,
/// Optional right-aligned text (current setting, badge count, "" /// Optional right-aligned text (current setting, badge count, ""
/// disclosure). Drawn in muted colour. /// disclosure). Drawn in muted colour.
pub trailing: Option<String>, pub( crate ) trailing: Option<String>,
/// Message emitted on tap. `None` keeps the item visible but inert. /// Message emitted on tap. `None` keeps the item visible but inert.
pub on_press: Option<Msg>, pub( crate ) on_press: Option<Msg>,
/// Optional stable identifier for focus management. /// Optional stable identifier for focus management.
pub id: Option<WidgetId>, pub( crate ) id: Option<WidgetId>,
/// `true` paints the row with the dark selected surface and white /// `true` paints the row with the dark selected surface and white
/// text, regardless of hover / press state. Use to indicate the /// text, regardless of hover / press state. Use to indicate the
/// active item in a list of choices (combo dropdown, settings /// active item in a list of choices (combo dropdown, settings
/// group with a single active value). /// group with a single active value).
pub selected: bool, pub( crate ) selected: bool,
/// Optional leading icon — RGBA bytes + native dimensions. The /// Optional leading icon — RGBA bytes + native dimensions. The
/// row reserves `theme::ICON_SIZE + theme::ICON_GAP` on the left /// row reserves `theme::ICON_SIZE + theme::ICON_GAP` on the left
/// when this is set, and offsets the label / subtitle by the /// when this is set, and offsets the label / subtitle by the
/// same amount. Pass `None` to keep the icon-less layout. /// same amount. Pass `None` to keep the icon-less layout.
pub icon: Option<( Arc<Vec<u8>>, u32, u32 )>, pub( crate ) icon: Option<( Arc<Vec<u8>>, u32, u32 )>,
} }
impl<Msg: Clone> ListItem<Msg> impl<Msg: Clone> ListItem<Msg>
@@ -128,9 +128,14 @@ impl<Msg: Clone> ListItem<Msg>
self self
} }
pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32) pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{ {
let h = if self.subtitle.is_some() { theme::HEIGHT_SUB } else { theme::HEIGHT }; let h = if self.subtitle.is_some()
{
canvas.geom_px( theme::HEIGHT_SUB )
} else {
canvas.geom_px( theme::HEIGHT )
};
( max_width, h ) ( max_width, h )
} }
@@ -172,12 +177,14 @@ impl<Msg: Clone> ListItem<Msg>
canvas.stroke_rect( rect, theme::focus_color(), theme::FOCUS_W, theme::RADIUS ); canvas.stroke_rect( rect, theme::focus_color(), theme::FOCUS_W, theme::RADIUS );
} }
let label_size = canvas.font_px( theme::LABEL_SIZE );
let pad_h = canvas.geom_px( theme::PAD_H );
let has_sub = self.subtitle.is_some(); let has_sub = self.subtitle.is_some();
let label_y = if has_sub let label_y = if has_sub
{ {
rect.y + rect.height * 0.35 + theme::LABEL_SIZE * 0.3 rect.y + rect.height * 0.35 + label_size * 0.3
} else { } else {
rect.y + ( rect.height + theme::LABEL_SIZE ) / 2.0 - 2.0 rect.y + ( rect.height + label_size ) / 2.0 - 2.0
}; };
// Leading-icon column shifts the text right by // Leading-icon column shifts the text right by
@@ -187,30 +194,34 @@ impl<Msg: Clone> ListItem<Msg>
// is in `theme::icon_rgba` rather than baked here. // is in `theme::icon_rgba` rather than baked here.
let text_x = if let Some( ( rgba, w, h ) ) = &self.icon let text_x = if let Some( ( rgba, w, h ) ) = &self.icon
{ {
let icon_y = rect.y + ( rect.height - theme::ICON_SIZE ) / 2.0; let icon_size = canvas.geom_px( theme::ICON_SIZE );
let icon_rect = Rect { x: rect.x + theme::PAD_H, y: icon_y, width: theme::ICON_SIZE, height: theme::ICON_SIZE }; let icon_gap = canvas.geom_px( theme::ICON_GAP );
let icon_y = rect.y + ( rect.height - icon_size ) / 2.0;
let icon_rect = Rect { x: rect.x + pad_h, y: icon_y, width: icon_size, height: icon_size };
canvas.draw_image_data( rgba, *w, *h, icon_rect, 1.0 ); canvas.draw_image_data( rgba, *w, *h, icon_rect, 1.0 );
rect.x + theme::PAD_H + theme::ICON_SIZE + theme::ICON_GAP rect.x + pad_h + icon_size + icon_gap
} }
else else
{ {
rect.x + theme::PAD_H rect.x + pad_h
}; };
canvas.draw_text( &self.label, text_x, label_y, theme::LABEL_SIZE, label_color ); canvas.draw_text( &self.label, text_x, label_y, label_size, label_color );
if let Some( ref sub ) = self.subtitle if let Some( ref sub ) = self.subtitle
{ {
let sub_y = rect.y + rect.height * 0.62 + theme::SUBTITLE_SIZE * 0.3; let sub_size = canvas.font_px( theme::SUBTITLE_SIZE );
canvas.draw_text( sub, text_x, sub_y, theme::SUBTITLE_SIZE, subtitle_color ); let sub_y = rect.y + rect.height * 0.62 + sub_size * 0.3;
canvas.draw_text( sub, text_x, sub_y, sub_size, subtitle_color );
} }
if let Some( ref trail ) = self.trailing if let Some( ref trail ) = self.trailing
{ {
let tw = canvas.measure_text( trail, theme::TRAILING_SIZE ); let trail_size = canvas.font_px( theme::TRAILING_SIZE );
let tx = rect.x + rect.width - theme::PAD_H - tw; let tw = canvas.measure_text( trail, trail_size );
let ty = rect.y + ( rect.height + theme::TRAILING_SIZE ) / 2.0 - 2.0; let tx = rect.x + rect.width - pad_h - tw;
canvas.draw_text( trail, tx, ty, theme::TRAILING_SIZE, trailing_color ); let ty = rect.y + ( rect.height + trail_size ) / 2.0 - 2.0;
canvas.draw_text( trail, tx, ty, trail_size, trailing_color );
} }
} }

View File

@@ -37,6 +37,7 @@ use std::sync::Arc;
use crate::layout::column::column; use crate::layout::column::column;
use crate::layout::spacer::spacer; use crate::layout::spacer::spacer;
use crate::types::Length;
use super::Element; use super::Element;
@@ -49,8 +50,8 @@ mod tests;
/// element to show when this page is active. /// element to show when this page is active.
pub struct NotebookPage<Msg: Clone> pub struct NotebookPage<Msg: Clone>
{ {
pub label: String, pub( crate ) label: String,
pub view: Element<Msg>, pub( crate ) view: Element<Msg>,
} }
/// Paginated tab container. /// Paginated tab container.
@@ -61,9 +62,9 @@ pub struct NotebookPage<Msg: Clone>
/// notebook with 50 pages costs the same to draw as one with 5. /// notebook with 50 pages costs the same to draw as one with 5.
pub struct Notebook<Msg: Clone> pub struct Notebook<Msg: Clone>
{ {
pub pages: Vec<NotebookPage<Msg>>, pub( crate ) pages: Vec<NotebookPage<Msg>>,
pub selected: usize, pub( crate ) selected: usize,
pub on_select: Option<Arc<dyn Fn( usize ) -> Msg>>, pub( crate ) on_select: Option<Arc<dyn Fn( usize ) -> Msg>>,
} }
impl<Msg: Clone + 'static> Notebook<Msg> impl<Msg: Clone + 'static> Notebook<Msg>
@@ -138,7 +139,7 @@ impl<Msg: Clone + 'static> Notebook<Msg>
}; };
column() column()
.spacing( theme::SPACING ) .spacing( Length::widget( theme::SPACING ) )
.push( strip ) .push( strip )
.push( active_view ) .push( active_view )
.into() .into()

View File

@@ -47,16 +47,16 @@ mod tests;
/// ``` /// ```
pub struct Pressable<Msg: Clone> pub struct Pressable<Msg: Clone>
{ {
pub child: Box<Element<Msg>>, pub( crate ) child: Box<Element<Msg>>,
pub on_press: Option<Msg>, pub( crate ) on_press: Option<Msg>,
pub on_long_press: Option<Msg>, pub( crate ) on_long_press: Option<Msg>,
/// Drag-arm message. Fired alongside `on_long_press` when the touch /// Drag-arm message. Fired alongside `on_long_press` when the touch
/// hold timer elapses, AND fired by mouse left-button motion past /// hold timer elapses, AND fired by mouse left-button motion past
/// the drag-promotion threshold without waiting for the timer. The /// the drag-promotion threshold without waiting for the timer. The
/// caller uses this to arm any per-app drag state (in crustace, /// caller uses this to arm any per-app drag state (in crustace,
/// `dragging_item`); a widget that opens a context menu but isn't /// `dragging_item`); a widget that opens a context menu but isn't
/// draggable leaves this `None`. /// draggable leaves this `None`.
pub on_drag_start: Option<Msg>, pub( crate ) on_drag_start: Option<Msg>,
/// Keyboard `Escape`-key message. The runtime scans every laid-out /// Keyboard `Escape`-key message. The runtime scans every laid-out
/// pressable's snapshot and fires the topmost (highest `flat_idx`) /// pressable's snapshot and fires the topmost (highest `flat_idx`)
/// `on_escape` it finds before the default ESC fallthrough chain. /// `on_escape` it finds before the default ESC fallthrough chain.
@@ -64,7 +64,7 @@ pub struct Pressable<Msg: Clone>
/// modal dialog without each app having to wire a global keyboard /// modal dialog without each app having to wire a global keyboard
/// hook — but available to any composite that needs the same /// hook — but available to any composite that needs the same
/// semantics. /// semantics.
pub on_escape: Option<Msg>, pub( crate ) on_escape: Option<Msg>,
/// Make the pressable hit-testable even when no callback is set. /// Make the pressable hit-testable even when no callback is set.
/// A `swallow=true` pressable consumes pointer events at its hit /// A `swallow=true` pressable consumes pointer events at its hit
/// rect and emits no message — used by /// rect and emits no message — used by
@@ -73,9 +73,9 @@ pub struct Pressable<Msg: Clone>
/// when the user clicks on the dialog body itself. Has no effect /// when the user clicks on the dialog body itself. Has no effect
/// when any handler is also set; in that case the handler determines /// when any handler is also set; in that case the handler determines
/// the message and `swallow` is implicit. /// the message and `swallow` is implicit.
pub swallow: bool, pub( crate ) swallow: bool,
pub id: Option<WidgetId>, pub( crate ) id: Option<WidgetId>,
pub cursor: Option<crate::types::CursorShape>, pub( crate ) cursor: Option<crate::types::CursorShape>,
} }
impl<Msg: Clone> Pressable<Msg> impl<Msg: Clone> Pressable<Msg>

View File

@@ -34,9 +34,9 @@ mod tests;
pub struct ProgressBar pub struct ProgressBar
{ {
/// Current progress in `[0.0, 1.0]`. Always clamped at construction. /// Current progress in `[0.0, 1.0]`. Always clamped at construction.
pub value: f32, pub( crate ) value: f32,
/// Fill colour. Defaults to the theme's `accent` palette slot. /// Fill colour. Defaults to the theme's `accent` palette slot.
pub fill: Color, pub( crate ) fill: Color,
} }
impl ProgressBar impl ProgressBar
@@ -58,22 +58,23 @@ impl ProgressBar
/// Return the preferred `(width, height)`. Width fills the available /// Return the preferred `(width, height)`. Width fills the available
/// `max_width`; height is the theme-defined row height. /// `max_width`; height is the theme-defined row height.
pub fn preferred_size( &self, max_width: f32 ) -> (f32, f32) pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{ {
( max_width, theme::HEIGHT ) ( max_width, canvas.geom_px( theme::HEIGHT ) )
} }
pub fn draw( &self, canvas: &mut Canvas, rect: Rect ) pub fn draw( &self, canvas: &mut Canvas, rect: Rect )
{ {
let track_y = rect.y + ( rect.height - theme::TRACK_H ) / 2.0; let track_h = canvas.geom_px( theme::TRACK_H );
let track_r = theme::TRACK_H / 2.0; let track_y = rect.y + ( rect.height - track_h ) / 2.0;
let track_r = track_h / 2.0;
let track_rect = Rect let track_rect = Rect
{ {
x: rect.x, x: rect.x,
y: track_y, y: track_y,
width: rect.width, width: rect.width,
height: theme::TRACK_H, height: track_h,
}; };
canvas.fill_rect( track_rect, theme::track_bg(), track_r ); canvas.fill_rect( track_rect, theme::track_bg(), track_r );
@@ -85,7 +86,7 @@ impl ProgressBar
x: rect.x, x: rect.x,
y: track_y, y: track_y,
width: fill_w, width: fill_w,
height: theme::TRACK_H, height: track_h,
}; };
canvas.fill_rect( fill_rect, self.fill, track_r ); canvas.fill_rect( fill_rect, self.fill, track_r );
} }

View File

@@ -16,6 +16,6 @@ fn value_clamped_on_creation()
fn preferred_width_fills_available() fn preferred_width_fills_available()
{ {
let p = progress_bar( 0.5 ); let p = progress_bar( 0.5 );
let ( w, _ ) = p.preferred_size( 300.0 ); let ( w, _ ) = p.preferred_size( 300.0, &crate::render::Canvas::new( 400, 800 ) );
assert_eq!( w, 300.0 ); assert_eq!( w, 300.0 );
} }

View File

@@ -40,14 +40,14 @@ pub struct Radio<Msg: Clone>
{ {
/// Whether this option is currently selected. Drawn from this field /// Whether this option is currently selected. Drawn from this field
/// every frame. /// every frame.
pub selected: bool, pub( crate ) selected: bool,
/// Message emitted when the user picks this option. `None` leaves the /// Message emitted when the user picks this option. `None` leaves the
/// radio inert. /// radio inert.
pub on_select: Option<Msg>, pub( crate ) on_select: Option<Msg>,
/// Optional label drawn to the right of the circle. /// Optional label drawn to the right of the circle.
pub label: Option<String>, pub( crate ) label: Option<String>,
/// Optional stable identifier for focus management. /// Optional stable identifier for focus management.
pub id: Option<WidgetId>, pub( crate ) id: Option<WidgetId>,
} }
impl<Msg: Clone> Radio<Msg> impl<Msg: Clone> Radio<Msg>
@@ -83,14 +83,15 @@ impl<Msg: Clone> Radio<Msg>
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{ {
let outer = canvas.geom_px( theme::OUTER_SIZE );
let w = if let Some( ref label ) = self.label let w = if let Some( ref label ) = self.label
{ {
let text_w = canvas.measure_text( label, theme::FONT_SIZE ); let text_w = canvas.measure_text( label, canvas.font_px( theme::FONT_SIZE ) );
( theme::OUTER_SIZE + theme::GAP + text_w ).min( max_width ) ( outer + canvas.geom_px( theme::GAP ) + text_w ).min( max_width )
} else { } else {
theme::OUTER_SIZE.min( max_width ) outer.min( max_width )
}; };
( w, theme::HEIGHT ) ( w, canvas.geom_px( theme::HEIGHT ) )
} }
/// Focus ring on the outer circle extends `FOCUS_W + 2 + FOCUS_W/2 ≈ 6.5 px` /// Focus ring on the outer circle extends `FOCUS_W + 2 + FOCUS_W/2 ≈ 6.5 px`
@@ -102,28 +103,30 @@ impl<Msg: Clone> Radio<Msg>
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool ) pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool )
{ {
let circle_y = rect.y + ( rect.height - theme::OUTER_SIZE ) / 2.0; let outer_size = canvas.geom_px( theme::OUTER_SIZE );
let outer_r = theme::OUTER_SIZE / 2.0; let circle_y = rect.y + ( rect.height - outer_size ) / 2.0;
let outer_r = outer_size / 2.0;
let outer_rect = Rect let outer_rect = Rect
{ {
x: rect.x, x: rect.x,
y: circle_y, y: circle_y,
width: theme::OUTER_SIZE, width: outer_size,
height: theme::OUTER_SIZE, height: outer_size,
}; };
if self.selected if self.selected
{ {
canvas.fill_rect( outer_rect, theme::selected(), outer_r ); canvas.fill_rect( outer_rect, theme::selected(), outer_r );
let dot_r = theme::DOT_SIZE / 2.0; let dot_size = canvas.geom_px( theme::DOT_SIZE );
let dot_r = dot_size / 2.0;
let cx = rect.x + outer_r; let cx = rect.x + outer_r;
let cy = circle_y + outer_r; let cy = circle_y + outer_r;
let dot_rect = Rect let dot_rect = Rect
{ {
x: cx - dot_r, x: cx - dot_r,
y: cy - dot_r, y: cy - dot_r,
width: theme::DOT_SIZE, width: dot_size,
height: theme::DOT_SIZE, height: dot_size,
}; };
canvas.fill_rect( dot_rect, theme::dot_color(), dot_r ); canvas.fill_rect( dot_rect, theme::dot_color(), dot_r );
} else { } else {
@@ -138,9 +141,10 @@ impl<Msg: Clone> Radio<Msg>
if let Some( ref label ) = self.label if let Some( ref label ) = self.label
{ {
let text_x = rect.x + theme::OUTER_SIZE + theme::GAP; let fs = canvas.font_px( theme::FONT_SIZE );
let text_y = rect.y + ( rect.height + theme::FONT_SIZE ) / 2.0 - 2.0; let text_x = rect.x + outer_size + canvas.geom_px( theme::GAP );
canvas.draw_text( label, text_x, text_y, theme::FONT_SIZE, theme::label_color() ); let text_y = rect.y + ( rect.height + fs ) / 2.0 - 2.0;
canvas.draw_text( label, text_x, text_y, fs, theme::label_color() );
} }
} }

View File

@@ -22,9 +22,9 @@ mod tests;
/// message to emit when it is tapped. /// message to emit when it is tapped.
pub struct LinkSpan<Msg> pub struct LinkSpan<Msg>
{ {
pub start: usize, pub( crate ) start: usize,
pub end: usize, pub( crate ) end: usize,
pub msg: Msg, pub( crate ) msg: Msg,
} }
/// A wrapped paragraph with clickable link ranges — the ltk counterpart of an /// A wrapped paragraph with clickable link ranges — the ltk counterpart of an
@@ -33,12 +33,12 @@ pub struct LinkSpan<Msg>
/// hit rect per link line so taps land on the link rather than the paragraph. /// hit rect per link line so taps land on the link rather than the paragraph.
pub struct RichText<Msg: Clone> pub struct RichText<Msg: Clone>
{ {
pub content: String, pub( crate ) content: String,
pub size: Length, pub( crate ) size: Length,
pub color: Color, pub( crate ) color: Color,
pub link_color: Color, pub( crate ) link_color: Color,
pub font: Option<( String, u16, FontStyle )>, pub( crate ) font: Option<( String, u16, FontStyle )>,
pub links: Vec<LinkSpan<Msg>>, pub( crate ) links: Vec<LinkSpan<Msg>>,
} }
impl<Msg: Clone> RichText<Msg> impl<Msg: Clone> RichText<Msg>

View File

@@ -61,11 +61,11 @@ impl ScrollAxis
pub struct Scroll<Msg: Clone> pub struct Scroll<Msg: Clone>
{ {
/// The single child element drawn inside the scrollable viewport. /// The single child element drawn inside the scrollable viewport.
pub child: Box<Element<Msg>>, pub( crate ) child: Box<Element<Msg>>,
/// Optional stable identifier — used as scroll state key. /// Optional stable identifier — used as scroll state key.
pub id: Option<WidgetId>, pub( crate ) id: Option<WidgetId>,
/// Which axis (or both) this viewport scrolls along. /// Which axis (or both) this viewport scrolls along.
pub axis: ScrollAxis, pub( crate ) axis: ScrollAxis,
} }
impl<Msg: Clone> Scroll<Msg> impl<Msg: Clone> Scroll<Msg>

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>
use crate::types::{ Color, Rect }; use crate::types::{ Color, Length, Rect };
use crate::render::Canvas; use crate::render::Canvas;
use super::Element; use super::Element;
@@ -30,13 +30,16 @@ mod theme;
pub struct Separator pub struct Separator
{ {
/// Stroke colour. Defaults to the theme's `divider` palette slot. /// Stroke colour. Defaults to the theme's `divider` palette slot.
pub color: Color, pub( crate ) color: Color,
/// Line thickness in logical pixels. /// Line thickness. `None` follows the process [`crate::WidgetScaling`]
pub thickness: f32, /// mode; `Some( len )` pins it (including `Length::px( 0.0 )`).
pub( crate ) thickness: Option<Length>,
/// Vertical padding above and below the line, baked into /// Vertical padding above and below the line, baked into
/// `preferred_size` so the divider visually centres in the row a /// `preferred_size` so the divider visually centres in the row a
/// parent column allocates. /// parent column allocates. `None` follows the
pub pad_v: f32, /// [`crate::WidgetScaling`] mode; `Some( len )` pins it — pass
/// `Length::px( 0.0 )` for a flush, padding-less divider.
pub( crate ) pad_v: Option<Length>,
} }
impl Separator impl Separator
@@ -48,11 +51,29 @@ impl Separator
Self Self
{ {
color: theme::color(), color: theme::color(),
thickness: theme::THICKNESS, thickness: None,
pad_v: theme::PAD_V, pad_v: None,
} }
} }
/// Resolve the line thickness: an explicit value wins, otherwise the
/// widget-scaling default.
fn resolved_thickness( &self, canvas: &Canvas ) -> f32
{
self.thickness
.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
.unwrap_or_else( || canvas.geom_px( theme::THICKNESS ) )
}
/// Resolve the vertical padding: an explicit value wins, otherwise the
/// widget-scaling default.
fn resolved_pad_v( &self, canvas: &Canvas ) -> f32
{
self.pad_v
.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
.unwrap_or_else( || canvas.geom_px( theme::PAD_V ) )
}
/// Override the stroke colour. Useful for emphasised dividers between /// Override the stroke colour. Useful for emphasised dividers between
/// destructive actions. /// destructive actions.
pub fn color( mut self, color: Color ) -> Self pub fn color( mut self, color: Color ) -> Self
@@ -61,25 +82,36 @@ impl Separator
self self
} }
/// Override the line thickness. Defaults to 1 logical pixel. /// Override the line thickness. Accepts logical `f32` pixels or any
pub fn thickness( mut self, t: f32 ) -> Self /// [`Length`]. Without this it follows the widget-scaling mode.
pub fn thickness( mut self, t: impl Into<Length> ) -> Self
{ {
self.thickness = t; self.thickness = Some( t.into() );
self
}
/// Override the vertical padding above and below the line. Accepts
/// logical `f32` pixels or any [`Length`]; pass `0.0` for a flush,
/// padding-less divider. Without this it follows the widget-scaling mode.
pub fn pad_v( mut self, p: impl Into<Length> ) -> Self
{
self.pad_v = Some( p.into() );
self self
} }
/// Return the preferred `(width, height)`. Width is `max_width`; /// Return the preferred `(width, height)`. Width is `max_width`;
/// height is `thickness + 2 × pad_v`. /// height is `thickness + 2 × pad_v`.
pub fn preferred_size( &self, max_width: f32 ) -> (f32, f32) pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{ {
( max_width, self.thickness + self.pad_v * 2.0 ) ( max_width, self.resolved_thickness( canvas ) + self.resolved_pad_v( canvas ) * 2.0 )
} }
/// Draw the divider line into `canvas` at `rect`. /// Draw the divider line into `canvas` at `rect`.
pub fn draw( &self, canvas: &mut Canvas, rect: Rect ) pub fn draw( &self, canvas: &mut Canvas, rect: Rect )
{ {
let y = rect.y + rect.height / 2.0; let y = rect.y + rect.height / 2.0;
canvas.draw_line( rect.x, y, rect.x + rect.width, y, self.color, self.thickness ); let thickness = self.resolved_thickness( canvas );
canvas.draw_line( rect.x, y, rect.x + rect.width, y, self.color, thickness );
} }
} }

View File

@@ -4,16 +4,30 @@
use super::*; use super::*;
#[test] #[test]
fn default_thickness() fn default_thickness_and_pad_follow_the_mode()
{ {
let s = separator(); let s = separator();
assert_eq!( s.thickness, theme::THICKNESS ); assert_eq!( s.thickness, None ); // None → follows the widget-scaling mode
assert_eq!( s.pad_v, None );
}
#[test]
fn explicit_zero_pad_v_is_flush_not_the_mode_default()
{
// The Option repr lets `0.0` mean a real flush divider, distinct from
// the mode-following default.
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
let s = separator().pad_v( 0.0 );
let canvas = crate::render::Canvas::new( 412, 900 );
assert_eq!( s.preferred_size( 200.0, &canvas ).1, canvas.geom_px( theme::THICKNESS ) );
} }
#[test] #[test]
fn preferred_height_includes_padding() fn preferred_height_includes_padding()
{ {
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
let s = separator(); let s = separator();
let ( _, h ) = s.preferred_size( 200.0 ); let canvas = crate::render::Canvas::new( 412, 900 );
assert_eq!( h, theme::THICKNESS + theme::PAD_V * 2.0 ); let ( _, h ) = s.preferred_size( 200.0, &canvas );
assert_eq!( h, canvas.geom_px( theme::THICKNESS ) + canvas.geom_px( theme::PAD_V ) * 2.0 );
} }

View File

@@ -27,11 +27,13 @@ pub enum SliderAxis
/// [`Slider`] and [`crate::widget::vslider::VSlider`] through the same call /// [`Slider`] and [`crate::widget::vslider::VSlider`] through the same call
/// site by consulting the [`SliderAxis`] stored in the widget's handler /// site by consulting the [`SliderAxis`] stored in the widget's handler
/// snapshot. /// snapshot.
pub fn value_from_pos_in_rect( rect: Rect, pos: Point, axis: SliderAxis ) -> f32 pub fn value_from_pos_in_rect( rect: Rect, pos: Point, axis: SliderAxis, thumb_px: f32 ) -> f32
{ {
match axis match axis
{ {
SliderAxis::Horizontal => value_from_x_in_rect( rect, pos.x ), // The vertical branch maps against the full rect height and takes no
// thumb inset, so `thumb_px` is unused there.
SliderAxis::Horizontal => value_from_x_in_rect( rect, pos.x, thumb_px ),
SliderAxis::Vertical => crate::widget::vslider::value_from_y_in_rect( rect, pos.y ), SliderAxis::Vertical => crate::widget::vslider::value_from_y_in_rect( rect, pos.y ),
} }
} }
@@ -83,15 +85,29 @@ pub( crate ) fn intersect_clip( saved: &[ Rect ], inner: Rect ) -> Vec<Rect>
/// slider's layout rect. Pure — depends only on `theme::THUMB_SIZE`. Lifted /// slider's layout rect. Pure — depends only on `theme::THUMB_SIZE`. Lifted
/// out of [`Slider`] so input handlers can call it directly from /// out of [`Slider`] so input handlers can call it directly from
/// [`crate::widget::LaidOutWidget`] without needing the [`Element`] tree. /// [`crate::widget::LaidOutWidget`] without needing the [`Element`] tree.
pub fn value_from_x_in_rect( rect: Rect, x: f32 ) -> f32 pub fn value_from_x_in_rect( rect: Rect, x: f32, thumb_px: f32 ) -> f32
{ {
let pad = theme::THUMB_SIZE / 2.0; let pad = thumb_px / 2.0;
let track_start = rect.x + pad; let track_start = rect.x + pad;
let track_end = rect.x + rect.width - pad; let track_end = rect.x + rect.width - pad;
let track_w = ( track_end - track_start ).max( 1.0 ); let track_w = ( track_end - track_start ).max( 1.0 );
( ( x - track_start ) / track_w ).clamp( 0.0, 1.0 ) ( ( x - track_start ) / track_w ).clamp( 0.0, 1.0 )
} }
/// The slider thumb's design pixel size — the raw theme constant, used as a
/// fallback when a widget-scaling-resolved size is not available (e.g. a
/// handler snapshot built outside layout). Public for `test_support`, so the
/// pure `value_from_*` helpers can be exercised with the real design thumb.
pub fn thumb_design_px() -> f32 { theme::THUMB_SIZE }
/// Resolve the thumb size through the widget-scaling mode. The layout pass
/// stores this in the [`crate::widget::WidgetHandlers::Slider`] snapshot so
/// the input path maps clicks against the same thumb the renderer drew.
pub( crate ) fn resolved_thumb_px( canvas: &Canvas ) -> f32
{
canvas.geom_px( theme::THUMB_SIZE )
}
/// A horizontal slider for selecting a value in a range. /// A horizontal slider for selecting a value in a range.
/// ///
/// The track defaults to a theme-resolved surface; pass /// The track defaults to a theme-resolved surface; pass
@@ -113,31 +129,31 @@ pub fn value_from_x_in_rect( rect: Rect, x: f32 ) -> f32
pub struct Slider<Msg: Clone> pub struct Slider<Msg: Clone>
{ {
/// Current value in `[0.0, 1.0]`. /// Current value in `[0.0, 1.0]`.
pub value: f32, pub( crate ) value: f32,
/// Callback invoked with the new value when the slider is dragged. /// Callback invoked with the new value when the slider is dragged.
/// `Arc` (not `Box`) so the layout pass can clone it into the per-leaf /// `Arc` (not `Box`) so the layout pass can clone it into the per-leaf
/// handler snapshot for O(1) dispatch on input events. /// handler snapshot for O(1) dispatch on input events.
pub on_change: Option<Arc<dyn Fn(f32) -> Msg>>, pub( crate ) on_change: Option<Arc<dyn Fn(f32) -> Msg>>,
/// Theme slot id for the track background pill. Defaults to the /// Theme slot id for the track background pill. Defaults to the
/// generic `surface-slider-track`; override per-instance to opt /// generic `surface-slider-track`; override per-instance to opt
/// into the `-flat` (no per-surface backdrop) variant when the /// into the `-flat` (no per-surface backdrop) variant when the
/// slider already lives inside a panel-wide blur, or any other /// slider already lives inside a panel-wide blur, or any other
/// custom slot. /// custom slot.
pub track_surface: &'static str, pub( crate ) track_surface: &'static str,
/// Theme slot id for the rising / leftward fill. Same shape as /// Theme slot id for the rising / leftward fill. Same shape as
/// [`Self::track_surface`] but for the active portion. /// [`Self::track_surface`] but for the active portion.
pub fill_surface: &'static str, pub( crate ) fill_surface: &'static str,
/// Paint the thumb with the active palette's `accent` colour and /// Paint the thumb with the active palette's `accent` colour and
/// a thicker white border (accent inner pill + 4 px white ring) /// a thicker white border (accent inner pill + 4 px white ring)
/// instead of the default white-on-text-primary thumb. /// instead of the default white-on-text-primary thumb.
pub accent_thumb: bool, pub( crate ) accent_thumb: bool,
/// Override the track paint with a custom /// Override the track paint with a custom
/// [`Paint`](crate::theme::Paint) — typically a /// [`Paint`](crate::theme::Paint) — typically a
/// [`Paint::Linear`](crate::theme::Paint::Linear) for spectrum / /// [`Paint::Linear`](crate::theme::Paint::Linear) for spectrum /
/// hue pickers. When set, the active-side fill is suppressed /// hue pickers. When set, the active-side fill is suppressed
/// because a spectrum already conveys position information /// because a spectrum already conveys position information
/// through colour and the thumb shows where the user is. /// through colour and the thumb shows where the user is.
pub track_paint: Option<crate::theme::Paint>, pub( crate ) track_paint: Option<crate::theme::Paint>,
} }
impl<Msg: Clone> Slider<Msg> impl<Msg: Clone> Slider<Msg>
@@ -204,15 +220,17 @@ impl<Msg: Clone> Slider<Msg>
} }
/// Return the preferred `(width, height)`. /// Return the preferred `(width, height)`.
pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32) pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{ {
( max_width, theme::HEIGHT ) ( max_width, canvas.geom_px( theme::HEIGHT ) )
} }
/// Compute the value `[0.0, 1.0]` from a tap/drag x position within `rect`. /// Compute the value `[0.0, 1.0]` from a tap/drag x position within `rect`.
/// Uses the design thumb size; the live input path resolves the thumb
/// through the widget-scaling mode via the handler snapshot instead.
pub fn value_from_x( &self, rect: Rect, x: f32 ) -> f32 pub fn value_from_x( &self, rect: Rect, x: f32 ) -> f32
{ {
value_from_x_in_rect( rect, x ) value_from_x_in_rect( rect, x, thumb_design_px() )
} }
/// Thumb border stroke is centered on the thumb edge (which touches the /// Thumb border stroke is centered on the thumb edge (which touches the
@@ -237,12 +255,17 @@ impl<Msg: Clone> Slider<Msg>
/// theme still paints a usable slider. /// theme still paints a usable slider.
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool ) pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool )
{ {
let pad = theme::THUMB_SIZE / 2.0; // Resolve the thumb / track through the widget-scaling mode. The
let track_y = rect.y + (rect.height - theme::TRACK_H) / 2.0; // pad MUST match `value_from_x_in_rect`, which the input path feeds
// the same mode-resolved thumb size via the handler snapshot.
let thumb = canvas.geom_px( theme::THUMB_SIZE );
let track_h = canvas.geom_px( theme::TRACK_H );
let pad = thumb / 2.0;
let track_y = rect.y + (rect.height - track_h) / 2.0;
let track_start = rect.x + pad; let track_start = rect.x + pad;
let track_end = rect.x + rect.width - pad; let track_end = rect.x + rect.width - pad;
let track_w = track_end - track_start; let track_w = track_end - track_start;
let radius_bg = theme::TRACK_H / 2.0; let radius_bg = track_h / 2.0;
// Background track — full pill across the inner span. // Background track — full pill across the inner span.
let track_rect = Rect let track_rect = Rect
@@ -250,7 +273,7 @@ impl<Msg: Clone> Slider<Msg>
x: track_start, x: track_start,
y: track_y, y: track_y,
width: track_w, width: track_w,
height: theme::TRACK_H, height: track_h,
}; };
if let Some( paint ) = self.track_paint.as_ref() if let Some( paint ) = self.track_paint.as_ref()
{ {
@@ -294,7 +317,7 @@ impl<Msg: Clone> Slider<Msg>
x: track_start, x: track_start,
y: track_y, y: track_y,
width: fill_w, width: fill_w,
height: theme::TRACK_H, height: track_h,
}; };
let saved_clip = canvas.clip_bounds(); let saved_clip = canvas.clip_bounds();
let band = intersect_clip( &saved_clip, visible ); let band = intersect_clip( &saved_clip, visible );
@@ -357,13 +380,13 @@ impl<Msg: Clone> Slider<Msg>
} }
else else
{ {
let thumb_r = theme::THUMB_SIZE / 2.0; let thumb_r = thumb / 2.0;
let thumb_rect = Rect let thumb_rect = Rect
{ {
x: thumb_cx - thumb_r, x: thumb_cx - thumb_r,
y: thumb_cy - thumb_r, y: thumb_cy - thumb_r,
width: theme::THUMB_SIZE, width: thumb,
height: theme::THUMB_SIZE, height: thumb,
}; };
canvas.fill_rect( thumb_rect, theme::thumb(), thumb_r ); canvas.fill_rect( thumb_rect, theme::thumb(), thumb_r );
canvas.stroke_rect( thumb_rect, theme::thumb_border(), theme::THUMB_BORDER_W, thumb_r ); canvas.stroke_rect( thumb_rect, theme::thumb_border(), theme::THUMB_BORDER_W, thumb_r );

View File

@@ -48,6 +48,7 @@ fn axis_dispatch_horizontal_uses_x()
rect, rect,
Point { x: 200.0, y: 9999.0 }, Point { x: 200.0, y: 9999.0 },
SliderAxis::Horizontal, SliderAxis::Horizontal,
super::thumb_design_px(),
); );
assert_eq!( v, 1.0 ); assert_eq!( v, 1.0 );
} }
@@ -61,10 +62,26 @@ fn axis_dispatch_vertical_uses_y()
rect, rect,
Point { x: 9999.0, y: 0.0 }, Point { x: 9999.0, y: 0.0 },
SliderAxis::Vertical, SliderAxis::Vertical,
super::thumb_design_px(),
); );
assert_eq!( v, 1.0 ); assert_eq!( v, 1.0 );
} }
#[test]
fn value_from_x_respects_thumb_pad()
{
let rect = Rect { x: 0.0, y: 0.0, width: 100.0, height: 36.0 };
// A 20 px thumb → 10 px pad → the track spans x in [10, 90].
assert_eq!( super::value_from_x_in_rect( rect, 10.0, 20.0 ), 0.0 );
assert_eq!( super::value_from_x_in_rect( rect, 90.0, 20.0 ), 1.0 );
assert_eq!( super::value_from_x_in_rect( rect, 50.0, 20.0 ), 0.5 );
// A bigger thumb → bigger pad → the same x maps to a smaller value.
assert!(
super::value_from_x_in_rect( rect, 20.0, 40.0 )
< super::value_from_x_in_rect( rect, 20.0, 20.0 )
);
}
#[test] #[test]
fn track_paint_default_is_none() fn track_paint_default_is_none()
{ {

View File

@@ -39,14 +39,15 @@ pub struct Spinner
/// Rotation phase. Only the fractional part is consumed, so any /// Rotation phase. Only the fractional part is consumed, so any
/// monotonically increasing source works (`elapsed.as_secs_f32()`, /// monotonically increasing source works (`elapsed.as_secs_f32()`,
/// frame count divided by FPS, etc.). /// frame count divided by FPS, etc.).
pub phase: f32, pub( crate ) phase: f32,
/// Arc / ring colour. Defaults to the theme's `accent` palette slot. /// Arc / ring colour. Defaults to the theme's `accent` palette slot.
pub color: Color, pub( crate ) color: Color,
/// Square diameter in logical pixels. Both width and height of the /// Square diameter in logical pixels. Both width and height of the
/// laid-out rect target this size. /// laid-out rect target this size. The `0.0` default follows the
pub size: f32, /// process [`crate::WidgetScaling`] mode; any positive value pins it.
pub( crate ) size: f32,
/// Stroke width of the arc and the dim guide ring. /// Stroke width of the arc and the dim guide ring.
pub stroke_w: f32, pub( crate ) stroke_w: f32,
} }
impl Spinner impl Spinner
@@ -58,11 +59,18 @@ impl Spinner
{ {
phase: 0.0, phase: 0.0,
color: theme::fill(), color: theme::fill(),
size: theme::SIZE, size: 0.0,
stroke_w: theme::STROKE_W, stroke_w: theme::STROKE_W,
} }
} }
/// Resolve the square diameter against the widget-scaling mode: a
/// positive [`Self::size`] pins it, the `0.0` default follows the mode.
fn resolved_size( &self, canvas: &Canvas ) -> f32
{
if self.size > 0.0 { self.size } else { canvas.geom_px( theme::SIZE ) }
}
/// Set the rotation phase. The widget consumes only the fractional /// Set the rotation phase. The widget consumes only the fractional
/// part, so callers can pass an unbounded monotonic clock value. /// part, so callers can pass an unbounded monotonic clock value.
pub fn phase( mut self, p: f32 ) -> Self pub fn phase( mut self, p: f32 ) -> Self
@@ -93,9 +101,9 @@ impl Spinner
} }
/// Return the preferred `(width, height)` — the spinner is square. /// Return the preferred `(width, height)` — the spinner is square.
pub fn preferred_size( &self, max_width: f32 ) -> ( f32, f32 ) pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{ {
let s = self.size.min( max_width ); let s = self.resolved_size( canvas ).min( max_width );
( s, s ) ( s, s )
} }

View File

@@ -8,7 +8,7 @@ fn defaults()
{ {
let s = spinner(); let s = spinner();
assert_eq!( s.phase, 0.0 ); assert_eq!( s.phase, 0.0 );
assert_eq!( s.size, theme::SIZE ); assert_eq!( s.size, 0.0 ); // 0.0 → follows the widget-scaling mode
assert_eq!( s.stroke_w, theme::STROKE_W ); assert_eq!( s.stroke_w, theme::STROKE_W );
} }
@@ -25,14 +25,15 @@ fn builders_apply()
fn preferred_size_clamps_to_max_width() fn preferred_size_clamps_to_max_width()
{ {
let s = spinner().size( 100.0 ); let s = spinner().size( 100.0 );
assert_eq!( s.preferred_size( 40.0 ), ( 40.0, 40.0 ) ); let canvas = crate::render::Canvas::new( 412, 900 );
assert_eq!( s.preferred_size( 200.0 ), ( 100.0, 100.0 ) ); assert_eq!( s.preferred_size( 40.0, &canvas ), ( 40.0, 40.0 ) );
assert_eq!( s.preferred_size( 200.0, &canvas ), ( 100.0, 100.0 ) );
} }
#[ test ] #[ test ]
fn preferred_size_is_square() fn preferred_size_is_square()
{ {
let s = spinner(); let s = spinner();
let ( w, h ) = s.preferred_size( 999.0 ); let ( w, h ) = s.preferred_size( 999.0, &crate::render::Canvas::new( 412, 900 ) );
assert_eq!( w, h ); assert_eq!( w, h );
} }

View File

@@ -20,7 +20,7 @@
//! # }} //! # }}
//! ``` //! ```
use crate::types::Color; use crate::types::{ Color, Length };
use super::Element; use super::Element;
mod theme; mod theme;
@@ -35,12 +35,12 @@ mod tests;
/// expected). /// expected).
pub struct TabBar<Msg: Clone> pub struct TabBar<Msg: Clone>
{ {
pub labels: Vec<String>, pub( crate ) labels: Vec<String>,
pub selected: usize, pub( crate ) selected: usize,
pub on_select: Option<std::sync::Arc<dyn Fn( usize ) -> Msg>>, pub( crate ) on_select: Option<std::sync::Arc<dyn Fn( usize ) -> Msg>>,
/// Background colour of the strip itself. `None` paints no /// Background colour of the strip itself. `None` paints no
/// background, letting the parent surface show through. /// background, letting the parent surface show through.
pub strip_bg: Option<Color>, pub( crate ) strip_bg: Option<Color>,
} }
impl<Msg: Clone + 'static> TabBar<Msg> impl<Msg: Clone + 'static> TabBar<Msg>
@@ -104,7 +104,7 @@ impl<Msg: Clone + 'static> TabBar<Msg>
let cb = self.on_select.clone(); let cb = self.on_select.clone();
let labels = self.labels; let labels = self.labels;
let mut r = row::<Msg>().padding( theme::PADDING ).spacing( theme::SPACING ); let mut r = row::<Msg>().padding( Length::widget( theme::PADDING ) ).spacing( Length::widget( theme::SPACING ) );
for ( i, label ) in labels.into_iter().enumerate() for ( i, label ) in labels.into_iter().enumerate()
{ {
let is_active = i == selected; let is_active = i == selected;

View File

@@ -33,24 +33,24 @@ pub enum TextAlign
/// either word-wrapped or kept on one line with ellipsis truncation. /// either word-wrapped or kept on one line with ellipsis truncation.
pub struct Text pub struct Text
{ {
pub content: String, pub( crate ) content: String,
/// Font size as a [`Length`]. Resolved against the surface's logical /// Font size as a [`Length`]. Resolved against the surface's logical
/// viewport at layout time, so a `Length::Vmin( 5.0 )` heading scales /// viewport at layout time, so a `Length::Vmin( 5.0 )` heading scales
/// with the screen instead of being frozen at a px constant. /// with the screen instead of being frozen at a px constant.
pub size: Length, pub( crate ) size: Length,
pub color: Color, pub( crate ) color: Color,
pub align: TextAlign, pub( crate ) align: TextAlign,
pub wrap: bool, pub( crate ) wrap: bool,
/// When `true` (default), overflowing single-line text is truncated /// When `true` (default), overflowing single-line text is truncated
/// with an ellipsis. When `false`, the full string is painted even /// with an ellipsis. When `false`, the full string is painted even
/// if it extends past the layout rect — useful for very short /// if it extends past the layout rect — useful for very short
/// labels (calendar day numbers, day-of-week stubs) where a couple /// labels (calendar day numbers, day-of-week stubs) where a couple
/// of pixels of overflow is invisible but "..." is noisy. /// of pixels of overflow is invisible but "..." is noisy.
pub truncate: bool, pub( crate ) truncate: bool,
/// Optional `(family, weight, style)` override resolved through /// Optional `(family, weight, style)` override resolved through
/// the active theme's font registry on every draw. `None` keeps /// the active theme's font registry on every draw. `None` keeps
/// the canvas default font (Sora Regular in `ltk-theme-default`). /// the canvas default font (Sora Regular in `ltk-theme-default`).
pub font: Option<( String, u16, FontStyle )>, pub( crate ) font: Option<( String, u16, FontStyle )>,
} }
impl Text impl Text

View File

@@ -81,7 +81,7 @@ impl<Msg: Clone> TextEdit<Msg>
canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS ); canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS );
} }
let font_size = self.font_size; let font_size = self.effective_font_size( canvas );
let text_y = rect.y + (rect.height + font_size) / 2.0 - 2.0; let text_y = rect.y + (rect.height + font_size) / 2.0 - 2.0;
let text = self.display_text(); let text = self.display_text();
let secure = self.effective_secure(); let secure = self.effective_secure();

View File

@@ -60,6 +60,9 @@ pub( crate ) fn byte_offset_at(
byte_offset_in_line( canvas, value, vl.start, vl.end, pos.x - rect.x - theme::PAD_H, false, theme::FONT_SIZE ) byte_offset_in_line( canvas, value, vl.start, vl.end, pos.x - rect.x - theme::PAD_H, false, theme::FONT_SIZE )
} else { } else {
// Resolve the font-size sentinel (0.0 / negative → widget-scaling mode)
// so a click maps against the same size the renderer drew with.
let font_size = super::resolve_font_size( canvas, font_size );
// Mirror the horizontal scroll *and* alignment the renderer // Mirror the horizontal scroll *and* alignment the renderer
// applies so a click lands on the glyph the user actually // applies so a click lands on the glyph the user actually
// sees, not on the byte offset that would correspond to an // sees, not on the byte offset that would correspond to an

View File

@@ -10,7 +10,7 @@ use std::sync::Arc;
use crate::render::Canvas; use crate::render::Canvas;
use crate::secure_mem::secure_zero; use crate::secure_mem::secure_zero;
use crate::types::{ Rect, WidgetId }; use crate::types::{ Length, Rect, WidgetId };
use super::Element; use super::Element;
@@ -26,6 +26,23 @@ pub use draw::password_toggle_hit_zone;
pub( crate ) use hit_test::byte_offset_at; pub( crate ) use hit_test::byte_offset_at;
pub( crate ) use cursor::{ cursor_visual_down, cursor_visual_end, cursor_visual_home, cursor_visual_up }; pub( crate ) use cursor::{ cursor_visual_down, cursor_visual_end, cursor_visual_home, cursor_visual_up };
/// Decode a single-line font-size field into a concrete px against the
/// widget-scaling mode. A positive value is an explicit fixed size; `0.0`
/// follows the mode at the theme default; a negative value follows the mode
/// at the design px `-value` (see [`TextEdit::font_size_fluid`]). Shared by
/// the draw and hit-test paths, which all carry a canvas.
pub( crate ) fn resolve_font_size( canvas: &Canvas, fs: f32 ) -> f32
{
if fs > 0.0
{
fs
} else if fs == 0.0 {
canvas.font_px( theme::FONT_SIZE )
} else {
canvas.font_px( -fs )
}
}
/// A text input field. /// A text input field.
/// ///
/// Single-line by default; switches to a multi-row text-area via /// Single-line by default; switches to a multi-row text-area via
@@ -83,67 +100,75 @@ pub( crate ) use cursor::{ cursor_visual_down, cursor_visual_end, cursor_visual_
pub struct TextEdit<Msg: Clone> pub struct TextEdit<Msg: Clone>
{ {
/// Placeholder text shown when the field is empty. /// Placeholder text shown when the field is empty.
pub placeholder: String, pub( crate ) placeholder: String,
/// Current field value. /// Current field value.
pub value: String, pub( crate ) value: String,
/// Callback invoked with the new value on every keystroke. /// Callback invoked with the new value on every keystroke.
/// `Arc` (not `Box`) so the layout pass can clone it into the per-leaf /// `Arc` (not `Box`) so the layout pass can clone it into the per-leaf
/// handler snapshot for O(1) dispatch on input events. /// handler snapshot for O(1) dispatch on input events.
pub on_change: Option<Arc<dyn Fn(String) -> Msg>>, pub( crate ) on_change: Option<Arc<dyn Fn(String) -> Msg>>,
/// Message emitted when the user presses Enter. /// Message emitted when the user presses Enter.
pub on_submit: Option<Msg>, pub( crate ) on_submit: Option<Msg>,
/// When `true`, the value is rendered as bullet characters (password mode). /// When `true`, the value is rendered as bullet characters (password mode).
pub secure: bool, pub( crate ) secure: bool,
/// When `true`, the widget renders as a multi-row text area: the /// When `true`, the widget renders as a multi-row text area: the
/// box grows to [`Self::rows`] visible rows, line breaks in the /// box grows to [`Self::rows`] visible rows, line breaks in the
/// value are honoured at draw time, and pressing Enter inserts a /// value are honoured at draw time, and pressing Enter inserts a
/// `\n` rather than firing [`Self::on_submit`]. Ignored when /// `\n` rather than firing [`Self::on_submit`]. Ignored when
/// [`Self::secure`] is set — passwords are always single-line. /// [`Self::secure`] is set — passwords are always single-line.
pub multiline: bool, pub( crate ) multiline: bool,
/// Visible row count when `multiline` is `true`. Drives /// Visible row count when `multiline` is `true`. Drives
/// `preferred_size`'s height calculation so a multiline field /// `preferred_size`'s height calculation so a multiline field
/// claims a sensible vertical slot in the parent layout. Ignored /// claims a sensible vertical slot in the parent layout. Ignored
/// when `multiline` is `false`. /// when `multiline` is `false`.
pub rows: u32, pub( crate ) rows: u32,
/// Byte offset of the text cursor within `value` (used by insert_str/backspace). /// Byte offset of the text cursor within `value` (used by insert_str/backspace).
pub cursor_pos: usize, pub( crate ) cursor_pos: usize,
/// Optional stable identifier for focus management. /// Optional stable identifier for focus management.
pub id: Option<WidgetId>, pub( crate ) id: Option<WidgetId>,
/// Override the pointer cursor shape on hover. `None` falls back /// Override the pointer cursor shape on hover. `None` falls back
/// to the I-beam default that matches every desktop convention. /// to the I-beam default that matches every desktop convention.
pub cursor: Option<crate::types::CursorShape>, pub( crate ) cursor: Option<crate::types::CursorShape>,
/// Horizontal alignment of the displayed text inside the inner /// Horizontal alignment of the displayed text inside the inner
/// content rect. Only takes effect on the single-line path when /// content rect. Only takes effect on the single-line path when
/// the value fits inside the inner width — once the value /// the value fits inside the inner width — once the value
/// overflows, the internal `single_line_scroll_x` helper takes /// overflows, the internal `single_line_scroll_x` helper takes
/// over and the alignment offset collapses to `0` so scrolling /// over and the alignment offset collapses to `0` so scrolling
/// reads naturally. Default `TextAlign::Left`. /// reads naturally. Default `TextAlign::Left`.
pub align: super::text::TextAlign, pub( crate ) align: super::text::TextAlign,
/// Skip the field's background fill and border stroke. Useful /// Skip the field's background fill and border stroke. Useful
/// when the [`TextEdit`] is dropped inside another container /// when the [`TextEdit`] is dropped inside another container
/// that paints its own surface (e.g. the digit cells inside /// that paints its own surface (e.g. the digit cells inside
/// [`crate::widget::time_picker::TimePicker`]) and a second pill /// [`crate::widget::time_picker::TimePicker`]) and a second pill
/// would only add visual noise. /// would only add visual noise.
pub borderless: bool, pub( crate ) borderless: bool,
/// Override the preferred width reported to the parent layout. /// Override the preferred width reported to the parent layout.
/// Without this the single-line `TextEdit` claims `max_width` and /// Without this the single-line `TextEdit` claims `max_width` and
/// fills whatever rect the parent allocates — which is the right /// fills whatever rect the parent allocates — which is the right
/// 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 fixed_width: Option<f32>, pub( crate ) fixed_width: Option<f32>,
/// Font size in pixels for the single-line draw path. Defaults to /// Font size in pixels for the single-line draw path. `0.0` (the
/// the theme's `FONT_SIZE` constant. Multiline mode ignores this /// default) follows the process [`crate::WidgetScaling`] mode (fluid by
/// for now and always uses the default — multiline soft-wrap /// default, via [`crate::Canvas::font_px`]); any positive value pins an
/// layout depends on the constant in several places that are not /// explicit size. The sentinel avoids threading a canvas through the
/// yet parameterised. /// handler snapshot; every consumer that reads it has a canvas and
pub font_size: f32, /// resolves it via [`Self::effective_font_size`]. Multiline mode ignores
/// this and always uses the theme constant.
pub( crate ) font_size: f32,
/// Optional single-line field height. `None` uses the theme default
/// (`theme::HEIGHT`); a [`Length`] scales the box with the surface, so
/// a field can match a fluid button or grow with a fluid form. Resolved
/// in physical layout space, like all geometry. Ignored in multiline
/// mode, where the height follows [`Self::rows`].
pub( crate ) height: Option<Length>,
/// When `true`, focusing the field selects the whole value so the /// When `true`, focusing the field selects the whole value so the
/// next keystroke replaces it. Standard behaviour for numeric /// next keystroke replaces it. Standard behaviour for numeric
/// pickers and short-form fields where the user usually wants to /// pickers and short-form fields where the user usually wants to
/// retype rather than edit. Default `false` — long-form fields /// retype rather than edit. Default `false` — long-form fields
/// keep the cursor at the end on focus. /// keep the cursor at the end on focus.
pub select_on_focus: bool, pub( crate ) select_on_focus: bool,
/// Self-managed "show / hide password" eye affordance — when /// Self-managed "show / hide password" eye affordance — when
/// `Some( ( visible, on_toggle ) )` the field renders an /// `Some( ( visible, on_toggle ) )` the field renders an
/// `actions/visible` ↔ `actions/invisible` icon at its right /// `actions/visible` ↔ `actions/invisible` icon at its right
@@ -152,10 +177,10 @@ pub struct TextEdit<Msg: Clone>
/// `visible` (overriding `secure`). Set on a field that already /// `visible` (overriding `secure`). Set on a field that already
/// has `secure( true )` and the explicit flag becomes redundant /// has `secure( true )` and the explicit flag becomes redundant
/// — the toggle controls the visibility from then on. /// — the toggle controls the visibility from then on.
pub password_toggle: Option<( bool, Msg )>, pub( crate ) password_toggle: Option<( bool, Msg )>,
/// When `true`, the field renders its box and value but takes no keyboard /// When `true`, the field renders its box and value but takes no keyboard
/// focus and accepts no input — a read-only display styled as a text field. /// focus and accepts no input — a read-only display styled as a text field.
pub read_only: bool, pub( crate ) read_only: bool,
} }
impl<Msg: Clone> TextEdit<Msg> impl<Msg: Clone> TextEdit<Msg>
@@ -181,7 +206,8 @@ impl<Msg: Clone> TextEdit<Msg>
align: super::text::TextAlign::Left, align: super::text::TextAlign::Left,
borderless: false, borderless: false,
fixed_width: None, fixed_width: None,
font_size: theme::FONT_SIZE, font_size: 0.0,
height: None,
select_on_focus: false, select_on_focus: false,
password_toggle: None, password_toggle: None,
read_only: false, read_only: false,
@@ -214,15 +240,43 @@ impl<Msg: Clone> TextEdit<Msg>
} }
} }
/// Override the font size used by the single-line draw path. /// Pin the single-line font size to an explicit fixed px. Without any
/// Defaults to the theme's `FONT_SIZE` constant. Ignored in /// font-size call the size follows the process [`crate::WidgetScaling`]
/// multiline mode. /// mode at the theme default; see [`Self::font_size_fluid`] to follow
/// the mode at a custom design px. Ignored in multiline mode.
pub fn font_size( mut self, px: f32 ) -> Self pub fn font_size( mut self, px: f32 ) -> Self
{ {
self.font_size = px.max( 1.0 ); self.font_size = px.max( 1.0 );
self self
} }
/// Set a font size that follows the process [`crate::WidgetScaling`]
/// mode at the given design px — the fluid counterpart of
/// [`Self::font_size`]. Encoded as a negative sentinel so it flows
/// through the handler snapshot as a plain `f32` without threading a
/// canvas; every consumer resolves it via `resolve_font_size`.
pub fn font_size_fluid( mut self, design_px: f32 ) -> Self
{
self.font_size = -design_px.max( 1.0 );
self
}
/// Resolve the single-line font size against the widget-scaling mode.
pub( crate ) fn effective_font_size( &self, canvas: &Canvas ) -> f32
{
resolve_font_size( canvas, self.font_size )
}
/// Set the single-line field height. Accepts logical `f32` pixels or any
/// [`Length`] (e.g. `Length::vmin( 9.0 ).clamp( 44.0, 72.0 )` to scale
/// the box with the surface, or to match a fluid button). Ignored in
/// multiline mode, where the height follows [`Self::rows`].
pub fn height( mut self, h: impl Into<Length> ) -> Self
{
self.height = Some( h.into() );
self
}
/// Select the whole value when the field receives focus, so the /// Select the whole value when the field receives focus, so the
/// next keystroke replaces it. Default `false`. /// next keystroke replaces it. Default `false`.
pub fn select_on_focus( mut self, on: bool ) -> Self pub fn select_on_focus( mut self, on: bool ) -> Self
@@ -383,7 +437,7 @@ impl<Msg: Clone> TextEdit<Msg>
/// ///
/// Single-line: theme-defined `HEIGHT`. /// Single-line: theme-defined `HEIGHT`.
/// Multiline: enough room for [`Self::rows`] lines plus padding. /// Multiline: enough room for [`Self::rows`] lines plus padding.
pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32) pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{ {
if self.multiline && !self.effective_secure() if self.multiline && !self.effective_secure()
{ {
@@ -394,7 +448,10 @@ impl<Msg: Clone> TextEdit<Msg>
let w = self.fixed_width let w = self.fixed_width
.map( |fw| fw.min( max_width ) ) .map( |fw| fw.min( max_width ) )
.unwrap_or( max_width ); .unwrap_or( max_width );
( w, theme::HEIGHT ) let h = self.height
.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
.unwrap_or_else( || canvas.geom_px( theme::HEIGHT ) );
( w, h )
} }
} }
@@ -408,7 +465,7 @@ impl<Msg: Clone> TextEdit<Msg>
} }
/// Translate a pointer position inside `rect` to the byte offset /// Translate a pointer position inside `rect` to the byte offset
/// in [`Self::value`] that the cursor should land on. Thin /// in the field's `value` that the cursor should land on. Thin
/// wrapper around `byte_offset_at` using this widget's value / /// wrapper around `byte_offset_at` using this widget's value /
/// flags. /// flags.
pub fn byte_offset_at_self( pub fn byte_offset_at_self(
@@ -501,6 +558,7 @@ impl<Msg: Clone> TextEdit<Msg>
borderless: self.borderless, borderless: self.borderless,
fixed_width: self.fixed_width, fixed_width: self.fixed_width,
font_size: self.font_size, font_size: self.font_size,
height: self.height,
select_on_focus: self.select_on_focus, select_on_focus: self.select_on_focus,
password_toggle: self.password_toggle.clone().map( |( v, m )| ( v, ( *f )( m ) ) ), password_toggle: self.password_toggle.clone().map( |( v, m )| ( v, ( *f )( m ) ) ),
read_only: self.read_only, read_only: self.read_only,

View File

@@ -368,10 +368,12 @@ fn multiline_builder_enables()
fn multiline_grows_preferred_height_with_rows() fn multiline_grows_preferred_height_with_rows()
{ {
use crate::render::Canvas; use crate::render::Canvas;
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
let canvas = Canvas::new( 800, 600 ); let canvas = Canvas::new( 800, 600 );
let single = new_te( "" ); let single = new_te( "" );
let ( _, h_single ) = single.preferred_size( 200.0, &canvas ); let ( _, h_single ) = single.preferred_size( 200.0, &canvas );
assert_eq!( h_single, super::theme::HEIGHT ); // The single-line default routes through the widget-scaling mode.
assert_eq!( h_single, canvas.geom_px( super::theme::HEIGHT ) );
let multi = new_te( "" ).multiline( true ).rows( 5 ); let multi = new_te( "" ).multiline( true ).rows( 5 );
let ( _, h_multi ) = multi.preferred_size( 200.0, &canvas ); let ( _, h_multi ) = multi.preferred_size( 200.0, &canvas );
@@ -382,6 +384,23 @@ fn multiline_grows_preferred_height_with_rows()
assert!( h_bigger > h_multi, "more rows → taller box" ); assert!( h_bigger > h_multi, "more rows → taller box" );
} }
#[ test ]
fn height_builder_scales_single_line_box_with_surface()
{
use crate::render::Canvas;
use crate::types::Length;
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
// An explicit height bypasses the mode: vmin on a 400×800 surface →
// 10 % of the smaller side (400) = 40 px, resolved in physical space.
let t = new_te( "" ).height( Length::vmin( 10.0 ) );
let canvas = Canvas::new( 400, 800 );
assert_eq!( t.preferred_size( 1000.0, &canvas ).1, 40.0 );
// The default single-line height follows the widget-scaling mode.
let d = new_te( "" );
assert_eq!( d.preferred_size( 1000.0, &canvas ).1, canvas.geom_px( super::theme::HEIGHT ) );
}
#[ test ] #[ test ]
fn rows_clamps_zero_to_one() fn rows_clamps_zero_to_one()
{ {
@@ -425,7 +444,7 @@ fn defaults_for_new_inline_builders()
assert_eq!( t.align, super::super::text::TextAlign::Left ); assert_eq!( t.align, super::super::text::TextAlign::Left );
assert!( !t.borderless ); assert!( !t.borderless );
assert!( t.fixed_width.is_none() ); assert!( t.fixed_width.is_none() );
assert_eq!( t.font_size, super::theme::FONT_SIZE ); assert_eq!( t.font_size, 0.0 ); // sentinel: follows the widget-scaling mode
assert!( !t.select_on_focus ); assert!( !t.select_on_focus );
} }
@@ -462,6 +481,21 @@ fn font_size_builder_clamps_to_at_least_one_pixel()
assert_eq!( t.font_size, 1.0 ); assert_eq!( t.font_size, 1.0 );
} }
#[ test ]
fn font_size_fluid_encodes_negative_sentinel_and_resolves_via_mode()
{
use crate::render::Canvas;
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size_fluid( 28.0 );
assert_eq!( t.font_size, -28.0 );
// Negative sentinel resolves to the widget-scaling font of design px 28.
let canvas = Canvas::new( 412, 900 );
assert_eq!( t.effective_font_size( &canvas ), canvas.font_px( 28.0 ) );
// The 0.0 default resolves to the theme default under the mode.
let d: TextEdit<()> = TextEdit::new( "".into(), "".into() );
assert_eq!( d.effective_font_size( &canvas ), canvas.font_px( super::theme::FONT_SIZE ) );
}
#[ test ] #[ test ]
fn select_on_focus_builder_toggles_flag() fn select_on_focus_builder_toggles_flag()
{ {

View File

@@ -38,6 +38,7 @@ use std::sync::Arc;
use crate::layout::column::column; 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::types::Length;
use super::Element; use super::Element;
@@ -152,12 +153,12 @@ fn snap_step_delta( value: i32, step: i32, dir: i32 ) -> i32
/// Time-of-day selector with up / down steppers per unit. /// Time-of-day selector with up / down steppers per unit.
pub struct TimePicker<Msg: Clone> pub struct TimePicker<Msg: Clone>
{ {
pub value: Time, pub( crate ) value: Time,
pub on_change: Option<Arc<dyn Fn( Time ) -> Msg>>, pub( crate ) on_change: Option<Arc<dyn Fn( Time ) -> Msg>>,
pub minute_step: u8, pub( crate ) minute_step: u8,
pub second_step: u8, pub( crate ) second_step: u8,
pub seconds: bool, pub( crate ) seconds: bool,
pub twelve_hour: bool, pub( crate ) twelve_hour: bool,
} }
impl<Msg: Clone + 'static> TimePicker<Msg> impl<Msg: Clone + 'static> TimePicker<Msg>
@@ -305,7 +306,7 @@ impl<Msg: Clone + 'static> TimePicker<Msg>
None => None =>
{ {
text( display ) text( display )
.size( theme::VAL_FS ) .size( Length::widget( theme::VAL_FS ) )
.color( theme::text() ) .color( theme::text() )
.align_center() .align_center()
.into() .into()
@@ -317,7 +318,7 @@ impl<Msg: Clone + 'static> TimePicker<Msg>
text_edit::<Msg>( "", display ) text_edit::<Msg>( "", display )
.borderless( true ) .borderless( true )
.fixed_width( 72.0 ) .fixed_width( 72.0 )
.font_size( theme::VAL_FS ) .font_size_fluid( theme::VAL_FS )
.align( TextAlign::Center ) .align( TextAlign::Center )
.select_on_focus( true ) .select_on_focus( true )
.on_change( move |s: String| .on_change( move |s: String|
@@ -386,9 +387,9 @@ impl<Msg: Clone + 'static> TimePicker<Msg>
); );
let minute_col = stepper( minute_field, minute_up, minute_dn ); let minute_col = stepper( minute_field, minute_up, minute_dn );
let mut units = row::<Msg>().spacing( theme::SPACING ) let mut units = row::<Msg>().spacing( Length::widget( theme::SPACING ) )
.push( hour_col ) .push( hour_col )
.push( text( ":" ).size( theme::SEP_FS ).color( theme::text_muted() ) ) .push( text( ":" ).size( Length::widget( theme::SEP_FS ) ).color( theme::text_muted() ) )
.push( minute_col ); .push( minute_col );
if self.seconds if self.seconds
@@ -406,7 +407,7 @@ impl<Msg: Clone + 'static> TimePicker<Msg>
); );
let second_col = stepper( second_field, second_up, second_dn ); let second_col = stepper( second_field, second_up, second_dn );
units = units units = units
.push( text( ":" ).size( theme::SEP_FS ).color( theme::text_muted() ) ) .push( text( ":" ).size( Length::widget( theme::SEP_FS ) ).color( theme::text_muted() ) )
.push( second_col ); .push( second_col );
} }
@@ -427,7 +428,7 @@ impl<Msg: Clone + 'static> TimePicker<Msg>
// the whole hh:mm AM block inside the container. A flex // the whole hh:mm AM block inside the container. A flex
// spacer here would defeat the auto-centre and pin AM/PM // spacer here would defeat the auto-centre and pin AM/PM
// to the right. // to the right.
units = units.push( spacer().width( theme::SPACING * 2.0 ) ).push( ampm ); units = units.push( spacer().width( Length::widget( theme::SPACING * 2.0 ) ) ).push( ampm );
} }
// `Row::layout` centres a cluster horizontally when there are // `Row::layout` centres a cluster horizontally when there are
@@ -438,7 +439,7 @@ impl<Msg: Clone + 'static> TimePicker<Msg>
// AM/PM), so they centre automatically inside the container. // AM/PM), so they centre automatically inside the container.
container::<Msg>( units ) container::<Msg>( units )
.background( theme::surface_alt() ) .background( theme::surface_alt() )
.padding( theme::PADDING ) .padding( Length::widget( theme::PADDING ) )
.radius( theme::RADIUS ) .radius( theme::RADIUS )
.into() .into()
} }

View File

@@ -37,7 +37,7 @@ use std::hash::{ Hash, Hasher };
use std::time::Duration; use std::time::Duration;
use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec }; use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec };
use crate::types::{ Color, WidgetId }; use crate::types::{ Color, Length, WidgetId };
use super::Element; use super::Element;
mod theme; mod theme;
@@ -55,20 +55,20 @@ pub struct Toast<Msg: Clone>
/// Stable id for the overlay. Used to derive the [`OverlayId`] so /// Stable id for the overlay. Used to derive the [`OverlayId`] so
/// the same toast persists across frames when the message stays the /// the same toast persists across frames when the message stays the
/// same. /// same.
pub id: WidgetId, pub( crate ) id: WidgetId,
/// Body text rendered inside the pill. /// Body text rendered inside the pill.
pub message: String, pub( crate ) message: String,
/// Display duration. The runtime does **not** consume this — it is /// Display duration. The runtime does **not** consume this — it is
/// returned through [`Toast::duration_value`] so the app's timer /// returned through [`Toast::duration_value`] so the app's timer
/// scheduler can read it back. /// scheduler can read it back.
pub duration: Duration, pub( crate ) duration: Duration,
/// Optional message fired when the user taps anywhere outside the /// Optional message fired when the user taps anywhere outside the
/// pill. Convenient for "tap anywhere to dismiss" behaviour. /// pill. Convenient for "tap anywhere to dismiss" behaviour.
pub on_dismiss: Option<Msg>, pub( crate ) on_dismiss: Option<Msg>,
/// Optional override for the pill background colour. /// Optional override for the pill background colour.
pub bg: Option<Color>, pub( crate ) bg: Option<Color>,
/// Optional override for the body text colour. /// Optional override for the body text colour.
pub fg: Option<Color>, pub( crate ) fg: Option<Color>,
} }
impl<Msg: Clone + 'static> Toast<Msg> impl<Msg: Clone + 'static> Toast<Msg>
@@ -165,14 +165,17 @@ impl<Msg: Clone + 'static> Toast<Msg>
let bg = self.bg.unwrap_or_else( theme::bg ); let bg = self.bg.unwrap_or_else( theme::bg );
let fg = self.fg.unwrap_or_else( theme::text ); let fg = self.fg.unwrap_or_else( theme::text );
// Inline path: the pill lays out in the app's own (screen-sized)
// surface and the container auto-sizes to the text, so fluid font /
// padding scale with the screen without any clipping.
let pill: Element<Msg> = container( let pill: Element<Msg> = container(
text( self.message.clone() ) text( self.message.clone() )
.size( theme::FONT_SIZE ) .size( Length::widget( theme::FONT_SIZE ) )
.color( fg ) .color( fg )
) )
.background( bg ) .background( bg )
.padding_h( theme::PAD_H ) .padding_h( Length::widget( theme::PAD_H ) )
.padding_v( 12.0 ) .padding_v( Length::widget( 12.0 ) )
.radius( theme::RADIUS ) .radius( theme::RADIUS )
.into(); .into();
@@ -202,12 +205,12 @@ impl<Msg: Clone + 'static> Toast<Msg>
let pill: Element<Msg> = container( let pill: Element<Msg> = container(
text( self.message.clone() ) text( self.message.clone() )
.size( theme::FONT_SIZE ) .size( Length::widget( theme::FONT_SIZE ) )
.color( fg ) .color( fg )
) )
.background( bg ) .background( bg )
.padding_h( theme::PAD_H ) .padding_h( Length::widget( theme::PAD_H ) )
.padding_v( 12.0 ) .padding_v( Length::widget( 12.0 ) )
.radius( theme::RADIUS ) .radius( theme::RADIUS )
.into(); .into();
@@ -226,7 +229,8 @@ impl<Msg: Clone + 'static> Toast<Msg>
id: overlay_id, id: overlay_id,
layer: Layer::Overlay, layer: Layer::Overlay,
anchor: Anchor::BOTTOM, anchor: Anchor::BOTTOM,
size: ( 0, theme::HEIGHT + theme::BOTTOM_MARGIN as u32 + 24 ), // The surface scales with the display so the fluid pill always fits.
size: ( Length::px( 0.0 ), Length::widget( theme::HEIGHT as f32 + theme::BOTTOM_MARGIN as f32 + 24.0 ) ),
exclusive_zone: 0, exclusive_zone: 0,
keyboard_exclusive: false, keyboard_exclusive: false,
input_region: None, input_region: None,

View File

@@ -38,14 +38,14 @@ pub struct Toggle<Msg: Clone>
{ {
/// Current on / off state. Drawn from this field every frame; the /// Current on / off state. Drawn from this field every frame; the
/// runtime never mutates it. /// runtime never mutates it.
pub value: bool, pub( crate ) value: bool,
/// Message emitted on activation. `None` leaves the toggle inert (it /// Message emitted on activation. `None` leaves the toggle inert (it
/// still renders and takes focus, but does nothing on press). /// still renders and takes focus, but does nothing on press).
pub on_toggle: Option<Msg>, pub( crate ) on_toggle: Option<Msg>,
/// Optional label drawn to the left of the track. /// Optional label drawn to the left of the track.
pub label: Option<String>, pub( crate ) label: Option<String>,
/// Optional stable identifier for focus management. /// Optional stable identifier for focus management.
pub id: Option<WidgetId>, pub( crate ) id: Option<WidgetId>,
} }
impl<Msg: Clone> Toggle<Msg> impl<Msg: Clone> Toggle<Msg>
@@ -92,14 +92,15 @@ impl<Msg: Clone> Toggle<Msg>
/// label is set. Height is the theme-defined row height. /// label is set. Height is the theme-defined row height.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{ {
let track_w = canvas.geom_px( theme::TRACK_W );
let w = if let Some( ref label ) = self.label let w = if let Some( ref label ) = self.label
{ {
let text_w = canvas.measure_text( label, theme::FONT_SIZE ); let text_w = canvas.measure_text( label, canvas.font_px( theme::FONT_SIZE ) );
( text_w + theme::GAP + theme::TRACK_W ).min( max_width ) ( text_w + canvas.geom_px( theme::GAP ) + track_w ).min( max_width )
} else { } else {
theme::TRACK_W.min( max_width ) track_w.min( max_width )
}; };
( w, theme::HEIGHT ) ( w, canvas.geom_px( theme::HEIGHT ) )
} }
/// Bounding box of everything painted at `rect` across all states. The /// Bounding box of everything painted at `rect` across all states. The
@@ -114,43 +115,48 @@ impl<Msg: Clone> Toggle<Msg>
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool ) pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool )
{ {
let track_w = canvas.geom_px( theme::TRACK_W );
let track_h = canvas.geom_px( theme::TRACK_H );
let thumb_size = canvas.geom_px( theme::THUMB_SIZE );
let track_x = if let Some( ref label ) = self.label let track_x = if let Some( ref label ) = self.label
{ {
let text_y = rect.y + ( rect.height + theme::FONT_SIZE ) / 2.0 - 2.0; let fs = canvas.font_px( theme::FONT_SIZE );
canvas.draw_text( label, rect.x, text_y, theme::FONT_SIZE, theme::label_color() ); let text_y = rect.y + ( rect.height + fs ) / 2.0 - 2.0;
rect.x + rect.width - theme::TRACK_W canvas.draw_text( label, rect.x, text_y, fs, theme::label_color() );
rect.x + rect.width - track_w
} else { } else {
rect.x + ( rect.width - theme::TRACK_W ) / 2.0 rect.x + ( rect.width - track_w ) / 2.0
}; };
let track_y = rect.y + ( rect.height - theme::TRACK_H ) / 2.0; let track_y = rect.y + ( rect.height - track_h ) / 2.0;
let track_r = theme::TRACK_H / 2.0; let track_r = track_h / 2.0;
let track_rect = Rect let track_rect = Rect
{ {
x: track_x, x: track_x,
y: track_y, y: track_y,
width: theme::TRACK_W, width: track_w,
height: theme::TRACK_H, height: track_h,
}; };
let track_color = if self.value { theme::track_on() } else { theme::track_off() }; let track_color = if self.value { theme::track_on() } else { theme::track_off() };
canvas.fill_rect( track_rect, track_color, track_r ); canvas.fill_rect( track_rect, track_color, track_r );
let thumb_pad = ( theme::TRACK_H - theme::THUMB_SIZE ) / 2.0; let thumb_pad = ( track_h - thumb_size ) / 2.0;
let thumb_cx = if self.value let thumb_cx = if self.value
{ {
track_x + theme::TRACK_W - thumb_pad - theme::THUMB_SIZE / 2.0 track_x + track_w - thumb_pad - thumb_size / 2.0
} else { } else {
track_x + thumb_pad + theme::THUMB_SIZE / 2.0 track_x + thumb_pad + thumb_size / 2.0
}; };
let thumb_cy = track_y + theme::TRACK_H / 2.0; let thumb_cy = track_y + track_h / 2.0;
let thumb_r = theme::THUMB_SIZE / 2.0; let thumb_r = thumb_size / 2.0;
let thumb_rect = Rect let thumb_rect = Rect
{ {
x: thumb_cx - thumb_r, x: thumb_cx - thumb_r,
y: thumb_cy - thumb_r, y: thumb_cy - thumb_r,
width: theme::THUMB_SIZE, width: thumb_size,
height: theme::THUMB_SIZE, height: thumb_size,
}; };
canvas.fill_rect( thumb_rect, theme::thumb(), thumb_r ); canvas.fill_rect( thumb_rect, theme::thumb(), thumb_r );
canvas.stroke_rect( thumb_rect, theme::thumb_border(), theme::THUMB_BORDER_W, thumb_r ); canvas.stroke_rect( thumb_rect, theme::thumb_border(), theme::THUMB_BORDER_W, thumb_r );

View File

@@ -41,7 +41,7 @@ use std::collections::hash_map::DefaultHasher;
use std::hash::{ Hash, Hasher }; use std::hash::{ Hash, Hasher };
use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec }; use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec };
use crate::types::{ Color, WidgetId }; use crate::types::{ Color, Length, WidgetId };
use super::Element; use super::Element;
mod theme; mod theme;
@@ -53,20 +53,20 @@ mod tests;
pub struct Tooltip<Msg: Clone> pub struct Tooltip<Msg: Clone>
{ {
/// Body text rendered inside the tooltip. /// Body text rendered inside the tooltip.
pub message: String, pub( crate ) message: String,
/// Stable identifier of the widget the popup anchors to. The widget /// Stable identifier of the widget the popup anchors to. The widget
/// must have been built with `.id( anchor_id )` so the runtime can /// must have been built with `.id( anchor_id )` so the runtime can
/// look its rect up in the previous frame's layout snapshot. /// look its rect up in the previous frame's layout snapshot.
pub anchor_id: WidgetId, pub( crate ) anchor_id: WidgetId,
/// Maximum width (logical pixels). The tooltip is single-line; long /// Maximum width (logical pixels). The tooltip is single-line; long
/// strings are clipped at the surface boundary. /// strings are clipped at the surface boundary.
pub max_width: u32, pub( crate ) max_width: u32,
/// Optional override for the tooltip background colour. /// Optional override for the tooltip background colour.
pub bg: Option<Color>, pub( crate ) bg: Option<Color>,
/// Optional override for the body text colour. /// Optional override for the body text colour.
pub fg: Option<Color>, pub( crate ) fg: Option<Color>,
/// Optional message fired when the user taps outside the popup. /// Optional message fired when the user taps outside the popup.
pub on_dismiss: Option<Msg>, pub( crate ) on_dismiss: Option<Msg>,
} }
impl<Msg: Clone + 'static> Tooltip<Msg> impl<Msg: Clone + 'static> Tooltip<Msg>
@@ -129,12 +129,12 @@ impl<Msg: Clone + 'static> Tooltip<Msg>
let body: Element<Msg> = container( let body: Element<Msg> = container(
text( self.message.clone() ) text( self.message.clone() )
.size( theme::FONT_SIZE ) .size( Length::widget( theme::FONT_SIZE ) )
.color( fg ) .color( fg )
) )
.background( bg ) .background( bg )
.padding_h( theme::PAD_H ) .padding_h( Length::widget( theme::PAD_H ) )
.padding_v( theme::PAD_V ) .padding_v( Length::widget( theme::PAD_V ) )
.radius( theme::RADIUS ) .radius( theme::RADIUS )
.into(); .into();
@@ -143,7 +143,9 @@ impl<Msg: Clone + 'static> Tooltip<Msg>
id: overlay_id, id: overlay_id,
layer: Layer::Overlay, layer: Layer::Overlay,
anchor: Anchor::TOP, anchor: Anchor::TOP,
size: ( self.max_width, theme::HEIGHT ), // Height scales with the display so the fluid text always fits; the
// caller-set max width stays fixed so wrapping is predictable.
size: ( Length::px( self.max_width as f32 ), Length::widget( theme::HEIGHT as f32 ) ),
exclusive_zone: 0, exclusive_zone: 0,
keyboard_exclusive: false, keyboard_exclusive: false,
input_region: None, input_region: None,

View File

@@ -12,7 +12,7 @@ use crate::widget::Element;
pub struct Viewport<Msg: Clone> pub struct Viewport<Msg: Clone>
{ {
/// The child element to render inside the viewport. /// The child element to render inside the viewport.
pub child: Box<Element<Msg>>, pub( crate ) child: Box<Element<Msg>>,
/// Optional fixed width in logical pixels. When omitted the /// Optional fixed width in logical pixels. When omitted the
/// viewport reports `max_width` as its preferred width — i.e. it /// viewport reports `max_width` as its preferred width — i.e. it
/// stretches to fill whatever horizontal slice the parent layout /// stretches to fill whatever horizontal slice the parent layout
@@ -21,15 +21,15 @@ pub struct Viewport<Msg: Clone>
/// the viewport's `max_width` claim) or whenever the inner content /// the viewport's `max_width` claim) or whenever the inner content
/// has its own intrinsic width and the viewport is just a vertical /// has its own intrinsic width and the viewport is just a vertical
/// clip. /// clip.
pub width: Option<f32>, pub( crate ) width: Option<f32>,
/// Optional fixed height in logical pixels. When omitted the viewport /// Optional fixed height in logical pixels. When omitted the viewport
/// reports the child's natural height. /// reports the child's natural height.
pub height: Option<f32>, pub( crate ) height: Option<f32>,
/// Logical pixels at the bottom edge that fade to transparent during the /// Logical pixels at the bottom edge that fade to transparent during the
/// blit. Zero leaves the bottom hard-edged. Useful for slide-in panels so /// blit. Zero leaves the bottom hard-edged. Useful for slide-in panels so
/// the leading edge of the animation does not knife-cut against the layer /// the leading edge of the animation does not knife-cut against the layer
/// below it. /// below it.
pub fade_bottom: f32, pub( crate ) fade_bottom: f32,
} }
impl<Msg: Clone> Viewport<Msg> impl<Msg: Clone> Viewport<Msg>

View File

@@ -35,8 +35,8 @@ pub fn value_from_y_in_rect( rect: Rect, y: f32 ) -> f32
/// offers — it is intrinsically sized, not filler. /// offers — it is intrinsically sized, not filler.
/// ///
/// The widget renders a rounded track in `palette.surface_alt` and, on top, /// The widget renders a rounded track in `palette.surface_alt` and, on top,
/// a rising pill in `palette.accent` whose height is proportional to /// a rising pill in `palette.accent` whose height is proportional to its
/// [`VSlider::value`]. No separate thumb is drawn; the top edge of the fill /// `value`. No separate thumb is drawn; the top edge of the fill
/// itself acts as the value indicator. /// itself acts as the value indicator.
/// ///
/// ```rust,no_run /// ```rust,no_run
@@ -64,25 +64,25 @@ pub struct VSlider<Msg: Clone>
{ {
/// Current value in `[0.0, 1.0]`. `0.0` paints no fill; `1.0` fills the /// Current value in `[0.0, 1.0]`. `0.0` paints no fill; `1.0` fills the
/// whole pill. /// whole pill.
pub value: f32, pub( crate ) value: f32,
/// Width of the pill. Defaults to 56 px; accepts any [`Length`]. /// Width of the pill. Defaults to 56 px; accepts any [`Length`].
pub width: Length, pub( crate ) width: Length,
/// Height of the pill. Defaults to 160 px; accepts any [`Length`]. /// Height of the pill. Defaults to 160 px; accepts any [`Length`].
pub height: Length, pub( crate ) height: Length,
/// Callback invoked with the new value when the slider is tapped or /// Callback invoked with the new value when the slider is tapped or
/// dragged. `Arc` (not `Box`) so the layout pass can clone it into the /// dragged. `Arc` (not `Box`) so the layout pass can clone it into the
/// per-leaf handler snapshot for O(1) dispatch on input events. /// per-leaf handler snapshot for O(1) dispatch on input events.
pub on_change: Option<Arc<dyn Fn(f32) -> Msg>>, pub( crate ) on_change: Option<Arc<dyn Fn(f32) -> Msg>>,
/// Theme slot id for the unfilled track. Defaults to /// Theme slot id for the unfilled track. Defaults to
/// `surface-slider-track`. Override with [`VSlider::track_surface`] /// `surface-slider-track`. Override with [`VSlider::track_surface`]
/// when the slider lives inside a panel that already provides its /// when the slider lives inside a panel that already provides its
/// own backdrop blur — point the slot at a `*-flat` variant /// own backdrop blur — point the slot at a `*-flat` variant
/// (no `backdrop` field) so the pipeline does not run a redundant /// (no `backdrop` field) so the pipeline does not run a redundant
/// backdrop snapshot per slider per frame. /// backdrop snapshot per slider per frame.
pub track_surface: &'static str, pub( crate ) track_surface: &'static str,
/// Theme slot id for the filled portion. Same role as /// Theme slot id for the filled portion. Same role as
/// [`Self::track_surface`] but for the rising fill. /// [`Self::track_surface`] but for the rising fill.
pub fill_surface: &'static str, pub( crate ) fill_surface: &'static str,
} }
impl<Msg: Clone> VSlider<Msg> impl<Msg: Clone> VSlider<Msg>
@@ -93,8 +93,8 @@ impl<Msg: Clone> VSlider<Msg>
Self Self
{ {
value: value.clamp( 0.0, 1.0 ), value: value.clamp( 0.0, 1.0 ),
width: Length::px( theme::WIDTH ), width: Length::widget( theme::WIDTH ),
height: Length::px( theme::HEIGHT ), height: Length::widget( theme::HEIGHT ),
on_change: None, on_change: None,
track_surface: theme::SURFACE_TRACK, track_surface: theme::SURFACE_TRACK,
fill_surface: theme::SURFACE_FILL, fill_surface: theme::SURFACE_FILL,

View File

@@ -88,20 +88,23 @@ fn preferred_size_ignores_max_width()
{ {
// A VSlider is intrinsically sized — the parent's max_width doesn't // A VSlider is intrinsically sized — the parent's max_width doesn't
// change what we return. // change what we return.
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
let canvas = make_canvas(); let canvas = make_canvas();
let s: VSlider<()> = vslider( 0.5 ); let s: VSlider<()> = vslider( 0.5 );
let ( w_small, _ ) = s.preferred_size( 10.0, &canvas ); let ( w_small, _ ) = s.preferred_size( 10.0, &canvas );
let ( w_big, _ ) = s.preferred_size( 9_999.0, &canvas ); let ( w_big, _ ) = s.preferred_size( 9_999.0, &canvas );
assert_eq!( w_small, theme::WIDTH ); // The intrinsic width now follows the widget-scaling mode.
assert_eq!( w_big, theme::WIDTH ); assert_eq!( w_small, canvas.geom_px( theme::WIDTH ) );
assert_eq!( w_big, canvas.geom_px( theme::WIDTH ) );
} }
#[ test ] #[ test ]
fn default_dimensions_are_the_theme_constants() fn default_dimensions_follow_widget_scaling()
{ {
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
let s: VSlider<()> = vslider( 0.0 ); let s: VSlider<()> = vslider( 0.0 );
assert_eq!( s.width, Length::px( theme::WIDTH ) ); assert_eq!( s.width, Length::widget( theme::WIDTH ) );
assert_eq!( s.height, Length::px( theme::HEIGHT ) ); assert_eq!( s.height, Length::widget( theme::HEIGHT ) );
} }
#[ test ] #[ test ]

View File

@@ -38,23 +38,24 @@ pub enum WindowButtonKind
pub struct WindowButton<Msg: Clone> pub struct WindowButton<Msg: Clone>
{ {
/// Which decoration role this button paints. /// Which decoration role this button paints.
pub kind: WindowButtonKind, pub( crate ) kind: WindowButtonKind,
/// Message emitted on activation. `None` greys the button and skips /// Message emitted on activation. `None` greys the button and skips
/// the hover / pressed surface — useful for "maximize disabled" on /// the hover / pressed surface — useful for "maximize disabled" on
/// fixed-size windows. /// fixed-size windows.
pub on_press: Option<Msg>, pub( crate ) on_press: Option<Msg>,
/// Square hit-target size in logical pixels. Clamped to a 20 px floor /// Square hit-target size in logical pixels. An explicit [`Self::size`]
/// by [`Self::size`] so the button stays touchable. /// is clamped to a 20 px floor so the button stays touchable; the `0.0`
pub size: f32, /// default follows the process [`crate::WidgetScaling`] mode.
pub( crate ) size: f32,
/// Optional stable identifier for focus management. /// Optional stable identifier for focus management.
pub id: Option<WidgetId>, pub( crate ) id: Option<WidgetId>,
/// Whether this button takes part in the Tab / Shift+Tab cycle. Defaults /// Whether this button takes part in the Tab / Shift+Tab cycle. Defaults
/// to `false` to match desktop convention — title-bar chrome on macOS, /// to `false` to match desktop convention — title-bar chrome on macOS,
/// GNOME and Windows is click/touch-only and never steals keyboard focus /// GNOME and Windows is click/touch-only and never steals keyboard focus
/// from window content. Opt in with [`Self::focusable`] for shells where /// from window content. Opt in with [`Self::focusable`] for shells where
/// keyboard reachability of decorations matters (accessibility, no-mouse /// keyboard reachability of decorations matters (accessibility, no-mouse
/// kiosks). Pointer / touch hit testing is unaffected by this flag. /// kiosks). Pointer / touch hit testing is unaffected by this flag.
pub focusable: bool, pub( crate ) focusable: bool,
} }
impl<Msg: Clone> WindowButton<Msg> impl<Msg: Clone> WindowButton<Msg>
@@ -67,7 +68,7 @@ impl<Msg: Clone> WindowButton<Msg>
{ {
kind, kind,
on_press: None, on_press: None,
size: theme::SIZE, size: 0.0,
id: None, id: None,
focusable: false, focusable: false,
} }
@@ -112,9 +113,10 @@ impl<Msg: Clone> WindowButton<Msg>
self self
} }
pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> ( f32, f32 ) pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{ {
let s = self.size.min( max_width ); let resolved = if self.size > 0.0 { self.size } else { canvas.geom_px( theme::SIZE ) };
let s = resolved.min( max_width );
( s, s ) ( s, s )
} }

View File

@@ -5,11 +5,13 @@ use super::*;
use crate::render::Canvas; use crate::render::Canvas;
#[ test ] #[ test ]
fn default_size_is_decoration_sized() fn default_size_follows_widget_scaling()
{ {
let canvas = Canvas::new( 100, 100 ); let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
let canvas = Canvas::new( 412, 412 );
let b = window_button::<()>( WindowButtonKind::Close ); let b = window_button::<()>( WindowButtonKind::Close );
assert_eq!( b.preferred_size( 100.0, &canvas ), ( theme::SIZE, theme::SIZE ) ); let s = canvas.geom_px( theme::SIZE );
assert_eq!( b.preferred_size( 1000.0, &canvas ), ( s, s ) );
} }
#[ test ] #[ test ]

View File

@@ -1,3 +1,5 @@
#![ cfg( feature = "test-support" ) ]
// Animation lifecycle coverage. The runtime polls `App::is_animating()` once // Animation lifecycle coverage. The runtime polls `App::is_animating()` once
// per frame and, while it returns `true`, redraws at ~60 Hz reading // per frame and, while it returns `true`, redraws at ~60 Hz reading
// `Instant::now()` against a stored start time. These tests exercise that // `Instant::now()` against a stored start time. These tests exercise that

View File

@@ -1,3 +1,5 @@
#![ cfg( feature = "test-support" ) ]
//! Integration tests for [`ltk::Element::map`] — the Elm-style adapter //! Integration tests for [`ltk::Element::map`] — the Elm-style adapter
//! that re-tags every per-leaf message in a sub-tree. //! that re-tags every per-leaf message in a sub-tree.
//! //!

View File

@@ -1,3 +1,5 @@
#![ cfg( feature = "test-support" ) ]
// End-to-end coverage for the runtime contract: `Msg → App::update → next // End-to-end coverage for the runtime contract: `Msg → App::update → next
// view → render`. The Wayland event loop in `ltk::run` is the integration // view → render`. The Wayland event loop in `ltk::run` is the integration
// point that ties widget-level handler snapshots, focus traversal and keysym // point that ties widget-level handler snapshots, focus traversal and keysym

View File

@@ -2,7 +2,7 @@
// the way `Spacer` distributes leftover space inside a `Column`. // the way `Spacer` distributes leftover space inside a `Column`.
use ltk::core::Canvas; use ltk::core::Canvas;
use ltk::{ stack, spacer, HAlign, Length, VAlign, Rect }; use ltk::{ stack, spacer, HAlign, VAlign, Rect };
fn make_canvas() -> Canvas fn make_canvas() -> Canvas
{ {
@@ -178,42 +178,29 @@ fn stack_empty_preferred_size_height_is_zero()
// ── Spacer: builder semantics ───────────────────────────────────────────────── // ── Spacer: builder semantics ─────────────────────────────────────────────────
// Builder-field storage (weight / fixed dimensions) is covered by internal
// unit tests in `src/layout/spacer.rs`; here we exercise only the public
// observable — `preferred_size` — which reflects those fields.
#[ test ] #[ test ]
fn spacer_default_has_weight_one_and_no_fixed_size() fn spacer_default_has_no_fixed_size()
{ {
let canvas = make_canvas(); let canvas = make_canvas();
let s = spacer(); assert_eq!( spacer().preferred_size( &canvas ), ( 0.0, 0.0 ) );
assert_eq!( s.weight, 1 );
assert!( s.fixed_height.is_none() );
assert!( s.fixed_width.is_none() );
assert_eq!( s.preferred_size( &canvas ), ( 0.0, 0.0 ) );
} }
#[ test ] #[ test ]
fn spacer_height_pins_vertical_axis_only() fn spacer_height_pins_vertical_axis_only()
{ {
let canvas = make_canvas(); let canvas = make_canvas();
let s = spacer().height( 24.0 ); assert_eq!( spacer().height( 24.0 ).preferred_size( &canvas ), ( 0.0, 24.0 ) );
assert_eq!( s.fixed_height, Some( Length::px( 24.0 ) ) );
assert!( s.fixed_width.is_none() );
assert_eq!( s.preferred_size( &canvas ), ( 0.0, 24.0 ) );
} }
#[ test ] #[ test ]
fn spacer_width_pins_horizontal_axis_only() fn spacer_width_pins_horizontal_axis_only()
{ {
let canvas = make_canvas(); let canvas = make_canvas();
let s = spacer().width( 12.0 ); assert_eq!( spacer().width( 12.0 ).preferred_size( &canvas ), ( 12.0, 0.0 ) );
assert_eq!( s.fixed_width, Some( Length::px( 12.0 ) ) );
assert!( s.fixed_height.is_none() );
assert_eq!( s.preferred_size( &canvas ), ( 12.0, 0.0 ) );
}
#[ test ]
fn spacer_weight_replaces_default()
{
let s = spacer().weight( 5 );
assert_eq!( s.weight, 5 );
} }
// ── Spacer in Column: leftover distribution ─────────────────────────────────── // ── Spacer in Column: leftover distribution ───────────────────────────────────

View File

@@ -1,3 +1,5 @@
#![ cfg( feature = "test-support" ) ]
use ltk::test_support::diff_overlay_ids; use ltk::test_support::diff_overlay_ids;
use ltk::OverlayId; use ltk::OverlayId;

View File

@@ -1,4 +1,6 @@
use ltk::test_support::value_from_x_in_rect; #![ cfg( feature = "test-support" ) ]
use ltk::test_support::{ value_from_x_in_rect, thumb_design_px };
use ltk::Rect; use ltk::Rect;
fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect
@@ -6,20 +8,24 @@ fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect
Rect { x, y, width: w, height: h } Rect { x, y, width: w, height: h }
} }
/// The thumb size the pure math is exercised with — the design constant, so
/// the pad matches what the renderer draws with an unscaled thumb.
fn thumb() -> f32 { thumb_design_px() }
#[ test ] #[ test ]
fn left_edge_clamps_to_zero() fn left_edge_clamps_to_zero()
{ {
let r = rect( 0.0, 0.0, 200.0, 36.0 ); let r = rect( 0.0, 0.0, 200.0, 36.0 );
assert_eq!( value_from_x_in_rect( r, 0.0 ), 0.0 ); assert_eq!( value_from_x_in_rect( r, 0.0, thumb() ), 0.0 );
assert_eq!( value_from_x_in_rect( r, -50.0 ), 0.0 ); assert_eq!( value_from_x_in_rect( r, -50.0, thumb() ), 0.0 );
} }
#[ test ] #[ test ]
fn right_edge_clamps_to_one() fn right_edge_clamps_to_one()
{ {
let r = rect( 0.0, 0.0, 200.0, 36.0 ); let r = rect( 0.0, 0.0, 200.0, 36.0 );
assert_eq!( value_from_x_in_rect( r, 200.0 ), 1.0 ); assert_eq!( value_from_x_in_rect( r, 200.0, thumb() ), 1.0 );
assert_eq!( value_from_x_in_rect( r, 999.0 ), 1.0 ); assert_eq!( value_from_x_in_rect( r, 999.0, thumb() ), 1.0 );
} }
#[ test ] #[ test ]
@@ -28,7 +34,7 @@ fn center_returns_half()
// The thumb adds padding on both sides, so the geometric center of the // The thumb adds padding on both sides, so the geometric center of the
// rect produces ~0.5 (within the THUMB_SIZE/2 tolerance). // rect produces ~0.5 (within the THUMB_SIZE/2 tolerance).
let r = rect( 0.0, 0.0, 200.0, 36.0 ); let r = rect( 0.0, 0.0, 200.0, 36.0 );
let v = value_from_x_in_rect( r, 100.0 ); let v = value_from_x_in_rect( r, 100.0, thumb() );
assert!( ( v - 0.5 ).abs() < 0.1, "expected ~0.5 got {}", v ); assert!( ( v - 0.5 ).abs() < 0.1, "expected ~0.5 got {}", v );
} }
@@ -36,9 +42,9 @@ fn center_returns_half()
fn rect_offset_translates_correctly() fn rect_offset_translates_correctly()
{ {
let r = rect( 500.0, 0.0, 200.0, 36.0 ); let r = rect( 500.0, 0.0, 200.0, 36.0 );
assert_eq!( value_from_x_in_rect( r, 500.0 ), 0.0 ); assert_eq!( value_from_x_in_rect( r, 500.0, thumb() ), 0.0 );
assert_eq!( value_from_x_in_rect( r, 700.0 ), 1.0 ); assert_eq!( value_from_x_in_rect( r, 700.0, thumb() ), 1.0 );
let v = value_from_x_in_rect( r, 600.0 ); let v = value_from_x_in_rect( r, 600.0, thumb() );
assert!( ( v - 0.5 ).abs() < 0.1, "expected ~0.5 got {}", v ); assert!( ( v - 0.5 ).abs() < 0.1, "expected ~0.5 got {}", v );
} }
@@ -48,7 +54,7 @@ fn output_is_always_in_unit_range()
let r = rect( 10.0, 10.0, 300.0, 36.0 ); let r = rect( 10.0, 10.0, 300.0, 36.0 );
for x in -100i32 ..= 500 for x in -100i32 ..= 500
{ {
let v = value_from_x_in_rect( r, x as f32 ); let v = value_from_x_in_rect( r, x as f32, thumb() );
assert!( ( 0.0..= 1.0 ).contains( &v ), "v={} out of range at x={}", v, x ); assert!( ( 0.0..= 1.0 ).contains( &v ), "v={} out of range at x={}", v, x );
} }
} }
@@ -59,7 +65,7 @@ fn very_narrow_rect_does_not_divide_by_zero()
// Width < THUMB_SIZE — track_w is clamped to 1.0 internally so the // Width < THUMB_SIZE — track_w is clamped to 1.0 internally so the
// formula stays defined. // formula stays defined.
let r = rect( 0.0, 0.0, 4.0, 36.0 ); let r = rect( 0.0, 0.0, 4.0, 36.0 );
let v = value_from_x_in_rect( r, 2.0 ); let v = value_from_x_in_rect( r, 2.0, thumb() );
assert!( v.is_finite() ); assert!( v.is_finite() );
assert!( ( 0.0..= 1.0 ).contains( &v ) ); assert!( ( 0.0..= 1.0 ).contains( &v ) );
} }

View File

@@ -1,3 +1,5 @@
#![ cfg( feature = "test-support" ) ]
mod common; mod common;
use common::{ lw_none, rect }; use common::{ lw_none, rect };

View File

@@ -1,3 +1,5 @@
#![ cfg( feature = "test-support" ) ]
// End-to-end coverage for the `text_edit` ↔ event-loop contract: the layout // End-to-end coverage for the `text_edit` ↔ event-loop contract: the layout
// pass snapshots the widget's `on_change` / `on_submit` / `value` into a // pass snapshots the widget's `on_change` / `on_submit` / `value` into a
// `WidgetHandlers::TextEdit` entry, and the runtime drives keystrokes through // `WidgetHandlers::TextEdit` entry, and the runtime drives keystrokes through

View File

@@ -1,3 +1,5 @@
#![ cfg( feature = "test-support" ) ]
mod common; mod common;
use common::{ lw_none, lw_button, rect }; use common::{ lw_none, lw_button, rect };

View File

@@ -1,7 +1,10 @@
#![ cfg( feature = "test-support" ) ]
use ltk::test_support:: use ltk::test_support::
{ {
value_from_y_in_rect, value_from_y_in_rect,
value_from_pos_in_rect, value_from_pos_in_rect,
thumb_design_px,
SliderAxis, SliderAxis,
}; };
use ltk::{ Point, Rect }; use ltk::{ Point, Rect };
@@ -90,6 +93,7 @@ fn value_from_pos_dispatches_horizontal()
r, r,
Point { x: 200.0, y: 0.0 }, Point { x: 200.0, y: 0.0 },
SliderAxis::Horizontal, SliderAxis::Horizontal,
thumb_design_px(),
); );
assert_eq!( v, 1.0 ); assert_eq!( v, 1.0 );
} }
@@ -102,6 +106,7 @@ fn value_from_pos_dispatches_vertical()
r, r,
Point { x: 9_999.0, y: 0.0 }, Point { x: 9_999.0, y: 0.0 },
SliderAxis::Vertical, SliderAxis::Vertical,
thumb_design_px(),
); );
assert_eq!( v, 1.0 ); assert_eq!( v, 1.0 );
} }
@@ -111,9 +116,9 @@ fn value_from_pos_axes_are_independent()
{ {
let r = rect( 0.0, 0.0, 200.0, 200.0 ); let r = rect( 0.0, 0.0, 200.0, 200.0 );
let h = value_from_pos_in_rect( let h = value_from_pos_in_rect(
r, Point { x: 100.0, y: 0.0 }, SliderAxis::Horizontal ); r, Point { x: 100.0, y: 0.0 }, SliderAxis::Horizontal, thumb_design_px() );
let v = value_from_pos_in_rect( let v = value_from_pos_in_rect(
r, Point { x: 0.0, y: 100.0 }, SliderAxis::Vertical ); r, Point { x: 0.0, y: 100.0 }, SliderAxis::Vertical, thumb_design_px() );
assert!( ( h - 0.5 ).abs() < 0.1 ); assert!( ( h - 0.5 ).abs() < 0.1 );
assert!( ( v - 0.5 ).abs() < 1e-6 ); assert!( ( v - 0.5 ).abs() < 1e-6 );
} }

View File

@@ -1,3 +1,5 @@
#![ cfg( feature = "test-support" ) ]
// Handler-level dispatch coverage for the interactive widgets that aren't // Handler-level dispatch coverage for the interactive widgets that aren't
// buttons or text edits: toggle, checkbox, radio and slider. Each test renders // buttons or text edits: toggle, checkbox, radio and slider. Each test renders
// the widget through `UiSurface`, locates its `WidgetHandlers` snapshot and // the widget through `UiSurface`, locates its `WidgetHandlers` snapshot and