From ce893ac776c932e3729bd6c087743b399bbcd61e Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Tue, 7 Jul 2026 17:40:33 +0200 Subject: [PATCH] =?UTF-8?q?responsive=20fluid/physical=20scaling,=20widget?= =?UTF-8?q?-API=20stabilization,=20and=20perf=20guardrails=20Responsive=20?= =?UTF-8?q?scaling.=20ltk=20now=20offers=20two=20first-class=20ways=20to?= =?UTF-8?q?=20size=20a=20UI=20so=20it=20adapts=20across=20screens,=20chose?= =?UTF-8?q?n=20per=20process=20via=20`WidgetScaling=20{=20Fluid,=20Physica?= =?UTF-8?q?l=20}`=20(`set=5Fwidget=5Fscaling`=20/=20`widget=5Fscaling`,=20?= =?UTF-8?q?default=20`Fluid`).=20Fluid=20sizing=20(`Length::fluid(=20px=20?= =?UTF-8?q?)`)=20makes=20a=20design=20pixel=20a=20proportion=20of=20the=20?= =?UTF-8?q?surface's=20smaller=20side,=20calibrated=20against=20a=20refere?= =?UTF-8?q?nce=20width=20(`set=5Ffluid=5Freference`=20/=20`fluid=5Freferen?= =?UTF-8?q?ce`,=20412=20px=20default)=20and=20bounded=20by=20`FLUID=5FMIN`?= =?UTF-8?q?=20/=20`FLUID=5FMAX`;=20physical=20sizing=20(`Length::dp(=20px?= =?UTF-8?q?=20)`)=20is=20a=20constant-physical-size=20pixel=20scaled=20by?= =?UTF-8?q?=20display=20density=20(`set=5Fdensity`=20/=20`density`).=20`Le?= =?UTF-8?q?ngth`=20gains=20`orient(=20portrait,=20landscape=20)`=20?= =?UTF-8?q?=E2=80=94=20resolve=20one=20value=20in=20portrait,=20another=20?= =?UTF-8?q?in=20landscape=20=E2=80=94=20plus=20`widget(=20px=20)`,=20which?= =?UTF-8?q?=20picks=20fluid=20or=20dp=20per=20the=20active=20mode.=20Canva?= =?UTF-8?q?s=20exposes=20`geom=5Fpx`=20(geometry,=20resolved=20in=20physic?= =?UTF-8?q?al=20layout=20space)=20and=20`font=5Fpx`=20(font=20size,=20brid?= =?UTF-8?q?ging=20logical=20/=20physical=20per=20mode)=20so=20widgets=20an?= =?UTF-8?q?d=20apps=20share=20one=20resolution=20path.=20Note=20the=20rena?= =?UTF-8?q?me:=20`set=5Fdesign=5Freference`=20/=20`design=5Freference`=20b?= =?UTF-8?q?ecame=20`set=5Ffluid=5Freference`=20/=20`fluid=5Freference`,=20?= =?UTF-8?q?and=20`Length::dp`=20changed=20meaning=20=E2=80=94=20the=20old?= =?UTF-8?q?=20surface-proportional=20behaviour=20now=20lives=20on=20`Lengt?= =?UTF-8?q?h::fluid`.=20Widgets.=20Every=20stock=20widget=20resolves=20its?= =?UTF-8?q?=20default=20geometry=20and=20font=20through=20the=20widget-sca?= =?UTF-8?q?ling=20mode=20instead=20of=20frozen=20pixels,=20so=20a=20whole?= =?UTF-8?q?=20UI=20scales=20coherently=20without=20per-call=20units.=20New?= =?UTF-8?q?=20size=20builders=20where=20they=20were=20missing:=20`button`?= =?UTF-8?q?=20gains=20`font=5Fsize`=20/=20`height`,=20`text=5Fedit`=20gain?= =?UTF-8?q?s=20`height`=20/=20`font=5Fsize=5Ffluid`,=20`separator`=20gains?= =?UTF-8?q?=20`pad=5Fv`,=20and=20assorted=20widgets=20accept=20a=20`Length?= =?UTF-8?q?`=20where=20they=20previously=20took=20only=20`f32`.=20Overlays?= =?UTF-8?q?.=20`OverlaySpec::size`=20is=20now=20`(=20Length,=20Length=20)`?= =?UTF-8?q?=20instead=20of=20`(=20u32,=20u32=20)`,=20resolved=20against=20?= =?UTF-8?q?the=20main=20surface=20when=20the=20overlay=20is=20materialized?= =?UTF-8?q?,=20so=20overlays=20can=20scale=20with=20the=20display;=20`Leng?= =?UTF-8?q?th::px(=20=E2=80=A6=20)`=20reproduces=20the=20old=20fixed=20siz?= =?UTF-8?q?ing.=20API=20stabilization=20(toward=201.0).=20Widget=20struct?= =?UTF-8?q?=20fields=20are=20now=20`pub(=20crate=20)`=20=E2=80=94=20they?= =?UTF-8?q?=20are=20configured=20through=20builders,=20not=20field=20acces?= =?UTF-8?q?s=20=E2=80=94=20except=20the=20value=20/=20state=20types=20apps?= =?UTF-8?q?=20genuinely=20read=20or=20construct=20(`Time`,=20`Date`,=20`Co?= =?UTF-8?q?mboState`),=20which=20stay=20public.=20The=20internal=20`test?= =?UTF-8?q?=5Fsupport`=20helpers=20move=20behind=20a=20`test-support`=20Ca?= =?UTF-8?q?rgo=20feature=20(off=20by=20default,=20so=20third-party=20build?= =?UTF-8?q?s=20never=20see=20them;=20ltk's=20own=20`make=20test`=20enables?= =?UTF-8?q?=20it).=20`Separator`=20drops=20its=20`0.0`-means-mode=20sentin?= =?UTF-8?q?el=20for=20`Option`,=20so=20an=20explicit=20`pad=5Fv(?= =?UTF-8?q?=200.0=20)`=20is=20a=20real=20flush=20divider=20distinct=20from?= =?UTF-8?q?=20the=20mode-following=20default.=20Performance=20guardrails.?= =?UTF-8?q?=20Opt-in=20diagnostics=20via=20`LTK=5FPERF=5FWARN=3D1`=20warn?= =?UTF-8?q?=20about=20stuck=20animations,=20sustained=20software-render=20?= =?UTF-8?q?animation,=20and=20low=20`poll=5Finterval`;=20software-rendered?= =?UTF-8?q?=20animation=20is=20capped=20near=2030=20Hz=20to=20spare=20CPU?= =?UTF-8?q?=20on=20machines=20that=20fall=20back=20off=20EGL.=20Apps=20can?= =?UTF-8?q?=20override=20the=20cap=20with=20`App::cap=5Fsoftware=5Fanimati?= =?UTF-8?q?on`.=20Docs=20and=20build.=20The=20two=20scaling=20modes=20are?= =?UTF-8?q?=20documented=20in=20README,=20onboarding=20and=20architecture,?= =?UTF-8?q?=20with=20the=20earlier=20gradient=20/=20backdrop=20doc=20drift?= =?UTF-8?q?=20cleaned=20up.=20The=20Makefile=20now=20ships=20the=20`locale?= =?UTF-8?q?s/`=20directory=20into=20the=20packaged=20crate=20(fixing=20i18?= =?UTF-8?q?n=20keys=20rendering=20raw=20for=20downstreams),=20builds=20the?= =?UTF-8?q?=20new=20`responsive`=20example,=20and=20runs=20tests=20with=20?= =?UTF-8?q?`--features=20test-support`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 7 + Makefile | 5 +- README.md | 33 +++- docs/architecture.md | 17 +- docs/cookbook.md | 52 +++++- docs/onboarding.md | 22 +++ examples/mini_shell.rs | 8 +- examples/responsive.rs | 156 ++++++++++++++++++ examples/showcase.rs | 14 +- src/app.rs | 30 ++-- src/draw/layout.rs | 11 +- src/event_loop/app_data.rs | 3 + src/event_loop/handlers.rs | 23 ++- src/event_loop/invalidation.rs | 5 + src/event_loop/mod.rs | 2 + src/event_loop/overlays_reconcile.rs | 31 +++- src/event_loop/perf.rs | 173 +++++++++++++++++++ src/event_loop/run.rs | 2 + src/event_loop/tooltip.rs | 4 +- src/layout/column.rs | 14 +- src/layout/row.rs | 8 +- src/layout/spacer.rs | 25 ++- src/layout/stack.rs | 4 +- src/layout/wrap_grid.rs | 12 +- src/lib.rs | 135 ++++++++++++--- src/render/mod.rs | 80 ++++++++- src/types.rs | 238 +++++++++++++++++++++++++-- src/widget/anchored_overlay/mod.rs | 6 +- src/widget/button/mod.rs | 116 ++++++++++--- src/widget/button/tests.rs | 51 ++++++ src/widget/carousel/mod.rs | 10 +- src/widget/checkbox/mod.rs | 37 +++-- src/widget/color_picker/mod.rs | 20 +-- src/widget/combo/mod.rs | 4 +- src/widget/container/mod.rs | 26 +-- src/widget/date_picker/mod.rs | 18 +- src/widget/dialog/mod.rs | 16 +- src/widget/element.rs | 12 +- src/widget/external/mod.rs | 8 +- src/widget/flex/mod.rs | 4 +- src/widget/handlers.rs | 24 ++- src/widget/image/mod.rs | 49 +++++- src/widget/image/tests.rs | 20 +++ src/widget/list_item/mod.rs | 55 ++++--- src/widget/notebook/mod.rs | 13 +- src/widget/pressable/mod.rs | 16 +- src/widget/progress_bar/mod.rs | 17 +- src/widget/progress_bar/tests.rs | 2 +- src/widget/radio/mod.rs | 40 +++-- src/widget/rich_text/mod.rs | 18 +- src/widget/scroll/mod.rs | 6 +- src/widget/separator/mod.rs | 60 +++++-- src/widget/separator/tests.rs | 22 ++- src/widget/slider/mod.rs | 65 +++++--- src/widget/slider/tests.rs | 17 ++ src/widget/spinner/mod.rs | 24 ++- src/widget/spinner/tests.rs | 9 +- src/widget/tab_bar/mod.rs | 12 +- src/widget/text/mod.rs | 14 +- src/widget/text_edit/draw.rs | 2 +- src/widget/text_edit/hit_test.rs | 3 + src/widget/text_edit/mod.rs | 118 +++++++++---- src/widget/text_edit/tests.rs | 38 ++++- src/widget/time_picker/mod.rs | 27 +-- src/widget/toast/mod.rs | 32 ++-- src/widget/toggle/mod.rs | 52 +++--- src/widget/tooltip/mod.rs | 24 +-- src/widget/viewport/mod.rs | 8 +- src/widget/vslider/mod.rs | 20 +-- src/widget/vslider/tests.rs | 13 +- src/widget/window_button/mod.rs | 22 +-- src/widget/window_button/tests.rs | 8 +- tests/animation.rs | 2 + tests/element_map.rs | 2 + tests/event_loop_flow.rs | 2 + tests/layout_stack_spacer.rs | 31 +--- tests/overlay_reconciliation.rs | 2 + tests/slider_math.rs | 28 ++-- tests/tab_navigation.rs | 2 + tests/text_edit_dispatch.rs | 2 + tests/tree_lookup.rs | 2 + tests/vslider_math.rs | 9 +- tests/widget_dispatch.rs | 2 + 83 files changed, 1850 insertions(+), 526 deletions(-) create mode 100644 examples/responsive.rs create mode 100644 src/event_loop/perf.rs diff --git a/Cargo.toml b/Cargo.toml index 00173c1..eda76da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,13 @@ exclude = [ "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] chrono = { version = "0.4", features = ["clock"] } serde = { version = "1.0", features = ["derive"] } diff --git a/Makefile b/Makefile index 88c9c1f..e284cc4 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ all: cargo build --release test: - cargo test + cargo test --features test-support # Typecheck the `no_run` snippets in docs/*.md by feeding each markdown # file to `rustdoc --test`. Catches API drift in cookbook + widget @@ -29,13 +29,14 @@ doc: install: doc 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 install -d $(DOCDIR) cp -r target/doc/* $(DOCDIR)/ examples: 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 scroll LTK_THEMES_DIR=themes cargo run --release --example sliders diff --git a/README.md b/README.md index f91138f..d80d990 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ cargo run --example showcase Useful entry points in this repository: - `cargo run --example showcase` +- `cargo run --example responsive` - `cargo run --example widgets` - `cargo run --example inputs` - `cargo run --example scroll` @@ -187,6 +188,7 @@ Useful entry points in this repository: In general: - 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 `mini_shell` if you need overlays, theme switching, or shell-style composition @@ -211,6 +213,25 @@ More advanced APIs are available when needed: - `core::UiSurface` - 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 By default, `ltk` creates a regular `xdg-shell` window. @@ -257,13 +278,17 @@ The library already provides: ## Backend Differences 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, -clipping and gradients all paint identically on both paths. The gap is in the +yet. The widget tree, layout, hit-testing, text, images, fills, strokes and +clipping all paint identically on both paths. The gaps are in gradients and the shadow / backdrop pipeline. -Effects that currently render only on the **GLES** backend, and are silent -no-ops on the **Software** backend: +Effects that currently render only on the **GLES** backend, and degrade on the +**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 declare a `Shadow` slot show the soft halo on GLES and a flat fill on software. diff --git a/docs/architecture.md b/docs/architecture.md index 6743b4b..a0aa5e8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 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 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. - *Lower `poll_interval()` is not free.* Crustace polls every 30 s because the clock only shows HH:MM. If your UI shows seconds, `Some(Duration::from_secs(1))` is fine; if it shows nothing time-sensitive, leave it `None`. - *Scroll viewports own a sub-canvas.* They're slightly more expensive to draw than a plain column. Use them when you need clipping or actual scrolling, not as a wrapper. -- *GPU vs software*: the GLES path is selected automatically when EGL is available; 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. +**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 | Pattern | File | diff --git a/docs/cookbook.md b/docs/cookbook.md index 075b26f..d861ec4 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -13,6 +13,7 @@ reference, see [`docs/widgets.md`](./widgets.md). For theme JSON, see ## Table of contents +- [Responsive sizing across mobile / tablet / desktop](#responsive-sizing-across-mobile--tablet--desktop) - [Slide-in panel](#slide-in-panel) - [Password field with PAM submit](#password-field-with-pam-submit) - [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>, lw: u32, lh: u32 ) -> Element { +column::() + .padding( Length::vmin( 4.0 ).clamp( 16.0, 48.0 ) ) + .spacing( Length::vmin( 2.0 ).clamp( 8.0, 24.0 ) ) + // Logo: 40 % of the width in portrait, 5 % of the height in landscape. + .push( img_widget( logo, lw, lh ).short_side( Length::orient( 40.0, 5.0 ) ) ) + // Heading: fluid, but never below 20 px nor above 44 px. + .push( text( "Welcome" ).size( Length::vmin( 6.0 ).clamp( 20.0, 44.0 ) ) ) + .into() +# } +``` + +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 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 # 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 SLIDE_DURATION: f32 = 0.25; # #[ derive( Clone ) ] enum Msg { CloseQs } @@ -71,7 +111,7 @@ fn build_quick_settings_overlay( &self ) -> OverlaySpec id: OVERLAY_QS, layer: Layer::Overlay, 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, keyboard_exclusive: false, 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. ```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 ); # #[ derive( Clone ) ] enum Msg { CloseModal } # struct App { modal_open: bool, modal_drag_progress: f32 } @@ -239,7 +279,7 @@ fn overlays( &self ) -> Vec> id: OVERLAY_MODAL, layer: Layer::Overlay, anchor: Anchor::ALL, - size: ( 0, 0 ), + size: ( Length::px( 0.0 ), Length::px( 0.0 ) ), exclusive_zone: 0, keyboard_exclusive: false, input_region: None, // accept input @@ -488,7 +528,7 @@ blocking other UI. ```rust,no_run # 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 ); # #[ derive( Clone ) ] enum Msg {} struct AppState @@ -539,7 +579,7 @@ impl App for AppState id: OVERLAY_TOAST, layer: Layer::Overlay, anchor: Anchor::BOTTOM, - size: ( 0, 0 ), + size: ( Length::px( 0.0 ), Length::px( 0.0 ) ), exclusive_zone: 0, keyboard_exclusive: false, input_region: Some( vec![] ), // pass-through diff --git a/docs/onboarding.md b/docs/onboarding.md index 2b6fcd6..d6a402d 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -322,6 +322,28 @@ enum AppMsg 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 If you are new to the library, this order minimizes confusion: diff --git a/examples/mini_shell.rs b/examples/mini_shell.rs index 2e3ef9b..ee271da 100644 --- a/examples/mini_shell.rs +++ b/examples/mini_shell.rs @@ -23,7 +23,7 @@ use std::time::{ Duration, Instant }; use ltk:: { - App, ButtonVariant, Color, Element, Keysym, OverlayId, OverlaySpec, + App, ButtonVariant, Color, Element, Keysym, Length, OverlayId, OverlaySpec, ThemeMode, button, column, container, progress_bar, row, slider, spacer, text, toggle, }; @@ -190,7 +190,7 @@ impl App for AppState id: OVERLAY_QUICK_SETTINGS, layer: ltk::Layer::Overlay, anchor: ltk::Anchor::ALL, - size: ( 0, 0 ), + size: ( Length::px( 0.0 ), Length::px( 0.0 ) ), exclusive_zone: 0, keyboard_exclusive: false, input_region: None, @@ -207,7 +207,7 @@ impl App for AppState id: OVERLAY_CONFIRM, layer: ltk::Layer::Overlay, anchor: ltk::Anchor::ALL, - size: ( 0, 0 ), + size: ( Length::px( 0.0 ), Length::px( 0.0 ) ), exclusive_zone: 0, keyboard_exclusive: false, input_region: None, @@ -224,7 +224,7 @@ impl App for AppState id: OVERLAY_TOAST, layer: ltk::Layer::Overlay, anchor: ltk::Anchor::ALL, - size: ( 0, 0 ), + size: ( Length::px( 0.0 ), Length::px( 0.0 ) ), exclusive_zone: 0, keyboard_exclusive: false, // Pass-through: the OSD must not steal taps from anything below. diff --git a/examples/responsive.rs b/examples/responsive.rs new file mode 100644 index 0000000..fd56ce5 --- /dev/null +++ b/examples/responsive.rs @@ -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 + { + 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::() + .spacing( 12.0 ) + .push( button::( "Switch mode".to_string() ) + .variant( ButtonVariant::Primary ) + .font_size( 16.0 ) + .height( 44.0 ) + .on_press( Message::SwitchMode ) ) + .push( button::( "− density".to_string() ) + .font_size( 16.0 ) + .height( 44.0 ) + .on_press( Message::DensityDown ) ) + .push( button::( "+ density".to_string() ) + .font_size( 16.0 ) + .height( 44.0 ) + .on_press( Message::DensityUp ) ); + + // The demo widgets — NO explicit sizes, so they follow the mode. + let demo = column::() + .spacing( 16.0 ) + .max_width( 520.0 ) + .push( button::( "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::() + .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 + { + if keysym == Keysym::Escape + { + std::process::exit( 0 ); + } + None + } +} + +fn main() +{ + ltk::run( ResponsiveApp::new() ); +} diff --git a/examples/showcase.rs b/examples/showcase.rs index 8e2d398..478cf79 100644 --- a/examples/showcase.rs +++ b/examples/showcase.rs @@ -151,10 +151,22 @@ impl App for ShowcaseApp // Viewport-relative image: 30 % of the surface width by 10 % of its // 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::() .spacing( 4.0 ) .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 ); diff --git a/src/app.rs b/src/app.rs index 6f81dab..f14adee 100644 --- a/src/app.rs +++ b/src/app.rs @@ -213,9 +213,14 @@ pub struct OverlaySpec /// Screen edges to anchor to. pub anchor: Anchor, - /// Desired size `( width, height )` in logical pixels. `0` in either - /// component means "fill available space in that dimension". - pub size: ( u32, u32 ), + /// Desired size `( width, height )`, resolved to logical pixels against + /// the main surface (output) when the overlay is materialized. A + /// [`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. /// `-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`. 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`] /// slide only repositions the app's input-transparent subsurfaces /// ([`Self::subsurfaces`]) while the main surface buffer stays unchanged. @@ -733,17 +746,6 @@ pub trait App: 'static /// panel over a static main surface. 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 /// transparent or to deviate from the theme. Default: the active /// theme's `bg` token, so the window matches the rest of the diff --git a/src/draw/layout.rs b/src/draw/layout.rs index bf8eb26..5378eba 100644 --- a/src/draw/layout.rs +++ b/src/draw/layout.rs @@ -453,13 +453,22 @@ pub( crate ) fn layout_and_draw( other.draw( canvas, rect, is_focused, is_hovered, is_pressed, cursor_pos, sel_anchor ); 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 { rect, flat_idx, id: widget_id, paint_rect: other.paint_bounds( rect ), - handlers: other.handlers(), + handlers, keyboard_focusable: other.is_focusable(), cursor: other.cursor_shape(), tooltip: other.tooltip().map( str::to_string ), diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index ea16a19..4dceb00 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -256,6 +256,9 @@ pub struct AppData /// Focus request deferred because the target widget was absent from /// `widget_rects` (e.g. read_only, not yet rebuilt). Retried next draw. pub focus_retry: Option, + /// Runtime performance guardrails (stuck-animation / poll-interval + /// diagnostics and the software animation-rate cap). See [`super::perf`]. + pub perf: super::perf::PerfState, } impl AppData diff --git a/src/event_loop/handlers.rs b/src/event_loop/handlers.rs index 63bb900..3dca314 100644 --- a/src/event_loop/handlers.rs +++ b/src/event_loop/handlers.rs @@ -662,10 +662,29 @@ impl Dispatch for AppData } else { - state.view_dirty = true; - state.main.request_redraw(); + // 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.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 ) => { diff --git a/src/event_loop/invalidation.rs b/src/event_loop/invalidation.rs index b3ec55d..9ba63b8 100644 --- a/src/event_loop/invalidation.rs +++ b/src/event_loop/invalidation.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. +#[ cfg( feature = "test-support" ) ] use std::collections::HashSet; use crate::app::{ App, InvalidationScope, SurfaceTarget }; @@ -62,6 +63,10 @@ pub( super ) fn apply_invalidation( data: &mut AppData, scope: Invali /// /// `added` preserves the order of `next` (so creation order is deterministic); /// `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( current: impl IntoIterator, next: &[ crate::app::OverlayId ], diff --git a/src/event_loop/mod.rs b/src/event_loop/mod.rs index dd38e46..26e8f92 100644 --- a/src/event_loop/mod.rs +++ b/src/event_loop/mod.rs @@ -9,6 +9,7 @@ pub( crate ) mod cursor_shape; pub( crate ) mod drag; pub( crate ) mod focus; mod handlers; +pub( crate ) mod perf; pub( crate ) mod repeat; pub( crate ) mod subsurface; pub( crate ) mod surface; @@ -25,4 +26,5 @@ pub( crate ) use app_data::AppData; pub( crate ) use surface::{ FrameState, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState }; pub use error::RunError; pub( crate ) use run::{ run, try_run }; +#[ cfg( feature = "test-support" ) ] pub use invalidation::diff_overlay_ids; diff --git a/src/event_loop/overlays_reconcile.rs b/src/event_loop/overlays_reconcile.rs index 82982ee..187af80 100644 --- a/src/event_loop/overlays_reconcile.rs +++ b/src/event_loop/overlays_reconcile.rs @@ -24,7 +24,7 @@ use wayland_protocols::xdg::shell::client::xdg_positioner:: }; use crate::app::App; -use crate::types::Rect; +use crate::types::{ Length, Rect }; use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState }; /// Sync `data.overlays` with the app's current `App::overlays()` spec list: @@ -106,6 +106,18 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) }; let parent_scale_i = data.main.scale_factor.max( 1 ); 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 // 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 @@ -124,6 +136,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) let overlays_m = &mut data.overlays; for spec in &specs { + let resolved_size = resolve_size( &spec.size ); if let Some( ss ) = overlays_m.get_mut( &spec.id ) { // Already-live overlay: propagate size changes to the layer @@ -135,14 +148,14 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) // sends a configure; the usual `on_configure` path picks up the // new dimensions and drives the redraw. Popups don't grow / // 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 { - let ( lw, lh ) = to_logical_size( spec.size ); + let ( lw, lh ) = to_logical_size( resolved_size ); layer_surface.set_size( lw, lh ); layer_surface.commit(); - ss.last_requested_size = spec.size; + ss.last_requested_size = resolved_size; } } // Compare the anchor at integer logical-pixel resolution: @@ -173,7 +186,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) if let Ok( positioner ) = XdgPositioner::new( xdg_shell ) { 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_h = spec_h.max( 1 ) as i32; positioner.set_size( popup_w, popup_h ); @@ -236,7 +249,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) let ay = ( anchor_rect.y / parent_scale ).round() 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 ( 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_h = spec_h.max( 1 ) as i32; positioner.set_size( popup_w, popup_h ); @@ -274,7 +287,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) } popup.wl_surface().commit(); let mut ss = SurfaceState::::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 ); overlays_m.insert( spec.id, ss ); continue; @@ -290,7 +303,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) layer: spec.layer.to_wlr_layer(), exclusive_zone: spec.exclusive_zone, anchor: spec.anchor, - size: to_logical_size( spec.size ), + size: to_logical_size( resolved_size ), keyboard_exclusive: spec.keyboard_exclusive, namespace: "ltk-overlay", }; @@ -305,7 +318,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) // instead of rendering at scale 1 until `scale_factor_changed` // lands a frame or two later. ss.scale_factor = parent_scale_i; - ss.last_requested_size = spec.size; + ss.last_requested_size = resolved_size; ss.layer_anchor = Some( spec.anchor ); overlays_m.insert( spec.id, ss ); } diff --git a/src/event_loop/perf.rs b/src/event_loop/perf.rs new file mode 100644 index 0000000..d78e323 --- /dev/null +++ b/src/event_loop/perf.rs @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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 = 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, + /// 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() ); + } +} diff --git a/src/event_loop/run.rs b/src/event_loop/run.rs index 7f61a4d..abe7d29 100644 --- a/src/event_loop/run.rs +++ b/src/event_loop/run.rs @@ -331,6 +331,7 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> overlays_dirty: true, first_frame_committed: false, focus_retry: None, + perf: super::perf::PerfState::new(), }; // Register a calloop channel so the app can send messages from any thread. @@ -360,6 +361,7 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> // The timer fires independently of Wayland events, waking the event loop on schedule. if let Some( dur ) = data.app.poll_interval() { + data.perf.warn_poll_interval( dur ); event_loop.handle() .insert_source( Timer::from_duration( dur ), diff --git a/src/event_loop/tooltip.rs b/src/event_loop/tooltip.rs index 2649d33..d301a59 100644 --- a/src/event_loop/tooltip.rs +++ b/src/event_loop/tooltip.rs @@ -4,7 +4,7 @@ use super::app_data::AppData; use super::surface::SurfaceFocus; 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 ); @@ -132,7 +132,7 @@ impl AppData id, layer: crate::app::Layer::Overlay, anchor: crate::app::Anchor::ALL, - size: ( 0, 0 ), + size: ( Length::px( 0.0 ), Length::px( 0.0 ) ), exclusive_zone: -1, keyboard_exclusive: false, input_region: Some( Vec::new() ), diff --git a/src/layout/column.rs b/src/layout/column.rs index a051ec2..a305a0a 100644 --- a/src/layout/column.rs +++ b/src/layout/column.rs @@ -43,17 +43,17 @@ use crate::widget::Element; /// ``` pub struct Column { - pub children: Vec>, + pub( crate ) children: Vec>, /// 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 /// px constant. - pub spacing: Length, + pub( crate ) spacing: Length, /// Padding on all sides. Same [`Length`] semantics as `spacing`. - pub padding: Length, - pub align_center_x: bool, - pub center_y: bool, - pub max_width: Option, - pub fit_content: bool, + pub( crate ) padding: Length, + pub( crate ) align_center_x: bool, + pub( crate ) center_y: bool, + pub( crate ) max_width: Option, + pub( crate ) fit_content: bool, } impl Column diff --git a/src/layout/row.rs b/src/layout/row.rs index 993e16b..48e97ac 100644 --- a/src/layout/row.rs +++ b/src/layout/row.rs @@ -41,12 +41,12 @@ use crate::widget::Element; /// ``` pub struct Row { - pub children: Vec>, + pub( crate ) children: Vec>, /// 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. - pub padding: Length, - pub align_right: bool, + pub( crate ) padding: Length, + pub( crate ) align_right: bool, } impl Row diff --git a/src/layout/spacer.rs b/src/layout/spacer.rs index 2b64f66..0b5e226 100644 --- a/src/layout/spacer.rs +++ b/src/layout/spacer.rs @@ -70,15 +70,15 @@ use crate::widget::Element; pub struct Spacer { /// Relative weight of this spacer (default 1). - pub weight: u32, + pub( crate ) weight: u32, /// Fixed height (overrides flexible behavior in a column). Accepts any /// [`Length`] — pass an `f32`/`i32`/`u32` for the px case (kept for /// backward compatibility with existing call sites), or /// `Length::vmin( … )` etc. for viewport-relative gaps. - pub fixed_height: Option, + pub( crate ) fixed_height: Option, /// Fixed width (overrides flexible behavior in a row). Same length-type /// semantics as [`Self::fixed_height`]. - pub fixed_width: Option, + pub( crate ) fixed_width: Option, } impl Spacer @@ -222,4 +222,23 @@ mod tests assert_eq!( s.resolved_height( &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 ); + } } diff --git a/src/layout/stack.rs b/src/layout/stack.rs index dde407a..c91e235 100644 --- a/src/layout/stack.rs +++ b/src/layout/stack.rs @@ -56,10 +56,10 @@ pub struct Stack /// `Option` overrides alignment/sizing with an exact rect (see /// [`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)). - pub children: Vec<( Element, HAlign, VAlign, f32, f32, f32, Option, Option )>, + pub( crate ) children: Vec<( Element, HAlign, VAlign, f32, f32, f32, Option, Option )>, /// When `true`, [`preferred_size`](Self::preferred_size) reports the max of /// children's intrinsic widths and heights instead of `(max_width, tallest)`. - pub fit_content: bool, + pub( crate ) fit_content: bool, } impl Stack diff --git a/src/layout/wrap_grid.rs b/src/layout/wrap_grid.rs index 9eb2c70..1ec299d 100644 --- a/src/layout/wrap_grid.rs +++ b/src/layout/wrap_grid.rs @@ -32,18 +32,18 @@ use crate::widget::Element; pub struct WrapGrid { /// Child widgets laid out in row-major order. - pub children: Vec>, + pub( crate ) children: Vec>, /// Number of columns per row. - pub columns: usize, + pub( crate ) columns: usize, /// Horizontal gap between cells. - pub spacing_x: Length, + pub( crate ) spacing_x: Length, /// Vertical gap between rows. - pub spacing_y: Length, + pub( crate ) spacing_y: Length, /// Padding on all sides. - pub padding: Length, + pub( crate ) padding: Length, /// When `true`, a partial last row is centred horizontally within /// the grid's content rect instead of being left-aligned. - pub centre_last_row: bool, + pub( crate ) centre_last_row: bool, } impl WrapGrid diff --git a/src/lib.rs b/src/lib.rs index 5fe961d..aca05e0 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -112,34 +112,108 @@ //! //! - [`Color`], [`Rect`], [`Point`], [`Size`], [`Corners`], [`WidgetId`]. //! - [`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::Vmax`]) or to the root font size ([`LengthBase::Em`]). -//! Every setter that takes a size, padding, spacing or font height -//! now accepts `impl Into`, so legacy `.size( 24.0 )` keeps -//! working while new code can write `.size( Length::vmin( 4.0 ) -//! .clamp( 16.0, 32.0 ) )` for a typeface that scales with the screen. +//! [`LengthBase::Vmax`] / [`LengthBase::Orient`]) or to the root font +//! size ([`LengthBase::Em`]). Every setter that takes a size, padding, +//! spacing or font height now accepts `impl Into`, so legacy +//! `.size( 24.0 )` keeps working while new code can write +//! `.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. //! -//! ## Designing for multiple resolutions +//! ## Responsive design: one UI from phone to desktop //! -//! ltk supports three approaches to making a layout adapt to the screen, -//! ordered from most to least common: +//! A core goal of ltk is that a **single view** reads coherently across +//! 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 -//! spacer height/width. Default to `Length::vmin( pct ).clamp( lo, hi )`: -//! the percentage tracks the surface's smaller side (so portrait and -//! landscape behave coherently), and the px clamps protect both ends -//! of the spectrum from breaking the design. -//! 2. **Responsive typographic scale** via [`theme::typography::h0`]… -//! [`theme::typography::body_xs`] — pre-calibrated [`Length`] values -//! coherent with the default Sora-based theme. Calibrated so a -//! 1000-px smaller side reproduces the legacy px constants exactly. -//! 3. **`view()`-level branching on `surface_width` / `surface_height`** -//! when the layout structure itself must change (e.g. sidebar → -//! bottom-tabs below a breakpoint). Keep this for genuine restructuring, -//! not for sizing — `Length` covers sizing. +//! ### The core rule — fluid, but clamped +//! +//! Default to `Length::vmin( pct ).clamp( lo, hi )` for every font size, +//! padding, spacing and spacer: +//! +//! - the percentage tracks the surface's **smaller** side, so portrait +//! and landscape stay coherent — an element keeps the same fraction of +//! the narrow axis whichever way the device is held; +//! - the px `clamp` bounds the fluid range so the design never collapses +//! on a watch-sized surface nor balloons on a 4K monitor. +//! +//! The clamp is not optional polish: it is what turns *pure* proportional +//! 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>, w: u32, h: u32 ) -> Element { +//! // 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 //! @@ -187,6 +261,12 @@ // English entry. 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( crate ) mod render; pub( crate ) mod system_fonts; @@ -248,7 +328,10 @@ pub use render::Canvas; 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 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 text_shaping::measure_text; 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 /// 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 ) ] pub mod test_support { pub use crate::tree::{ find_widget_at, find_widget, find_handlers, next_focusable_index }; 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::event_loop::diff_overlay_ids; } diff --git a/src/render/mod.rs b/src/render/mod.rs index 4aa8408..a1c5e37 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -39,7 +39,7 @@ use tiny_skia::{ Mask, Pixmap }; use crate::gles_render::{ BorrowedGlesTexture, GlesCanvas, GlesVersion }; 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 clip; @@ -239,6 +239,45 @@ impl Canvas ( 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 /// is GPU-backed. pub fn borrowed_gles_texture( &self ) -> Option @@ -805,6 +844,45 @@ mod viewport_tests c.set_dpi_scale( 2.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 ) ] diff --git a/src/types.rs b/src/types.rs index 8662086..78b03d8 100644 --- a/src/types.rs +++ b/src/types.rs @@ -24,7 +24,7 @@ //! `ltk::Rect`, …) so application code rarely needs the `ltk::types::` //! 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]`. #[ derive( Debug, Clone, Copy, PartialEq ) ] @@ -468,6 +468,15 @@ pub enum LengthBase Vmin( f32 ), /// Percentage of the viewport's **larger** dimension. 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 /// of `Em(2.0)` is twice the body size, regardless of viewport). Em( f32 ), @@ -485,6 +494,15 @@ impl LengthBase LengthBase::Vh( pct ) => vh * pct / 100.0, LengthBase::Vmin( pct ) => vw.min( 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, } } @@ -539,15 +557,58 @@ impl Length 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 ) ) } - /// "Design pixel": `px` interpreted at the reference vmin set via - /// [`set_design_reference`] (defaults to 412 px — the eydos mobile - /// reference width). The result is a `Vmin` value clamped to - /// `[px * 0.7, px * 1.5]`, so the layout scales with the screen - /// without collapsing on tiny surfaces or ballooning on 4K. + /// Orientation-aware size: `portrait` % of the **width** when the + /// viewport is portrait, `landscape` % of the **height** when it is + /// landscape. See [`LengthBase::Orient`]. Chain `.clamp( lo, hi )` to + /// bound the result in px as with any relative length. + 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 { - let r = design_reference(); - Length::vmin( px / r * 100.0 ).clamp( px * 0.7, px * 1.5 ) + Length::px( px * density() ) + } + + /// 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 @@ -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. -/// Call once at startup (e.g. before [`crate::run`]) to align the design -/// scale to the surface mock-up the app was designed for. -pub fn set_design_reference( reference_vmin: f32 ) +static FLUID_REFERENCE_BITS: AtomicU32 = AtomicU32::new( 412.0_f32.to_bits() ); + +/// Set the reference surface (short-side px) that [`Length::fluid`] +/// 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)` -/// resolves to `n` logical pixels. -pub fn design_reference() -> f32 +/// Current reference used by [`Length::fluid`] — the short-side px at which +/// `fluid( n )` resolves to `n` px before clamping. +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 for Length @@ -640,6 +771,7 @@ impl From for Length mod length_tests { use super::Length; + use crate::TEST_GLOBALS_LOCK as GLOBALS_LOCK; #[ test ] 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 ); } + #[ 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 ] fn em_uses_em_base() { @@ -695,4 +838,63 @@ mod length_tests let l: Length = 24.0_f32.into(); 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 ); + } } diff --git a/src/widget/anchored_overlay/mod.rs b/src/widget/anchored_overlay/mod.rs index 5b1ed68..d2d9beb 100644 --- a/src/widget/anchored_overlay/mod.rs +++ b/src/widget/anchored_overlay/mod.rs @@ -32,12 +32,12 @@ mod tests; pub struct AnchoredOverlay { /// The element to draw at the anchored position. - pub child: Box>, + pub( crate ) child: Box>, /// 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 /// of the child. - pub gap: f32, + pub( crate ) gap: f32, } impl AnchoredOverlay diff --git a/src/widget/button/mod.rs b/src/widget/button/mod.rs index c40a4d7..7f987e8 100644 --- a/src/widget/button/mod.rs +++ b/src/widget/button/mod.rs @@ -2,7 +2,7 @@ // Copyright (C) 2026 Liberux Labs, S. L. use std::sync::Arc; -use crate::types::{ Color, Rect, WidgetId }; +use crate::types::{ Color, Length, Rect, WidgetId }; use crate::render::Canvas; use super::Element; @@ -47,34 +47,45 @@ pub enum ButtonContent pub struct 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. - pub on_press: Option, + pub( crate ) on_press: Option, /// Message emitted when the user holds the button for /// [`App::long_press_duration`](crate::app::App::long_press_duration) /// without moving past the tolerance, OR when the user right-clicks /// with the mouse. `None` leaves the button without a context-menu /// equivalent. The fire does NOT by itself put the gesture into /// drag mode — that is governed by [`Self::on_drag_start`]. - pub on_long_press: Option, + pub( crate ) on_long_press: Option, /// Drag-arm message. Fires when the press transitions into a drag: /// touch on hold-timer expiry (in addition to `on_long_press`), /// mouse on motion past the drag-promotion threshold (without /// firing the menu). Independent of `on_long_press` so a button /// can open a menu without becoming draggable, or be draggable /// without showing a menu. - pub on_drag_start: Option, + pub( crate ) on_drag_start: Option, /// Visual variant controlling colors and borders. - pub variant: ButtonVariant, - /// Width and height in pixels for icon buttons. Defaults to `48.0`. - pub icon_size: f32, + pub( crate ) variant: ButtonVariant, + /// Width and height in pixels for icon buttons. The `0.0` default + /// 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, + /// 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, /// Optional stable identifier for focus management. - pub id: Option, + pub( crate ) id: Option, /// 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 /// to the `Pointer` (hand) default for clickable widgets. - pub cursor: Option, + pub( crate ) cursor: Option, /// When `true`, holding the button down auto-fires the /// `on_press` message: one immediate fire on press, then an /// initial delay (≈ 500 ms — same as the keyboard) followed by @@ -83,8 +94,8 @@ pub struct Button /// target). The runtime cancels the timer on release, on touch /// cancel, and on long-press promotion. Default `false` — most /// buttons fire on tap only. - pub repeating: bool, - pub tooltip: Option, + pub( crate ) repeating: bool, + pub( crate ) tooltip: Option, } impl Button @@ -99,7 +110,9 @@ impl Button on_long_press: None, on_drag_start: None, variant: ButtonVariant::Primary, - icon_size: theme::HEIGHT, + icon_size: 0.0, + font_size: None, + height: None, id: None, focusable: true, cursor: None, @@ -134,7 +147,9 @@ impl Button on_long_press: None, on_drag_start: None, variant: ButtonVariant::Tertiary, - icon_size: theme::HEIGHT, + icon_size: 0.0, + font_size: None, + height: None, id: None, focusable: true, cursor: None, @@ -222,6 +237,52 @@ impl Button 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 ) -> 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 ) -> 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. pub fn id( mut self, id: WidgetId ) -> Self { @@ -268,13 +329,13 @@ impl Button { ButtonContent::Text( label ) => { - let text_w = canvas.measure_text( label, theme::FONT_SIZE ); - let w = (text_w + theme::PAD_H * 2.0).min( max_width ); - ( w, theme::HEIGHT ) + let text_w = canvas.measure_text( label, self.label_font_size( canvas ) ); + let w = (text_w + canvas.geom_px( theme::PAD_H ) * 2.0).min( max_width ); + ( w, self.resolved_height( canvas ) ) } ButtonContent::Icon { .. } => { - let s = self.icon_size.min( max_width ); + let s = self.resolved_icon_size( canvas ).min( max_width ); ( s, s ) } } @@ -302,7 +363,8 @@ impl Button fn draw_text_button( &self, canvas: &mut Canvas, rect: Rect, focused: bool, label: &str ) { 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 { @@ -326,12 +388,12 @@ impl Button 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( label, rect.x + (rect.width - text_w) / 2.0, text_y, - theme::FONT_SIZE, + fs, text_c, ); } @@ -352,12 +414,12 @@ impl Button 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( label, rect.x + (rect.width - text_w) / 2.0, text_y, - theme::FONT_SIZE, + fs, text_c, ); } @@ -369,12 +431,12 @@ impl Button let ring = rect.expand( 2.0 ); 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( label, rect.x + (rect.width - text_w) / 2.0, text_y, - theme::FONT_SIZE, + fs, text_c, ); } @@ -446,6 +508,8 @@ impl Button on_drag_start: self.on_drag_start.map( |m| ( *f )( m ) ), variant: self.variant, icon_size: self.icon_size, + font_size: self.font_size, + height: self.height, id: self.id, focusable: self.focusable, cursor: self.cursor, diff --git a/src/widget/button/tests.rs b/src/widget/button/tests.rs index 5bb17cd..03ba2f6 100644 --- a/src/widget/button/tests.rs +++ b/src/widget/button/tests.rs @@ -99,3 +99,54 @@ fn repeating_builder_sets_flag() let b = b.repeating( false ); 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 = std::sync::Arc::new( |_| () ); + let b: Button<()> = Button::::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 ); +} diff --git a/src/widget/carousel/mod.rs b/src/widget/carousel/mod.rs index e38481d..111d635 100644 --- a/src/widget/carousel/mod.rs +++ b/src/widget/carousel/mod.rs @@ -32,19 +32,19 @@ mod tests; pub struct Carousel { /// Child widgets in display order, left-to-right. - pub children: Vec>, + pub( crate ) children: Vec>, /// Optional stable identifier — used by the host as the key for its /// drag / inertia / snap state. - pub id: Option, + pub( crate ) id: Option, /// Each child's width as a fraction of the viewport width. 0.8 leaves /// 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. - pub gap: f32, + pub( crate ) gap: f32, /// Logical-pixel offset applied to every child. Positive values shift /// children to the right (revealing later tiles). The host clamps, /// snaps and animates this value. - pub offset: f32, + pub( crate ) offset: f32, } impl Carousel diff --git a/src/widget/checkbox/mod.rs b/src/widget/checkbox/mod.rs index 0cdcf67..9881494 100644 --- a/src/widget/checkbox/mod.rs +++ b/src/widget/checkbox/mod.rs @@ -37,13 +37,13 @@ pub struct Checkbox { /// Current checked state. Drawn from this field every frame; the /// runtime never mutates it. - pub checked: bool, + pub( crate ) checked: bool, /// Message emitted on activation. `None` leaves the checkbox inert. - pub on_toggle: Option, + pub( crate ) on_toggle: Option, /// Optional label drawn to the right of the box. - pub label: Option, + pub( crate ) label: Option, /// Optional stable identifier for focus management. - pub id: Option, + pub( crate ) id: Option, } impl Checkbox @@ -83,14 +83,15 @@ impl Checkbox 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 text_w = canvas.measure_text( label, theme::FONT_SIZE ); - ( theme::BOX_SIZE + theme::GAP + text_w ).min( max_width ) + let text_w = canvas.measure_text( label, canvas.font_px( theme::FONT_SIZE ) ); + ( box_size + canvas.geom_px( theme::GAP ) + text_w ).min( max_width ) } 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 @@ -102,21 +103,22 @@ impl Checkbox 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 { x: rect.x, y: box_y, - width: theme::BOX_SIZE, - height: theme::BOX_SIZE, + width: box_size, + height: box_size, }; if self.checked { canvas.fill_rect( box_rect, theme::box_checked(), theme::RADIUS ); - let cx = rect.x + theme::BOX_SIZE / 2.0; - let cy = box_y + theme::BOX_SIZE / 2.0; - let s = theme::BOX_SIZE * 0.3; + let cx = rect.x + box_size / 2.0; + let cy = box_y + box_size / 2.0; + 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 * 0.3, cy + s * 0.7, cx + s, cy - s * 0.5, theme::check_color(), theme::CHECK_W ); } else { @@ -131,9 +133,10 @@ impl Checkbox if let Some( ref label ) = self.label { - let text_x = rect.x + theme::BOX_SIZE + theme::GAP; - let text_y = rect.y + ( rect.height + theme::FONT_SIZE ) / 2.0 - 2.0; - canvas.draw_text( label, text_x, text_y, theme::FONT_SIZE, theme::label_color() ); + let fs = canvas.font_px( theme::FONT_SIZE ); + let text_x = rect.x + box_size + canvas.geom_px( theme::GAP ); + let text_y = rect.y + ( rect.height + fs ) / 2.0 - 2.0; + canvas.draw_text( label, text_x, text_y, fs, theme::label_color() ); } } diff --git a/src/widget/color_picker/mod.rs b/src/widget/color_picker/mod.rs index 7eaacb2..7276c58 100644 --- a/src/widget/color_picker/mod.rs +++ b/src/widget/color_picker/mod.rs @@ -24,7 +24,7 @@ use std::sync::Arc; -use crate::types::Color; +use crate::types::{ Color, Length }; use crate::layout::column::column; use crate::layout::row::row; use crate::layout::spacer::spacer; @@ -114,12 +114,12 @@ pub fn parse_hex( s: &str ) -> Option /// a continuous hue strip. pub struct ColorPicker { - pub value: Color, - pub on_change: Option Msg>>, + pub( crate ) value: Color, + pub( crate ) on_change: Option Msg>>, /// When `true` the alpha slider is shown and the hex input /// accepts `#RRGGBBAA`. Default: `false` — most "pick a theme /// colour" flows are opaque. - pub show_alpha: bool, + pub( crate ) show_alpha: bool, } impl ColorPicker @@ -163,7 +163,7 @@ impl ColorPicker .radius( 12.0 ) .padding( 0.0 ) .into(); - let mut preview_row = row::().spacing( theme::SPACING ).push( + let mut preview_row = row::().spacing( Length::widget( theme::SPACING ) ).push( container::( swatch ) .padding( 0.0 ) .radius( 12.0 ), @@ -208,7 +208,7 @@ impl ColorPicker } ); } column::().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 ) .into() }; @@ -218,7 +218,7 @@ impl ColorPicker let b_build: Arc Color> = Arc::new( move |v| Color::rgba( value.r, value.g, v, value.a ) ); let a_build: Arc Color> = Arc::new( move |v| Color::rgba( value.r, value.g, value.b, v ) ); - let mut sliders = column::().spacing( theme::SPACING ) + let mut sliders = column::().spacing( Length::widget( theme::SPACING ) ) .push( chan_slider( "R", value.r, r_build ) ) .push( chan_slider( "G", value.g, g_build ) ) .push( chan_slider( "B", value.b, b_build ) ); @@ -267,18 +267,18 @@ impl ColorPicker } ); } let hue_row: Element = column::().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 ) .into(); - let body = column::().spacing( theme::SPACING * 2.0 ) + let body = column::().spacing( Length::widget( theme::SPACING * 2.0 ) ) .push( preview_row ) .push( sliders ) .push( hue_row ); container::( body ) .background( theme::surface_alt() ) - .padding( theme::PADDING ) + .padding( Length::widget( theme::PADDING ) ) .radius( theme::RADIUS ) .into() } diff --git a/src/widget/combo/mod.rs b/src/widget/combo/mod.rs index e19e81d..1354131 100644 --- a/src/widget/combo/mod.rs +++ b/src/widget/combo/mod.rs @@ -100,7 +100,7 @@ use std::collections::hash_map::DefaultHasher; use std::hash::{ Hash, Hasher }; use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec }; -use crate::types::WidgetId; +use crate::types::{ Length, WidgetId }; use super::Element; @@ -792,7 +792,7 @@ impl Combo // `size.0 == 0` asks the runtime to size the popup to the // trigger pill width (the canonical select / dropdown // 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, keyboard_exclusive: false, input_region: None, diff --git a/src/widget/container/mod.rs b/src/widget/container/mod.rs index 8f456cd..a3b032a 100644 --- a/src/widget/container/mod.rs +++ b/src/widget/container/mod.rs @@ -53,17 +53,17 @@ mod tests; /// ``` pub struct Container { - pub child: Box>, + pub( crate ) child: Box>, /// Optional background paint — flat colour, linear or radial /// gradient. Constructed via [`Container::background`], which /// accepts anything `Into` (a plain [`Color`] gets /// promoted to [`Paint::Solid`] via the trait impl). - pub background: Option, + pub( crate ) background: Option, /// Slot id of a themed surface (resolved via /// [`crate::theme::resolve_surface`]). When set, takes precedence /// over `background` and paints the full Glass stack instead of a /// flat colour fill. - pub surface: Option, + pub( crate ) surface: Option, /// Per-corner radii applied to every painted layer of the /// container chrome — flat fill, themed surface (gradient + outer /// shadows + insets + backdrop blur). Stored as [`Corners`] so @@ -71,33 +71,33 @@ pub struct Container /// panel pinned to the screen bottom, a side panel pinned to the /// left edge, …) without hitting the renderer with an offset /// trick. - pub corners: Corners, + pub( crate ) corners: Corners, /// 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 - /// viewport via [`Length::dp`] / [`Length::vmin`]. - pub pad_top: Length, + /// viewport via [`Length::fluid`] / [`Length::vmin`]. + pub( crate ) pad_top: Length, /// Padding on the right edge. - pub pad_right: Length, + pub( crate ) pad_right: Length, /// Padding on the bottom edge. - pub pad_bottom: Length, + pub( crate ) pad_bottom: Length, /// Padding on the left edge. - pub pad_left: Length, - pub opacity: f32, + pub( crate ) pad_left: Length, + pub( crate ) opacity: f32, /// Optional `( color, width_px )` border stroke painted around the /// container's rounded rectangle, after the fill / surface and /// 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 /// offers more, the container reports its preferred width as /// `min( offered, max_width )` so it does not stretch to fill. /// Mirrors the same flag on [`Column`](crate::layout::column::Column) /// and [`Row`](crate::layout::row::Row). - pub max_width: Option, + pub( crate ) max_width: Option, /// When true, the contents of this container are announced by /// assistive technologies as a `Live::Polite` region — useful for /// toasts, status banners and OSDs that need to be read on /// appearance even when the user has not navigated to them. - pub a11y_live: bool, + pub( crate ) a11y_live: bool, } impl Container diff --git a/src/widget/date_picker/mod.rs b/src/widget/date_picker/mod.rs index cd244a4..fad6cdd 100644 --- a/src/widget/date_picker/mod.rs +++ b/src/widget/date_picker/mod.rs @@ -133,7 +133,7 @@ pub struct Locale { /// First day of the week (0 = Sunday, 1 = Monday). Default 1 /// (Monday) — the convention used across most of Europe. - pub first_dow: u8, + pub( crate ) first_dow: u8, } impl Locale @@ -193,14 +193,14 @@ fn dow_short( dow: u8 ) -> String /// Calendar date selector. pub struct DatePicker { - pub value: Date, - pub view_year: i32, - pub view_month: u8, - pub today: Option, - pub on_change: Option Msg>>, - pub on_navigate: Option Msg>>, - pub locale: Locale, - pub width: Option, + pub( crate ) value: Date, + pub( crate ) view_year: i32, + pub( crate ) view_month: u8, + pub( crate ) today: Option, + pub( crate ) on_change: Option Msg>>, + pub( crate ) on_navigate: Option Msg>>, + pub( crate ) locale: Locale, + pub( crate ) width: Option, } impl DatePicker diff --git a/src/widget/dialog/mod.rs b/src/widget/dialog/mod.rs index 440f2c8..84a8e2a 100644 --- a/src/widget/dialog/mod.rs +++ b/src/widget/dialog/mod.rs @@ -103,23 +103,23 @@ pub const SUBTITLE_SIZE: f32 = 14.0; /// and action buttons. pub struct Dialog { - pub title: Option, - pub subtitle: Option, + pub( crate ) title: Option, + pub( crate ) subtitle: Option, /// User-supplied body element rendered between the subtitle and /// the action row. Use this for sliders, lists, spinners or any /// other custom content. - pub body: Option>>, - pub actions: Vec>, - pub modal: bool, + pub( crate ) body: Option>>, + pub( crate ) actions: Vec>, + pub( crate ) modal: bool, /// Optional message dispatched when the user taps the scrim /// (strictly outside the card). Always `None` when [`Self::modal`] /// is `true`. Construction panics if both are set together. - pub dismiss_msg: Option, + pub( crate ) dismiss_msg: Option, /// Optional message dispatched when the user presses `Escape` /// while the dialog is on screen. Wire this to the same message /// your "Cancel" action button uses. - pub cancel_msg: Option, - pub max_width: f32, + pub( crate ) cancel_msg: Option, + pub( crate ) max_width: f32, } impl Default for Dialog diff --git a/src/widget/element.rs b/src/widget/element.rs index ec50710..2eae9e6 100644 --- a/src/widget/element.rs +++ b/src/widget/element.rs @@ -66,8 +66,8 @@ impl Element Element::VSlider( s ) => s.preferred_size( max_width, canvas ), Element::Container( c ) => c.preferred_size( max_width, canvas ), Element::Toggle( t ) => t.preferred_size( max_width, canvas ), - Element::Separator( s ) => s.preferred_size( max_width ), - Element::ProgressBar( p ) => p.preferred_size( max_width ), + Element::Separator( s ) => s.preferred_size( max_width, canvas ), + Element::ProgressBar( p ) => p.preferred_size( max_width, canvas ), Element::Checkbox( c ) => c.preferred_size( max_width, canvas ), Element::Radio( r ) => r.preferred_size( max_width, canvas ), Element::ListItem( l ) => l.preferred_size( max_width, canvas ), @@ -75,7 +75,7 @@ impl Element Element::Pressable( p ) => p.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::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::Carousel( c ) => c.preferred_size( max_width, canvas ), } @@ -329,6 +329,9 @@ impl Element on_change: s.on_change.clone(), axis: slider::SliderAxis::Horizontal, 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 ) => @@ -338,6 +341,9 @@ impl Element on_change: s.on_change.clone(), axis: slider::SliderAxis::Vertical, 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, diff --git a/src/widget/external/mod.rs b/src/widget/external/mod.rs index 4a477a9..3ff6570 100644 --- a/src/widget/external/mod.rs +++ b/src/widget/external/mod.rs @@ -65,13 +65,13 @@ use crate::types::Rect; pub struct External { /// Reserved width in logical pixels. - pub width: f32, + pub( crate ) width: f32, /// Reserved height in logical pixels. - pub height: f32, + pub( crate ) height: f32, /// Source of the GL texture sampled at draw time. - pub source: ExternalSource, + pub( crate ) source: ExternalSource, /// Opacity multiplier in `[0.0, 1.0]`. - pub opacity: f32, + pub( crate ) opacity: f32, } /// Backends an [`External`] widget can pull pixels from. diff --git a/src/widget/flex/mod.rs b/src/widget/flex/mod.rs index 27cf103..c46c6cc 100644 --- a/src/widget/flex/mod.rs +++ b/src/widget/flex/mod.rs @@ -36,8 +36,8 @@ use super::Element; /// yet implemented. pub struct Flex { - pub child: Box>, - pub weight: u32, + pub( crate ) child: Box>, + pub( crate ) weight: u32, } impl Flex diff --git a/src/widget/handlers.rs b/src/widget/handlers.rs index 80dbf10..c66a4fb 100644 --- a/src/widget/handlers.rs +++ b/src/widget/handlers.rs @@ -78,6 +78,10 @@ pub enum WidgetHandlers on_change: Option Msg>>, axis: slider::SliderAxis, 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 Clone for WidgetHandlers 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 WidgetHandlers { 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, } } + /// 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 /// widget opted into press-and-hold repeat. The runtime reads /// this on press to decide whether to fire `press_msg` diff --git a/src/widget/image/mod.rs b/src/widget/image/mod.rs index e557d12..1ab9ce2 100644 --- a/src/widget/image/mod.rs +++ b/src/widget/image/mod.rs @@ -30,17 +30,22 @@ use crate::render::Canvas; pub struct Image { /// Raw RGBA pixel data (4 bytes per pixel, straight alpha). - pub rgba: Arc>, + pub( crate ) rgba: Arc>, /// Pixel width of the source image. - pub width: u32, + pub( crate ) width: u32, /// 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). - pub cover: bool, + pub( crate ) cover: bool, /// 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, /// Opacity multiplier in `[0.0, 1.0]`. Default: `1.0`. - pub opacity: f32, + pub( crate ) opacity: f32, } impl Image @@ -50,7 +55,7 @@ impl Image /// `width` and `height` must match the dimensions of `rgba`. pub fn new( rgba: Arc>, 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 @@ -59,7 +64,7 @@ impl Image { let img = image::open( path )?.into_rgba8(); 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). @@ -77,6 +82,18 @@ impl Image 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 ) -> Self + { + self.short_side = Some( extent.into() ); + self + } + /// Set the opacity multiplier. Clamped to `[0.0, 1.0]`. pub fn opacity( mut self, o: f32 ) -> Self { @@ -88,6 +105,22 @@ impl Image /// `canvas` is used to resolve viewport-relative [`Length`] values. 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 { let vp = canvas.viewport_layout(); diff --git a/src/widget/image/tests.rs b/src/widget/image/tests.rs index 5b07c20..cff9a3f 100644 --- a/src/widget/image/tests.rs +++ b/src/widget/image/tests.rs @@ -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 ) ); } +#[ 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 ─────────────────────────────────────────────────────────── #[ test ] diff --git a/src/widget/list_item/mod.rs b/src/widget/list_item/mod.rs index 14adb6e..53c1d87 100644 --- a/src/widget/list_item/mod.rs +++ b/src/widget/list_item/mod.rs @@ -38,27 +38,27 @@ pub struct ListItem { /// Primary label (always visible, top-aligned when a subtitle is /// present). - pub label: String, + pub( crate ) label: String, /// Optional secondary line drawn below the label in muted colour. /// Doubles the row height when set. - pub subtitle: Option, + pub( crate ) subtitle: Option, /// Optional right-aligned text (current setting, badge count, "›" /// disclosure). Drawn in muted colour. - pub trailing: Option, + pub( crate ) trailing: Option, /// Message emitted on tap. `None` keeps the item visible but inert. - pub on_press: Option, + pub( crate ) on_press: Option, /// Optional stable identifier for focus management. - pub id: Option, + pub( crate ) id: Option, /// `true` paints the row with the dark selected surface and white /// text, regardless of hover / press state. Use to indicate the /// active item in a list of choices (combo dropdown, settings /// group with a single active value). - pub selected: bool, + pub( crate ) selected: bool, /// Optional leading icon — RGBA bytes + native dimensions. The /// row reserves `theme::ICON_SIZE + theme::ICON_GAP` on the left /// when this is set, and offsets the label / subtitle by the /// same amount. Pass `None` to keep the icon-less layout. - pub icon: Option<( Arc>, u32, u32 )>, + pub( crate ) icon: Option<( Arc>, u32, u32 )>, } impl ListItem @@ -128,9 +128,14 @@ impl ListItem 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 ) } @@ -172,12 +177,14 @@ impl ListItem 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 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 { - 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 @@ -187,30 +194,34 @@ impl ListItem // is in `theme::icon_rgba` rather than baked here. 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_rect = Rect { x: rect.x + theme::PAD_H, y: icon_y, width: theme::ICON_SIZE, height: theme::ICON_SIZE }; + let icon_size = canvas.geom_px( 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 ); - rect.x + theme::PAD_H + theme::ICON_SIZE + theme::ICON_GAP + rect.x + pad_h + icon_size + icon_gap } 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 { - let sub_y = rect.y + rect.height * 0.62 + theme::SUBTITLE_SIZE * 0.3; - canvas.draw_text( sub, text_x, sub_y, theme::SUBTITLE_SIZE, subtitle_color ); + let sub_size = canvas.font_px( theme::SUBTITLE_SIZE ); + 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 { - let tw = canvas.measure_text( trail, theme::TRAILING_SIZE ); - let tx = rect.x + rect.width - theme::PAD_H - tw; - let ty = rect.y + ( rect.height + theme::TRAILING_SIZE ) / 2.0 - 2.0; - canvas.draw_text( trail, tx, ty, theme::TRAILING_SIZE, trailing_color ); + let trail_size = canvas.font_px( theme::TRAILING_SIZE ); + let tw = canvas.measure_text( trail, trail_size ); + let tx = rect.x + rect.width - pad_h - tw; + let ty = rect.y + ( rect.height + trail_size ) / 2.0 - 2.0; + canvas.draw_text( trail, tx, ty, trail_size, trailing_color ); } } diff --git a/src/widget/notebook/mod.rs b/src/widget/notebook/mod.rs index 7b26630..68072f3 100644 --- a/src/widget/notebook/mod.rs +++ b/src/widget/notebook/mod.rs @@ -37,6 +37,7 @@ use std::sync::Arc; use crate::layout::column::column; use crate::layout::spacer::spacer; +use crate::types::Length; use super::Element; @@ -49,8 +50,8 @@ mod tests; /// element to show when this page is active. pub struct NotebookPage { - pub label: String, - pub view: Element, + pub( crate ) label: String, + pub( crate ) view: Element, } /// Paginated tab container. @@ -61,9 +62,9 @@ pub struct NotebookPage /// notebook with 50 pages costs the same to draw as one with 5. pub struct Notebook { - pub pages: Vec>, - pub selected: usize, - pub on_select: Option Msg>>, + pub( crate ) pages: Vec>, + pub( crate ) selected: usize, + pub( crate ) on_select: Option Msg>>, } impl Notebook @@ -138,7 +139,7 @@ impl Notebook }; column() - .spacing( theme::SPACING ) + .spacing( Length::widget( theme::SPACING ) ) .push( strip ) .push( active_view ) .into() diff --git a/src/widget/pressable/mod.rs b/src/widget/pressable/mod.rs index 59a212a..a6833d9 100644 --- a/src/widget/pressable/mod.rs +++ b/src/widget/pressable/mod.rs @@ -47,16 +47,16 @@ mod tests; /// ``` pub struct Pressable { - pub child: Box>, - pub on_press: Option, - pub on_long_press: Option, + pub( crate ) child: Box>, + pub( crate ) on_press: Option, + pub( crate ) on_long_press: Option, /// Drag-arm message. Fired alongside `on_long_press` when the touch /// hold timer elapses, AND fired by mouse left-button motion past /// the drag-promotion threshold without waiting for the timer. The /// caller uses this to arm any per-app drag state (in crustace, /// `dragging_item`); a widget that opens a context menu but isn't /// draggable leaves this `None`. - pub on_drag_start: Option, + pub( crate ) on_drag_start: Option, /// Keyboard `Escape`-key message. The runtime scans every laid-out /// pressable's snapshot and fires the topmost (highest `flat_idx`) /// `on_escape` it finds before the default ESC fallthrough chain. @@ -64,7 +64,7 @@ pub struct Pressable /// modal dialog without each app having to wire a global keyboard /// hook — but available to any composite that needs the same /// semantics. - pub on_escape: Option, + pub( crate ) on_escape: Option, /// Make the pressable hit-testable even when no callback is set. /// A `swallow=true` pressable consumes pointer events at its hit /// rect and emits no message — used by @@ -73,9 +73,9 @@ pub struct Pressable /// when the user clicks on the dialog body itself. Has no effect /// when any handler is also set; in that case the handler determines /// the message and `swallow` is implicit. - pub swallow: bool, - pub id: Option, - pub cursor: Option, + pub( crate ) swallow: bool, + pub( crate ) id: Option, + pub( crate ) cursor: Option, } impl Pressable diff --git a/src/widget/progress_bar/mod.rs b/src/widget/progress_bar/mod.rs index 084b241..37be94f 100644 --- a/src/widget/progress_bar/mod.rs +++ b/src/widget/progress_bar/mod.rs @@ -34,9 +34,9 @@ mod tests; pub struct ProgressBar { /// 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. - pub fill: Color, + pub( crate ) fill: Color, } impl ProgressBar @@ -58,22 +58,23 @@ impl ProgressBar /// Return the preferred `(width, height)`. Width fills the available /// `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 ) { - let track_y = rect.y + ( rect.height - theme::TRACK_H ) / 2.0; - let track_r = theme::TRACK_H / 2.0; + let track_h = canvas.geom_px( theme::TRACK_H ); + let track_y = rect.y + ( rect.height - track_h ) / 2.0; + let track_r = track_h / 2.0; let track_rect = Rect { x: rect.x, y: track_y, width: rect.width, - height: theme::TRACK_H, + height: track_h, }; canvas.fill_rect( track_rect, theme::track_bg(), track_r ); @@ -85,7 +86,7 @@ impl ProgressBar x: rect.x, y: track_y, width: fill_w, - height: theme::TRACK_H, + height: track_h, }; canvas.fill_rect( fill_rect, self.fill, track_r ); } diff --git a/src/widget/progress_bar/tests.rs b/src/widget/progress_bar/tests.rs index 18f5af7..1b30ab8 100644 --- a/src/widget/progress_bar/tests.rs +++ b/src/widget/progress_bar/tests.rs @@ -16,6 +16,6 @@ fn value_clamped_on_creation() fn preferred_width_fills_available() { 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 ); } diff --git a/src/widget/radio/mod.rs b/src/widget/radio/mod.rs index 47e86ec..c786b1e 100644 --- a/src/widget/radio/mod.rs +++ b/src/widget/radio/mod.rs @@ -40,14 +40,14 @@ pub struct Radio { /// Whether this option is currently selected. Drawn from this field /// every frame. - pub selected: bool, + pub( crate ) selected: bool, /// Message emitted when the user picks this option. `None` leaves the /// radio inert. - pub on_select: Option, + pub( crate ) on_select: Option, /// Optional label drawn to the right of the circle. - pub label: Option, + pub( crate ) label: Option, /// Optional stable identifier for focus management. - pub id: Option, + pub( crate ) id: Option, } impl Radio @@ -83,14 +83,15 @@ impl Radio 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 text_w = canvas.measure_text( label, theme::FONT_SIZE ); - ( theme::OUTER_SIZE + theme::GAP + text_w ).min( max_width ) + let text_w = canvas.measure_text( label, canvas.font_px( theme::FONT_SIZE ) ); + ( outer + canvas.geom_px( theme::GAP ) + text_w ).min( max_width ) } 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` @@ -102,28 +103,30 @@ impl Radio 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_r = theme::OUTER_SIZE / 2.0; + let outer_size = canvas.geom_px( theme::OUTER_SIZE ); + let circle_y = rect.y + ( rect.height - outer_size ) / 2.0; + let outer_r = outer_size / 2.0; let outer_rect = Rect { x: rect.x, y: circle_y, - width: theme::OUTER_SIZE, - height: theme::OUTER_SIZE, + width: outer_size, + height: outer_size, }; if self.selected { 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 cy = circle_y + outer_r; let dot_rect = Rect { x: cx - dot_r, y: cy - dot_r, - width: theme::DOT_SIZE, - height: theme::DOT_SIZE, + width: dot_size, + height: dot_size, }; canvas.fill_rect( dot_rect, theme::dot_color(), dot_r ); } else { @@ -138,9 +141,10 @@ impl Radio if let Some( ref label ) = self.label { - let text_x = rect.x + theme::OUTER_SIZE + theme::GAP; - let text_y = rect.y + ( rect.height + theme::FONT_SIZE ) / 2.0 - 2.0; - canvas.draw_text( label, text_x, text_y, theme::FONT_SIZE, theme::label_color() ); + let fs = canvas.font_px( theme::FONT_SIZE ); + let text_x = rect.x + outer_size + canvas.geom_px( theme::GAP ); + let text_y = rect.y + ( rect.height + fs ) / 2.0 - 2.0; + canvas.draw_text( label, text_x, text_y, fs, theme::label_color() ); } } diff --git a/src/widget/rich_text/mod.rs b/src/widget/rich_text/mod.rs index 64d3fb4..e7bfa53 100644 --- a/src/widget/rich_text/mod.rs +++ b/src/widget/rich_text/mod.rs @@ -22,9 +22,9 @@ mod tests; /// message to emit when it is tapped. pub struct LinkSpan { - pub start: usize, - pub end: usize, - pub msg: Msg, + pub( crate ) start: usize, + pub( crate ) end: usize, + pub( crate ) msg: Msg, } /// A wrapped paragraph with clickable link ranges — the ltk counterpart of an @@ -33,12 +33,12 @@ pub struct LinkSpan /// hit rect per link line so taps land on the link rather than the paragraph. pub struct RichText { - pub content: String, - pub size: Length, - pub color: Color, - pub link_color: Color, - pub font: Option<( String, u16, FontStyle )>, - pub links: Vec>, + pub( crate ) content: String, + pub( crate ) size: Length, + pub( crate ) color: Color, + pub( crate ) link_color: Color, + pub( crate ) font: Option<( String, u16, FontStyle )>, + pub( crate ) links: Vec>, } impl RichText diff --git a/src/widget/scroll/mod.rs b/src/widget/scroll/mod.rs index 335c492..2f00fd8 100644 --- a/src/widget/scroll/mod.rs +++ b/src/widget/scroll/mod.rs @@ -61,11 +61,11 @@ impl ScrollAxis pub struct Scroll { /// The single child element drawn inside the scrollable viewport. - pub child: Box>, + pub( crate ) child: Box>, /// Optional stable identifier — used as scroll state key. - pub id: Option, + pub( crate ) id: Option, /// Which axis (or both) this viewport scrolls along. - pub axis: ScrollAxis, + pub( crate ) axis: ScrollAxis, } impl Scroll diff --git a/src/widget/separator/mod.rs b/src/widget/separator/mod.rs index ebe5fc8..05781ac 100644 --- a/src/widget/separator/mod.rs +++ b/src/widget/separator/mod.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use crate::types::{ Color, Rect }; +use crate::types::{ Color, Length, Rect }; use crate::render::Canvas; use super::Element; @@ -30,13 +30,16 @@ mod theme; pub struct Separator { /// Stroke colour. Defaults to the theme's `divider` palette slot. - pub color: Color, - /// Line thickness in logical pixels. - pub thickness: f32, + pub( crate ) color: Color, + /// Line thickness. `None` follows the process [`crate::WidgetScaling`] + /// mode; `Some( len )` pins it (including `Length::px( 0.0 )`). + pub( crate ) thickness: Option, /// Vertical padding above and below the line, baked into /// `preferred_size` so the divider visually centres in the row a - /// parent column allocates. - pub pad_v: f32, + /// parent column allocates. `None` follows the + /// [`crate::WidgetScaling`] mode; `Some( len )` pins it — pass + /// `Length::px( 0.0 )` for a flush, padding-less divider. + pub( crate ) pad_v: Option, } impl Separator @@ -48,11 +51,29 @@ impl Separator Self { color: theme::color(), - thickness: theme::THICKNESS, - pad_v: theme::PAD_V, + thickness: None, + 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 /// destructive actions. pub fn color( mut self, color: Color ) -> Self @@ -61,25 +82,36 @@ impl Separator self } - /// Override the line thickness. Defaults to 1 logical pixel. - pub fn thickness( mut self, t: f32 ) -> Self + /// Override the line thickness. Accepts logical `f32` pixels or any + /// [`Length`]. Without this it follows the widget-scaling mode. + pub fn thickness( mut self, t: impl Into ) -> 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 ) -> Self + { + self.pad_v = Some( p.into() ); self } /// Return the preferred `(width, height)`. Width is `max_width`; /// 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`. pub fn draw( &self, canvas: &mut Canvas, rect: Rect ) { 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 ); } } diff --git a/src/widget/separator/tests.rs b/src/widget/separator/tests.rs index f898eb0..1eca012 100644 --- a/src/widget/separator/tests.rs +++ b/src/widget/separator/tests.rs @@ -4,16 +4,30 @@ use super::*; #[test] -fn default_thickness() +fn default_thickness_and_pad_follow_the_mode() { 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] fn preferred_height_includes_padding() { + let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() ); let s = separator(); - let ( _, h ) = s.preferred_size( 200.0 ); - assert_eq!( h, theme::THICKNESS + theme::PAD_V * 2.0 ); + let canvas = crate::render::Canvas::new( 412, 900 ); + let ( _, h ) = s.preferred_size( 200.0, &canvas ); + assert_eq!( h, canvas.geom_px( theme::THICKNESS ) + canvas.geom_px( theme::PAD_V ) * 2.0 ); } diff --git a/src/widget/slider/mod.rs b/src/widget/slider/mod.rs index b4dd9fd..457f492 100644 --- a/src/widget/slider/mod.rs +++ b/src/widget/slider/mod.rs @@ -27,11 +27,13 @@ pub enum SliderAxis /// [`Slider`] and [`crate::widget::vslider::VSlider`] through the same call /// site by consulting the [`SliderAxis`] stored in the widget's handler /// 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 { - 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 ), } } @@ -83,15 +85,29 @@ pub( crate ) fn intersect_clip( saved: &[ Rect ], inner: Rect ) -> Vec /// slider's layout rect. Pure — depends only on `theme::THUMB_SIZE`. Lifted /// out of [`Slider`] so input handlers can call it directly from /// [`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_end = rect.x + rect.width - pad; let track_w = ( track_end - track_start ).max( 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. /// /// 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 { /// 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. /// `Arc` (not `Box`) so the layout pass can clone it into the per-leaf /// handler snapshot for O(1) dispatch on input events. - pub on_change: Option Msg>>, + pub( crate ) on_change: Option Msg>>, /// Theme slot id for the track background pill. Defaults to the /// generic `surface-slider-track`; override per-instance to opt /// into the `-flat` (no per-surface backdrop) variant when the /// slider already lives inside a panel-wide blur, or any other /// custom slot. - pub track_surface: &'static str, + pub( crate ) track_surface: &'static str, /// Theme slot id for the rising / leftward fill. Same shape as /// [`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 /// a thicker white border (accent inner pill + 4 px white ring) /// 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 /// [`Paint`](crate::theme::Paint) — typically a /// [`Paint::Linear`](crate::theme::Paint::Linear) for spectrum / /// hue pickers. When set, the active-side fill is suppressed /// because a spectrum already conveys position information /// through colour and the thumb shows where the user is. - pub track_paint: Option, + pub( crate ) track_paint: Option, } impl Slider @@ -204,15 +220,17 @@ impl Slider } /// 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`. + /// 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 { - 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 @@ -237,12 +255,17 @@ impl Slider /// theme still paints a usable slider. pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool ) { - let pad = theme::THUMB_SIZE / 2.0; - let track_y = rect.y + (rect.height - theme::TRACK_H) / 2.0; + // Resolve the thumb / track through the widget-scaling mode. The + // 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_end = rect.x + rect.width - pad; 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. let track_rect = Rect @@ -250,7 +273,7 @@ impl Slider x: track_start, y: track_y, width: track_w, - height: theme::TRACK_H, + height: track_h, }; if let Some( paint ) = self.track_paint.as_ref() { @@ -294,7 +317,7 @@ impl Slider x: track_start, y: track_y, width: fill_w, - height: theme::TRACK_H, + height: track_h, }; let saved_clip = canvas.clip_bounds(); let band = intersect_clip( &saved_clip, visible ); @@ -357,13 +380,13 @@ impl Slider } else { - let thumb_r = theme::THUMB_SIZE / 2.0; + let thumb_r = thumb / 2.0; let thumb_rect = Rect { x: thumb_cx - thumb_r, y: thumb_cy - thumb_r, - width: theme::THUMB_SIZE, - height: theme::THUMB_SIZE, + width: thumb, + height: thumb, }; canvas.fill_rect( thumb_rect, theme::thumb(), thumb_r ); canvas.stroke_rect( thumb_rect, theme::thumb_border(), theme::THUMB_BORDER_W, thumb_r ); diff --git a/src/widget/slider/tests.rs b/src/widget/slider/tests.rs index dc79c36..88c5ce6 100644 --- a/src/widget/slider/tests.rs +++ b/src/widget/slider/tests.rs @@ -48,6 +48,7 @@ fn axis_dispatch_horizontal_uses_x() rect, Point { x: 200.0, y: 9999.0 }, SliderAxis::Horizontal, + super::thumb_design_px(), ); assert_eq!( v, 1.0 ); } @@ -61,10 +62,26 @@ fn axis_dispatch_vertical_uses_y() rect, Point { x: 9999.0, y: 0.0 }, SliderAxis::Vertical, + super::thumb_design_px(), ); 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] fn track_paint_default_is_none() { diff --git a/src/widget/spinner/mod.rs b/src/widget/spinner/mod.rs index 46e0598..589c253 100644 --- a/src/widget/spinner/mod.rs +++ b/src/widget/spinner/mod.rs @@ -39,14 +39,15 @@ pub struct Spinner /// Rotation phase. Only the fractional part is consumed, so any /// monotonically increasing source works (`elapsed.as_secs_f32()`, /// frame count divided by FPS, etc.). - pub phase: f32, + pub( crate ) phase: f32, /// 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 - /// laid-out rect target this size. - pub size: f32, + /// laid-out rect target this size. The `0.0` default follows the + /// process [`crate::WidgetScaling`] mode; any positive value pins it. + pub( crate ) size: f32, /// Stroke width of the arc and the dim guide ring. - pub stroke_w: f32, + pub( crate ) stroke_w: f32, } impl Spinner @@ -58,11 +59,18 @@ impl Spinner { phase: 0.0, color: theme::fill(), - size: theme::SIZE, + size: 0.0, 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 /// part, so callers can pass an unbounded monotonic clock value. pub fn phase( mut self, p: f32 ) -> Self @@ -93,9 +101,9 @@ impl Spinner } /// 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 ) } diff --git a/src/widget/spinner/tests.rs b/src/widget/spinner/tests.rs index 37f0287..a9b13af 100644 --- a/src/widget/spinner/tests.rs +++ b/src/widget/spinner/tests.rs @@ -8,7 +8,7 @@ fn defaults() { let s = spinner(); 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 ); } @@ -25,14 +25,15 @@ fn builders_apply() fn preferred_size_clamps_to_max_width() { let s = spinner().size( 100.0 ); - assert_eq!( s.preferred_size( 40.0 ), ( 40.0, 40.0 ) ); - assert_eq!( s.preferred_size( 200.0 ), ( 100.0, 100.0 ) ); + let canvas = crate::render::Canvas::new( 412, 900 ); + 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 ] fn preferred_size_is_square() { 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 ); } diff --git a/src/widget/tab_bar/mod.rs b/src/widget/tab_bar/mod.rs index 6e00673..5487c47 100644 --- a/src/widget/tab_bar/mod.rs +++ b/src/widget/tab_bar/mod.rs @@ -20,7 +20,7 @@ //! # }} //! ``` -use crate::types::Color; +use crate::types::{ Color, Length }; use super::Element; mod theme; @@ -35,12 +35,12 @@ mod tests; /// expected). pub struct TabBar { - pub labels: Vec, - pub selected: usize, - pub on_select: Option Msg>>, + pub( crate ) labels: Vec, + pub( crate ) selected: usize, + pub( crate ) on_select: Option Msg>>, /// Background colour of the strip itself. `None` paints no /// background, letting the parent surface show through. - pub strip_bg: Option, + pub( crate ) strip_bg: Option, } impl TabBar @@ -104,7 +104,7 @@ impl TabBar let cb = self.on_select.clone(); let labels = self.labels; - let mut r = row::().padding( theme::PADDING ).spacing( theme::SPACING ); + let mut r = row::().padding( Length::widget( theme::PADDING ) ).spacing( Length::widget( theme::SPACING ) ); for ( i, label ) in labels.into_iter().enumerate() { let is_active = i == selected; diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs index 3e02d12..af63c19 100644 --- a/src/widget/text/mod.rs +++ b/src/widget/text/mod.rs @@ -33,24 +33,24 @@ pub enum TextAlign /// either word-wrapped or kept on one line with ellipsis truncation. pub struct Text { - pub content: String, + pub( crate ) content: String, /// Font size as a [`Length`]. Resolved against the surface's logical /// viewport at layout time, so a `Length::Vmin( 5.0 )` heading scales /// with the screen instead of being frozen at a px constant. - pub size: Length, - pub color: Color, - pub align: TextAlign, - pub wrap: bool, + pub( crate ) size: Length, + pub( crate ) color: Color, + pub( crate ) align: TextAlign, + pub( crate ) wrap: bool, /// When `true` (default), overflowing single-line text is truncated /// with an ellipsis. When `false`, the full string is painted even /// if it extends past the layout rect — useful for very short /// labels (calendar day numbers, day-of-week stubs) where a couple /// of pixels of overflow is invisible but "..." is noisy. - pub truncate: bool, + pub( crate ) truncate: bool, /// Optional `(family, weight, style)` override resolved through /// the active theme's font registry on every draw. `None` keeps /// 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 diff --git a/src/widget/text_edit/draw.rs b/src/widget/text_edit/draw.rs index 130ece7..ad918e0 100644 --- a/src/widget/text_edit/draw.rs +++ b/src/widget/text_edit/draw.rs @@ -81,7 +81,7 @@ impl TextEdit 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 = self.display_text(); let secure = self.effective_secure(); diff --git a/src/widget/text_edit/hit_test.rs b/src/widget/text_edit/hit_test.rs index b0f64c3..125713c 100644 --- a/src/widget/text_edit/hit_test.rs +++ b/src/widget/text_edit/hit_test.rs @@ -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 ) } 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 // applies so a click lands on the glyph the user actually // sees, not on the byte offset that would correspond to an diff --git a/src/widget/text_edit/mod.rs b/src/widget/text_edit/mod.rs index 342788e..2b5a602 100644 --- a/src/widget/text_edit/mod.rs +++ b/src/widget/text_edit/mod.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use crate::render::Canvas; use crate::secure_mem::secure_zero; -use crate::types::{ Rect, WidgetId }; +use crate::types::{ Length, Rect, WidgetId }; 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 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. /// /// 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 { /// Placeholder text shown when the field is empty. - pub placeholder: String, + pub( crate ) placeholder: String, /// Current field value. - pub value: String, + pub( crate ) value: String, /// Callback invoked with the new value on every keystroke. /// `Arc` (not `Box`) so the layout pass can clone it into the per-leaf /// handler snapshot for O(1) dispatch on input events. - pub on_change: Option Msg>>, + pub( crate ) on_change: Option Msg>>, /// Message emitted when the user presses Enter. - pub on_submit: Option, + pub( crate ) on_submit: Option, /// 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 /// box grows to [`Self::rows`] visible rows, line breaks in the /// value are honoured at draw time, and pressing Enter inserts a /// `\n` rather than firing [`Self::on_submit`]. Ignored when /// [`Self::secure`] is set — passwords are always single-line. - pub multiline: bool, + pub( crate ) multiline: bool, /// Visible row count when `multiline` is `true`. Drives /// `preferred_size`'s height calculation so a multiline field /// claims a sensible vertical slot in the parent layout. Ignored /// when `multiline` is `false`. - pub rows: u32, + pub( crate ) rows: u32, /// 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. - pub id: Option, + pub( crate ) id: Option, /// Override the pointer cursor shape on hover. `None` falls back /// to the I-beam default that matches every desktop convention. - pub cursor: Option, + pub( crate ) cursor: Option, /// Horizontal alignment of the displayed text inside the inner /// content rect. Only takes effect on the single-line path when /// the value fits inside the inner width — once the value /// overflows, the internal `single_line_scroll_x` helper takes /// over and the alignment offset collapses to `0` so scrolling /// 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 /// when the [`TextEdit`] is dropped inside another container /// that paints its own surface (e.g. the digit cells inside /// [`crate::widget::time_picker::TimePicker`]) and a second pill /// would only add visual noise. - pub borderless: bool, + pub( crate ) borderless: bool, /// Override the preferred width reported to the parent layout. /// Without this the single-line `TextEdit` claims `max_width` and /// fills whatever rect the parent allocates — which is the right /// default for forms but wrong when the field needs to be sized /// to fit a fixed number of glyphs (date / time pickers, inline /// numeric inputs). - pub fixed_width: Option, - /// Font size in pixels for the single-line draw path. Defaults to - /// the theme's `FONT_SIZE` constant. Multiline mode ignores this - /// for now and always uses the default — multiline soft-wrap - /// layout depends on the constant in several places that are not - /// yet parameterised. - pub font_size: f32, + pub( crate ) fixed_width: Option, + /// Font size in pixels for the single-line draw path. `0.0` (the + /// default) follows the process [`crate::WidgetScaling`] mode (fluid by + /// default, via [`crate::Canvas::font_px`]); any positive value pins an + /// explicit size. The sentinel avoids threading a canvas through the + /// handler snapshot; every consumer that reads it has a canvas and + /// 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, /// When `true`, focusing the field selects the whole value so the /// next keystroke replaces it. Standard behaviour for numeric /// pickers and short-form fields where the user usually wants to /// retype rather than edit. Default `false` — long-form fields /// 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 /// `Some( ( visible, on_toggle ) )` the field renders an /// `actions/visible` ↔ `actions/invisible` icon at its right @@ -152,10 +177,10 @@ pub struct TextEdit /// `visible` (overriding `secure`). Set on a field that already /// has `secure( true )` and the explicit flag becomes redundant /// — 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 /// focus and accepts no input — a read-only display styled as a text field. - pub read_only: bool, + pub( crate ) read_only: bool, } impl TextEdit @@ -181,7 +206,8 @@ impl TextEdit align: super::text::TextAlign::Left, borderless: false, fixed_width: None, - font_size: theme::FONT_SIZE, + font_size: 0.0, + height: None, select_on_focus: false, password_toggle: None, read_only: false, @@ -214,15 +240,43 @@ impl TextEdit } } - /// Override the font size used by the single-line draw path. - /// Defaults to the theme's `FONT_SIZE` constant. Ignored in - /// multiline mode. + /// Pin the single-line font size to an explicit fixed px. Without any + /// font-size call the size follows the process [`crate::WidgetScaling`] + /// 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 { self.font_size = px.max( 1.0 ); 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 ) -> Self + { + self.height = Some( h.into() ); + self + } + /// Select the whole value when the field receives focus, so the /// next keystroke replaces it. Default `false`. pub fn select_on_focus( mut self, on: bool ) -> Self @@ -383,7 +437,7 @@ impl TextEdit /// /// Single-line: theme-defined `HEIGHT`. /// 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() { @@ -394,7 +448,10 @@ impl TextEdit let w = self.fixed_width .map( |fw| fw.min( 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 TextEdit } /// 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 / /// flags. pub fn byte_offset_at_self( @@ -501,6 +558,7 @@ impl TextEdit borderless: self.borderless, fixed_width: self.fixed_width, font_size: self.font_size, + height: self.height, select_on_focus: self.select_on_focus, password_toggle: self.password_toggle.clone().map( |( v, m )| ( v, ( *f )( m ) ) ), read_only: self.read_only, diff --git a/src/widget/text_edit/tests.rs b/src/widget/text_edit/tests.rs index 0e8b7dd..a46186a 100644 --- a/src/widget/text_edit/tests.rs +++ b/src/widget/text_edit/tests.rs @@ -368,10 +368,12 @@ fn multiline_builder_enables() fn multiline_grows_preferred_height_with_rows() { 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 single = new_te( "" ); 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 ( _, 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" ); } +#[ 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 ] 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!( !t.borderless ); 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 ); } @@ -462,6 +481,21 @@ fn font_size_builder_clamps_to_at_least_one_pixel() 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 ] fn select_on_focus_builder_toggles_flag() { diff --git a/src/widget/time_picker/mod.rs b/src/widget/time_picker/mod.rs index 1bcc3eb..32352b5 100644 --- a/src/widget/time_picker/mod.rs +++ b/src/widget/time_picker/mod.rs @@ -38,6 +38,7 @@ use std::sync::Arc; use crate::layout::column::column; use crate::layout::row::row; use crate::layout::spacer::spacer; +use crate::types::Length; 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. pub struct TimePicker { - pub value: Time, - pub on_change: Option Msg>>, - pub minute_step: u8, - pub second_step: u8, - pub seconds: bool, - pub twelve_hour: bool, + pub( crate ) value: Time, + pub( crate ) on_change: Option Msg>>, + pub( crate ) minute_step: u8, + pub( crate ) second_step: u8, + pub( crate ) seconds: bool, + pub( crate ) twelve_hour: bool, } impl TimePicker @@ -305,7 +306,7 @@ impl TimePicker None => { text( display ) - .size( theme::VAL_FS ) + .size( Length::widget( theme::VAL_FS ) ) .color( theme::text() ) .align_center() .into() @@ -317,7 +318,7 @@ impl TimePicker text_edit::( "", display ) .borderless( true ) .fixed_width( 72.0 ) - .font_size( theme::VAL_FS ) + .font_size_fluid( theme::VAL_FS ) .align( TextAlign::Center ) .select_on_focus( true ) .on_change( move |s: String| @@ -386,9 +387,9 @@ impl TimePicker ); let minute_col = stepper( minute_field, minute_up, minute_dn ); - let mut units = row::().spacing( theme::SPACING ) + let mut units = row::().spacing( Length::widget( theme::SPACING ) ) .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 ); if self.seconds @@ -406,7 +407,7 @@ impl TimePicker ); let second_col = stepper( second_field, second_up, second_dn ); 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 ); } @@ -427,7 +428,7 @@ impl TimePicker // the whole hh:mm AM block inside the container. A flex // spacer here would defeat the auto-centre and pin AM/PM // 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 @@ -438,7 +439,7 @@ impl TimePicker // AM/PM), so they centre automatically inside the container. container::( units ) .background( theme::surface_alt() ) - .padding( theme::PADDING ) + .padding( Length::widget( theme::PADDING ) ) .radius( theme::RADIUS ) .into() } diff --git a/src/widget/toast/mod.rs b/src/widget/toast/mod.rs index 2edc2f4..e8d05fc 100644 --- a/src/widget/toast/mod.rs +++ b/src/widget/toast/mod.rs @@ -37,7 +37,7 @@ use std::hash::{ Hash, Hasher }; use std::time::Duration; use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec }; -use crate::types::{ Color, WidgetId }; +use crate::types::{ Color, Length, WidgetId }; use super::Element; mod theme; @@ -55,20 +55,20 @@ pub struct Toast /// Stable id for the overlay. Used to derive the [`OverlayId`] so /// the same toast persists across frames when the message stays the /// same. - pub id: WidgetId, + pub( crate ) id: WidgetId, /// Body text rendered inside the pill. - pub message: String, + pub( crate ) message: String, /// Display duration. The runtime does **not** consume this — it is /// returned through [`Toast::duration_value`] so the app's timer /// scheduler can read it back. - pub duration: Duration, + pub( crate ) duration: Duration, /// Optional message fired when the user taps anywhere outside the /// pill. Convenient for "tap anywhere to dismiss" behaviour. - pub on_dismiss: Option, + pub( crate ) on_dismiss: Option, /// Optional override for the pill background colour. - pub bg: Option, + pub( crate ) bg: Option, /// Optional override for the body text colour. - pub fg: Option, + pub( crate ) fg: Option, } impl Toast @@ -165,14 +165,17 @@ impl Toast let bg = self.bg.unwrap_or_else( theme::bg ); 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 = container( text( self.message.clone() ) - .size( theme::FONT_SIZE ) + .size( Length::widget( theme::FONT_SIZE ) ) .color( fg ) ) .background( bg ) - .padding_h( theme::PAD_H ) - .padding_v( 12.0 ) + .padding_h( Length::widget( theme::PAD_H ) ) + .padding_v( Length::widget( 12.0 ) ) .radius( theme::RADIUS ) .into(); @@ -202,12 +205,12 @@ impl Toast let pill: Element = container( text( self.message.clone() ) - .size( theme::FONT_SIZE ) + .size( Length::widget( theme::FONT_SIZE ) ) .color( fg ) ) .background( bg ) - .padding_h( theme::PAD_H ) - .padding_v( 12.0 ) + .padding_h( Length::widget( theme::PAD_H ) ) + .padding_v( Length::widget( 12.0 ) ) .radius( theme::RADIUS ) .into(); @@ -226,7 +229,8 @@ impl Toast id: overlay_id, layer: Layer::Overlay, 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, keyboard_exclusive: false, input_region: None, diff --git a/src/widget/toggle/mod.rs b/src/widget/toggle/mod.rs index cee6c26..ddf74d6 100644 --- a/src/widget/toggle/mod.rs +++ b/src/widget/toggle/mod.rs @@ -38,14 +38,14 @@ pub struct Toggle { /// Current on / off state. Drawn from this field every frame; the /// runtime never mutates it. - pub value: bool, + pub( crate ) value: bool, /// Message emitted on activation. `None` leaves the toggle inert (it /// still renders and takes focus, but does nothing on press). - pub on_toggle: Option, + pub( crate ) on_toggle: Option, /// Optional label drawn to the left of the track. - pub label: Option, + pub( crate ) label: Option, /// Optional stable identifier for focus management. - pub id: Option, + pub( crate ) id: Option, } impl Toggle @@ -92,14 +92,15 @@ impl Toggle /// label is set. Height is the theme-defined row height. 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 text_w = canvas.measure_text( label, theme::FONT_SIZE ); - ( text_w + theme::GAP + theme::TRACK_W ).min( max_width ) + let text_w = canvas.measure_text( label, canvas.font_px( theme::FONT_SIZE ) ); + ( text_w + canvas.geom_px( theme::GAP ) + track_w ).min( max_width ) } 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 @@ -114,43 +115,48 @@ impl Toggle 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 text_y = rect.y + ( rect.height + theme::FONT_SIZE ) / 2.0 - 2.0; - canvas.draw_text( label, rect.x, text_y, theme::FONT_SIZE, theme::label_color() ); - rect.x + rect.width - theme::TRACK_W + let fs = canvas.font_px( theme::FONT_SIZE ); + let text_y = rect.y + ( rect.height + fs ) / 2.0 - 2.0; + canvas.draw_text( label, rect.x, text_y, fs, theme::label_color() ); + rect.x + rect.width - track_w } 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_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 { x: track_x, y: track_y, - width: theme::TRACK_W, - height: theme::TRACK_H, + width: track_w, + height: track_h, }; let track_color = if self.value { theme::track_on() } else { theme::track_off() }; 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 { - track_x + theme::TRACK_W - thumb_pad - theme::THUMB_SIZE / 2.0 + track_x + track_w - thumb_pad - thumb_size / 2.0 } 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_r = theme::THUMB_SIZE / 2.0; + let thumb_cy = track_y + track_h / 2.0; + let thumb_r = thumb_size / 2.0; let thumb_rect = Rect { x: thumb_cx - thumb_r, y: thumb_cy - thumb_r, - width: theme::THUMB_SIZE, - height: theme::THUMB_SIZE, + width: thumb_size, + height: thumb_size, }; canvas.fill_rect( thumb_rect, theme::thumb(), thumb_r ); canvas.stroke_rect( thumb_rect, theme::thumb_border(), theme::THUMB_BORDER_W, thumb_r ); diff --git a/src/widget/tooltip/mod.rs b/src/widget/tooltip/mod.rs index 9f930b8..c12ae10 100644 --- a/src/widget/tooltip/mod.rs +++ b/src/widget/tooltip/mod.rs @@ -41,7 +41,7 @@ use std::collections::hash_map::DefaultHasher; use std::hash::{ Hash, Hasher }; use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec }; -use crate::types::{ Color, WidgetId }; +use crate::types::{ Color, Length, WidgetId }; use super::Element; mod theme; @@ -53,20 +53,20 @@ mod tests; pub struct 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 /// must have been built with `.id( anchor_id )` so the runtime can /// 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 /// strings are clipped at the surface boundary. - pub max_width: u32, + pub( crate ) max_width: u32, /// Optional override for the tooltip background colour. - pub bg: Option, + pub( crate ) bg: Option, /// Optional override for the body text colour. - pub fg: Option, + pub( crate ) fg: Option, /// Optional message fired when the user taps outside the popup. - pub on_dismiss: Option, + pub( crate ) on_dismiss: Option, } impl Tooltip @@ -129,12 +129,12 @@ impl Tooltip let body: Element = container( text( self.message.clone() ) - .size( theme::FONT_SIZE ) + .size( Length::widget( theme::FONT_SIZE ) ) .color( fg ) ) .background( bg ) - .padding_h( theme::PAD_H ) - .padding_v( theme::PAD_V ) + .padding_h( Length::widget( theme::PAD_H ) ) + .padding_v( Length::widget( theme::PAD_V ) ) .radius( theme::RADIUS ) .into(); @@ -143,7 +143,9 @@ impl Tooltip id: overlay_id, layer: Layer::Overlay, 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, keyboard_exclusive: false, input_region: None, diff --git a/src/widget/viewport/mod.rs b/src/widget/viewport/mod.rs index 8bad64b..47b9393 100644 --- a/src/widget/viewport/mod.rs +++ b/src/widget/viewport/mod.rs @@ -12,7 +12,7 @@ use crate::widget::Element; pub struct Viewport { /// The child element to render inside the viewport. - pub child: Box>, + pub( crate ) child: Box>, /// Optional fixed width in logical pixels. When omitted the /// viewport reports `max_width` as its preferred width — i.e. it /// stretches to fill whatever horizontal slice the parent layout @@ -21,15 +21,15 @@ pub struct Viewport /// the viewport's `max_width` claim) or whenever the inner content /// has its own intrinsic width and the viewport is just a vertical /// clip. - pub width: Option, + pub( crate ) width: Option, /// Optional fixed height in logical pixels. When omitted the viewport /// reports the child's natural height. - pub height: Option, + pub( crate ) height: Option, /// 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 /// the leading edge of the animation does not knife-cut against the layer /// below it. - pub fade_bottom: f32, + pub( crate ) fade_bottom: f32, } impl Viewport diff --git a/src/widget/vslider/mod.rs b/src/widget/vslider/mod.rs index c652355..cac765f 100644 --- a/src/widget/vslider/mod.rs +++ b/src/widget/vslider/mod.rs @@ -35,8 +35,8 @@ pub fn value_from_y_in_rect( rect: Rect, y: f32 ) -> f32 /// offers — it is intrinsically sized, not filler. /// /// The widget renders a rounded track in `palette.surface_alt` and, on top, -/// a rising pill in `palette.accent` whose height is proportional to -/// [`VSlider::value`]. No separate thumb is drawn; the top edge of the fill +/// a rising pill in `palette.accent` whose height is proportional to its +/// `value`. No separate thumb is drawn; the top edge of the fill /// itself acts as the value indicator. /// /// ```rust,no_run @@ -64,25 +64,25 @@ pub struct VSlider { /// Current value in `[0.0, 1.0]`. `0.0` paints no fill; `1.0` fills the /// whole pill. - pub value: f32, + pub( crate ) value: f32, /// 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`]. - pub height: Length, + pub( crate ) height: Length, /// 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 /// per-leaf handler snapshot for O(1) dispatch on input events. - pub on_change: Option Msg>>, + pub( crate ) on_change: Option Msg>>, /// Theme slot id for the unfilled track. Defaults to /// `surface-slider-track`. Override with [`VSlider::track_surface`] /// when the slider lives inside a panel that already provides its /// own backdrop blur — point the slot at a `*-flat` variant /// (no `backdrop` field) so the pipeline does not run a redundant /// 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 /// [`Self::track_surface`] but for the rising fill. - pub fill_surface: &'static str, + pub( crate ) fill_surface: &'static str, } impl VSlider @@ -93,8 +93,8 @@ impl VSlider Self { value: value.clamp( 0.0, 1.0 ), - width: Length::px( theme::WIDTH ), - height: Length::px( theme::HEIGHT ), + width: Length::widget( theme::WIDTH ), + height: Length::widget( theme::HEIGHT ), on_change: None, track_surface: theme::SURFACE_TRACK, fill_surface: theme::SURFACE_FILL, diff --git a/src/widget/vslider/tests.rs b/src/widget/vslider/tests.rs index 70f8c99..795d8de 100644 --- a/src/widget/vslider/tests.rs +++ b/src/widget/vslider/tests.rs @@ -88,20 +88,23 @@ fn preferred_size_ignores_max_width() { // A VSlider is intrinsically sized — the parent's max_width doesn't // change what we return. + let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() ); let canvas = make_canvas(); let s: VSlider<()> = vslider( 0.5 ); let ( w_small, _ ) = s.preferred_size( 10.0, &canvas ); let ( w_big, _ ) = s.preferred_size( 9_999.0, &canvas ); - assert_eq!( w_small, theme::WIDTH ); - assert_eq!( w_big, theme::WIDTH ); + // The intrinsic width now follows the widget-scaling mode. + assert_eq!( w_small, canvas.geom_px( theme::WIDTH ) ); + assert_eq!( w_big, canvas.geom_px( theme::WIDTH ) ); } #[ 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 ); - assert_eq!( s.width, Length::px( theme::WIDTH ) ); - assert_eq!( s.height, Length::px( theme::HEIGHT ) ); + assert_eq!( s.width, Length::widget( theme::WIDTH ) ); + assert_eq!( s.height, Length::widget( theme::HEIGHT ) ); } #[ test ] diff --git a/src/widget/window_button/mod.rs b/src/widget/window_button/mod.rs index eae94f9..db73a5e 100644 --- a/src/widget/window_button/mod.rs +++ b/src/widget/window_button/mod.rs @@ -38,23 +38,24 @@ pub enum WindowButtonKind pub struct WindowButton { /// Which decoration role this button paints. - pub kind: WindowButtonKind, + pub( crate ) kind: WindowButtonKind, /// Message emitted on activation. `None` greys the button and skips /// the hover / pressed surface — useful for "maximize disabled" on /// fixed-size windows. - pub on_press: Option, - /// Square hit-target size in logical pixels. Clamped to a 20 px floor - /// by [`Self::size`] so the button stays touchable. - pub size: f32, + pub( crate ) on_press: Option, + /// Square hit-target size in logical pixels. An explicit [`Self::size`] + /// is clamped to a 20 px floor so the button stays touchable; the `0.0` + /// default follows the process [`crate::WidgetScaling`] mode. + pub( crate ) size: f32, /// Optional stable identifier for focus management. - pub id: Option, + pub( crate ) id: Option, /// Whether this button takes part in the Tab / Shift+Tab cycle. Defaults /// to `false` to match desktop convention — title-bar chrome on macOS, /// GNOME and Windows is click/touch-only and never steals keyboard focus /// from window content. Opt in with [`Self::focusable`] for shells where /// keyboard reachability of decorations matters (accessibility, no-mouse /// kiosks). Pointer / touch hit testing is unaffected by this flag. - pub focusable: bool, + pub( crate ) focusable: bool, } impl WindowButton @@ -67,7 +68,7 @@ impl WindowButton { kind, on_press: None, - size: theme::SIZE, + size: 0.0, id: None, focusable: false, } @@ -112,9 +113,10 @@ impl WindowButton 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 ) } diff --git a/src/widget/window_button/tests.rs b/src/widget/window_button/tests.rs index ba1cff1..8abbdf3 100644 --- a/src/widget/window_button/tests.rs +++ b/src/widget/window_button/tests.rs @@ -5,11 +5,13 @@ use super::*; use crate::render::Canvas; #[ 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 ); - 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 ] diff --git a/tests/animation.rs b/tests/animation.rs index 07dab05..f09d50f 100644 --- a/tests/animation.rs +++ b/tests/animation.rs @@ -1,3 +1,5 @@ +#![ cfg( feature = "test-support" ) ] + // Animation lifecycle coverage. The runtime polls `App::is_animating()` once // per frame and, while it returns `true`, redraws at ~60 Hz reading // `Instant::now()` against a stored start time. These tests exercise that diff --git a/tests/element_map.rs b/tests/element_map.rs index 969f113..d0cc532 100644 --- a/tests/element_map.rs +++ b/tests/element_map.rs @@ -1,3 +1,5 @@ +#![ cfg( feature = "test-support" ) ] + //! Integration tests for [`ltk::Element::map`] — the Elm-style adapter //! that re-tags every per-leaf message in a sub-tree. //! diff --git a/tests/event_loop_flow.rs b/tests/event_loop_flow.rs index 44aae7e..1096f23 100644 --- a/tests/event_loop_flow.rs +++ b/tests/event_loop_flow.rs @@ -1,3 +1,5 @@ +#![ cfg( feature = "test-support" ) ] + // End-to-end coverage for the runtime contract: `Msg → App::update → next // view → render`. The Wayland event loop in `ltk::run` is the integration // point that ties widget-level handler snapshots, focus traversal and keysym diff --git a/tests/layout_stack_spacer.rs b/tests/layout_stack_spacer.rs index a4eff53..39d7fd1 100644 --- a/tests/layout_stack_spacer.rs +++ b/tests/layout_stack_spacer.rs @@ -2,7 +2,7 @@ // the way `Spacer` distributes leftover space inside a `Column`. use ltk::core::Canvas; -use ltk::{ stack, spacer, HAlign, Length, VAlign, Rect }; +use ltk::{ stack, spacer, HAlign, VAlign, Rect }; fn make_canvas() -> Canvas { @@ -178,42 +178,29 @@ fn stack_empty_preferred_size_height_is_zero() // ── 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 ] -fn spacer_default_has_weight_one_and_no_fixed_size() +fn spacer_default_has_no_fixed_size() { let canvas = make_canvas(); - let s = spacer(); - 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 ) ); + assert_eq!( spacer().preferred_size( &canvas ), ( 0.0, 0.0 ) ); } #[ test ] fn spacer_height_pins_vertical_axis_only() { let canvas = make_canvas(); - let s = spacer().height( 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 ) ); + assert_eq!( spacer().height( 24.0 ).preferred_size( &canvas ), ( 0.0, 24.0 ) ); } #[ test ] fn spacer_width_pins_horizontal_axis_only() { let canvas = make_canvas(); - let s = spacer().width( 12.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 ); + assert_eq!( spacer().width( 12.0 ).preferred_size( &canvas ), ( 12.0, 0.0 ) ); } // ── Spacer in Column: leftover distribution ─────────────────────────────────── diff --git a/tests/overlay_reconciliation.rs b/tests/overlay_reconciliation.rs index bebca07..3577e1c 100644 --- a/tests/overlay_reconciliation.rs +++ b/tests/overlay_reconciliation.rs @@ -1,3 +1,5 @@ +#![ cfg( feature = "test-support" ) ] + use ltk::test_support::diff_overlay_ids; use ltk::OverlayId; diff --git a/tests/slider_math.rs b/tests/slider_math.rs index 6037027..983569e 100644 --- a/tests/slider_math.rs +++ b/tests/slider_math.rs @@ -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; 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 } } +/// 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 ] fn left_edge_clamps_to_zero() { 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, -50.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, thumb() ), 0.0 ); } #[ test ] fn right_edge_clamps_to_one() { 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, 999.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, thumb() ), 1.0 ); } #[ test ] @@ -28,7 +34,7 @@ fn center_returns_half() // The thumb adds padding on both sides, so the geometric center of the // rect produces ~0.5 (within the THUMB_SIZE/2 tolerance). 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 ); } @@ -36,9 +42,9 @@ fn center_returns_half() fn rect_offset_translates_correctly() { 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, 700.0 ), 1.0 ); - let v = value_from_x_in_rect( r, 600.0 ); + assert_eq!( value_from_x_in_rect( r, 500.0, thumb() ), 0.0 ); + assert_eq!( value_from_x_in_rect( r, 700.0, thumb() ), 1.0 ); + let v = value_from_x_in_rect( r, 600.0, thumb() ); 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 ); 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 ); } } @@ -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 // formula stays defined. 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!( ( 0.0..= 1.0 ).contains( &v ) ); } diff --git a/tests/tab_navigation.rs b/tests/tab_navigation.rs index ae51da7..b1a07f8 100644 --- a/tests/tab_navigation.rs +++ b/tests/tab_navigation.rs @@ -1,3 +1,5 @@ +#![ cfg( feature = "test-support" ) ] + mod common; use common::{ lw_none, rect }; diff --git a/tests/text_edit_dispatch.rs b/tests/text_edit_dispatch.rs index 7a7988f..d1b799c 100644 --- a/tests/text_edit_dispatch.rs +++ b/tests/text_edit_dispatch.rs @@ -1,3 +1,5 @@ +#![ cfg( feature = "test-support" ) ] + // 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 // `WidgetHandlers::TextEdit` entry, and the runtime drives keystrokes through diff --git a/tests/tree_lookup.rs b/tests/tree_lookup.rs index af79090..3f11454 100644 --- a/tests/tree_lookup.rs +++ b/tests/tree_lookup.rs @@ -1,3 +1,5 @@ +#![ cfg( feature = "test-support" ) ] + mod common; use common::{ lw_none, lw_button, rect }; diff --git a/tests/vslider_math.rs b/tests/vslider_math.rs index 0cf0df6..870fe3e 100644 --- a/tests/vslider_math.rs +++ b/tests/vslider_math.rs @@ -1,7 +1,10 @@ +#![ cfg( feature = "test-support" ) ] + use ltk::test_support:: { value_from_y_in_rect, value_from_pos_in_rect, + thumb_design_px, SliderAxis, }; use ltk::{ Point, Rect }; @@ -90,6 +93,7 @@ fn value_from_pos_dispatches_horizontal() r, Point { x: 200.0, y: 0.0 }, SliderAxis::Horizontal, + thumb_design_px(), ); assert_eq!( v, 1.0 ); } @@ -102,6 +106,7 @@ fn value_from_pos_dispatches_vertical() r, Point { x: 9_999.0, y: 0.0 }, SliderAxis::Vertical, + thumb_design_px(), ); 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 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( - 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!( ( v - 0.5 ).abs() < 1e-6 ); } diff --git a/tests/widget_dispatch.rs b/tests/widget_dispatch.rs index 9e79dba..c2637b9 100644 --- a/tests/widget_dispatch.rs +++ b/tests/widget_dispatch.rs @@ -1,3 +1,5 @@ +#![ cfg( feature = "test-support" ) ] + // Handler-level dispatch coverage for the interactive widgets that aren't // buttons or text edits: toggle, checkbox, radio and slider. Each test renders // the widget through `UiSurface`, locates its `WidgetHandlers` snapshot and