responsive fluid/physical scaling, widget-API stabilization, and perf guardrails
Responsive scaling. ltk now offers two first-class ways to size a UI so it adapts across screens, chosen per process via `WidgetScaling { Fluid, Physical }` (`set_widget_scaling` / `widget_scaling`, default `Fluid`). Fluid sizing (`Length::fluid( px )`) makes a design pixel a proportion of the surface's smaller side, calibrated against a reference width (`set_fluid_reference` / `fluid_reference`, 412 px default) and bounded by `FLUID_MIN` / `FLUID_MAX`; physical sizing (`Length::dp( px )`) is a constant-physical-size pixel scaled by display density (`set_density` / `density`). `Length` gains `orient( portrait, landscape )` — resolve one value in portrait, another in landscape — plus `widget( px )`, which picks fluid or dp per the active mode. Canvas exposes `geom_px` (geometry, resolved in physical layout space) and `font_px` (font size, bridging logical / physical per mode) so widgets and apps share one resolution path. Note the rename: `set_design_reference` / `design_reference` became `set_fluid_reference` / `fluid_reference`, and `Length::dp` changed meaning — the old surface-proportional behaviour now lives on `Length::fluid`.
Widgets. Every stock widget resolves its default geometry and font through the widget-scaling mode instead of frozen pixels, so a whole UI scales coherently without per-call units. New size builders where they were missing: `button` gains `font_size` / `height`, `text_edit` gains `height` / `font_size_fluid`, `separator` gains `pad_v`, and assorted widgets accept a `Length` where they previously took only `f32`.
Overlays. `OverlaySpec::size` is now `( Length, Length )` instead of `( u32, u32 )`, resolved against the main surface when the overlay is materialized, so overlays can scale with the display; `Length::px( … )` reproduces the old fixed sizing.
API stabilization (toward 1.0). Widget struct fields are now `pub( crate )` — they are configured through builders, not field access — except the value / state types apps genuinely read or construct (`Time`, `Date`, `ComboState`), which stay public. The internal `test_support` helpers move behind a `test-support` Cargo feature (off by default, so third-party builds never see them; ltk's own `make test` enables it). `Separator` drops its `0.0`-means-mode sentinel for `Option<Length>`, so an explicit `pad_v( 0.0 )` is a real flush divider distinct from the mode-following default.
Performance guardrails. Opt-in diagnostics via `LTK_PERF_WARN=1` warn about stuck animations, sustained software-render animation, and low `poll_interval`; software-rendered animation is capped near 30 Hz to spare CPU on machines that fall back off EGL. Apps can override the cap with `App::cap_software_animation`.
Docs and build. The two scaling modes are documented in README, onboarding and architecture, with the earlier gradient / backdrop doc drift cleaned up. The Makefile now ships the `locales/` directory into the packaged crate (fixing i18n keys rendering raw for downstreams), builds the new `responsive` example, and runs tests with `--features test-support`.
This commit is contained in:
@@ -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"] }
|
||||
|
||||
5
Makefile
5
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
|
||||
|
||||
33
README.md
33
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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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<Vec<u8>>, lw: u32, lh: u32 ) -> Element<Msg> {
|
||||
column::<Msg>()
|
||||
.padding( Length::vmin( 4.0 ).clamp( 16.0, 48.0 ) )
|
||||
.spacing( Length::vmin( 2.0 ).clamp( 8.0, 24.0 ) )
|
||||
// Logo: 40 % of the width in portrait, 5 % of the height in landscape.
|
||||
.push( img_widget( logo, lw, lh ).short_side( Length::orient( 40.0, 5.0 ) ) )
|
||||
// Heading: fluid, but never below 20 px nor above 44 px.
|
||||
.push( text( "Welcome" ).size( Length::vmin( 6.0 ).clamp( 20.0, 44.0 ) ) )
|
||||
.into()
|
||||
# }
|
||||
```
|
||||
|
||||
Fluid units scale with the screen's pixels, not real-world
|
||||
millimetres. For body text that must stay physically legible and
|
||||
honour the user's font-size preference across an open-ended device
|
||||
set, prefer `Length::dp` or `Length::em` over raw `vmin`; the
|
||||
[`typography`](../src/theme/typography.rs) scale (`h0`…`body_xs`)
|
||||
gives clamped-`vmin` sizes tuned for running text. Reserve
|
||||
`view()`-level branching on `surface_width` / `surface_height` for
|
||||
genuine layout restructuring (sidebar → bottom tabs), not for sizing.
|
||||
|
||||
---
|
||||
|
||||
## Slide-in panel
|
||||
|
||||
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<Msg>
|
||||
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<OverlaySpec<Msg>>
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
156
examples/responsive.rs
Normal file
156
examples/responsive.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
//! `cargo run --example responsive`
|
||||
//!
|
||||
//! Demonstrates ltk's two responsive modes side by side on stock widgets.
|
||||
//! None of the widgets below set an explicit size — they all follow the
|
||||
//! process-wide [`ltk::WidgetScaling`] mode:
|
||||
//!
|
||||
//! - **Fluid** (the default): sizes are a fraction of the surface, so the
|
||||
//! whole set grows and shrinks as you resize the window.
|
||||
//! - **Physical**: sizes are a constant real-world size scaled by
|
||||
//! [`ltk::density`], independent of the surface.
|
||||
//!
|
||||
//! Tap **Switch mode** to flip between them, and **−/+ density** to change
|
||||
//! the physical density. Watch the button, field, checkbox, switch, slider
|
||||
//! and progress bar resize (or not) accordingly. Esc exits.
|
||||
//!
|
||||
//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running
|
||||
//! Wayland compositor (e.g. sway, labwc, or a full desktop session).
|
||||
|
||||
use ltk::
|
||||
{
|
||||
App, Element, Keysym, ButtonVariant, WidgetScaling,
|
||||
button, checkbox, column, progress_bar, row, separator, slider, spacer, text, text_edit, toggle,
|
||||
set_widget_scaling, set_density, density,
|
||||
};
|
||||
|
||||
#[ derive( Clone ) ]
|
||||
enum Message
|
||||
{
|
||||
SwitchMode,
|
||||
DensityUp,
|
||||
DensityDown,
|
||||
NameChanged( String ),
|
||||
ToggleCheck,
|
||||
ToggleSwitch,
|
||||
SliderChanged( f32 ),
|
||||
}
|
||||
|
||||
struct ResponsiveApp
|
||||
{
|
||||
physical: bool,
|
||||
name: String,
|
||||
checked: bool,
|
||||
switched: bool,
|
||||
volume: f32,
|
||||
}
|
||||
|
||||
impl ResponsiveApp
|
||||
{
|
||||
fn new() -> Self
|
||||
{
|
||||
// Start in the default fluid mode with a neutral density.
|
||||
set_widget_scaling( WidgetScaling::Fluid );
|
||||
set_density( 1.5 );
|
||||
Self
|
||||
{
|
||||
physical: false,
|
||||
name: String::new(),
|
||||
checked: true,
|
||||
switched: false,
|
||||
volume: 0.4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App for ResponsiveApp
|
||||
{
|
||||
type Message = Message;
|
||||
|
||||
fn view( &self ) -> Element<Message>
|
||||
{
|
||||
let palette = ltk::theme_palette();
|
||||
let primary = palette.text_primary;
|
||||
let secondary = palette.text_secondary;
|
||||
|
||||
let mode_label = if self.physical
|
||||
{
|
||||
format!( "Mode: Physical (density {:.1})", density() )
|
||||
} else {
|
||||
"Mode: Fluid (resize the window to see it scale)".to_string()
|
||||
};
|
||||
|
||||
// Mode + density controls. Explicit sizes here so the controls stay
|
||||
// stable while the demo widgets below react to the mode.
|
||||
let controls = row::<Message>()
|
||||
.spacing( 12.0 )
|
||||
.push( button::<Message>( "Switch mode".to_string() )
|
||||
.variant( ButtonVariant::Primary )
|
||||
.font_size( 16.0 )
|
||||
.height( 44.0 )
|
||||
.on_press( Message::SwitchMode ) )
|
||||
.push( button::<Message>( "− density".to_string() )
|
||||
.font_size( 16.0 )
|
||||
.height( 44.0 )
|
||||
.on_press( Message::DensityDown ) )
|
||||
.push( button::<Message>( "+ density".to_string() )
|
||||
.font_size( 16.0 )
|
||||
.height( 44.0 )
|
||||
.on_press( Message::DensityUp ) );
|
||||
|
||||
// The demo widgets — NO explicit sizes, so they follow the mode.
|
||||
let demo = column::<Message>()
|
||||
.spacing( 16.0 )
|
||||
.max_width( 520.0 )
|
||||
.push( button::<Message>( "A stock button".to_string() ).variant( ButtonVariant::Secondary ).on_press( Message::SwitchMode ) )
|
||||
.push( text_edit( "A stock text field".to_string(), self.name.clone() ).on_change( Message::NameChanged ) )
|
||||
.push( checkbox( self.checked ).label( "A stock checkbox".to_string() ).on_toggle( Message::ToggleCheck ) )
|
||||
.push( toggle( self.switched ).label( "A stock switch".to_string() ).on_toggle( Message::ToggleSwitch ) )
|
||||
.push( slider( self.volume ).on_change( Message::SliderChanged ) )
|
||||
.push( progress_bar( self.volume ) );
|
||||
|
||||
column::<Message>()
|
||||
.padding( 32.0 )
|
||||
.spacing( 20.0 )
|
||||
.center_y( true )
|
||||
.push( text( "ltk responsive modes".to_string() ).size( 26.0 ).color( primary ).align_center() )
|
||||
.push( text( mode_label ).size( 15.0 ).color( secondary ).align_center() )
|
||||
.push( controls )
|
||||
.push( separator() )
|
||||
.push( demo )
|
||||
.push( spacer().weight( 1 ) )
|
||||
.push( text( "Esc to exit".to_string() ).size( 12.0 ).color( secondary ).align_center() )
|
||||
.into()
|
||||
}
|
||||
|
||||
fn update( &mut self, msg: Message )
|
||||
{
|
||||
match msg
|
||||
{
|
||||
Message::SwitchMode =>
|
||||
{
|
||||
self.physical = !self.physical;
|
||||
set_widget_scaling( if self.physical { WidgetScaling::Physical } else { WidgetScaling::Fluid } );
|
||||
}
|
||||
Message::DensityUp => set_density( ( density() + 0.25 ).min( 4.0 ) ),
|
||||
Message::DensityDown => set_density( ( density() - 0.25 ).max( 0.5 ) ),
|
||||
Message::NameChanged( v ) => self.name = v,
|
||||
Message::ToggleCheck => self.checked = !self.checked,
|
||||
Message::ToggleSwitch => self.switched = !self.switched,
|
||||
Message::SliderChanged( v ) => self.volume = v,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_key( &mut self, keysym: Keysym ) -> Option<Message>
|
||||
{
|
||||
if keysym == Keysym::Escape
|
||||
{
|
||||
std::process::exit( 0 );
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn main()
|
||||
{
|
||||
ltk::run( ResponsiveApp::new() );
|
||||
}
|
||||
@@ -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::<Message>()
|
||||
.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 );
|
||||
|
||||
|
||||
30
src/app.rs
30
src/app.rs
@@ -213,9 +213,14 @@ pub struct OverlaySpec<Message: Clone>
|
||||
/// 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
|
||||
|
||||
@@ -453,13 +453,22 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
||||
other.draw( canvas, rect, is_focused, is_hovered, is_pressed, cursor_pos, sel_anchor );
|
||||
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 ),
|
||||
|
||||
@@ -256,6 +256,9 @@ pub struct AppData<A: App>
|
||||
/// 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<crate::types::WidgetId>,
|
||||
/// Runtime performance guardrails (stuck-animation / poll-interval
|
||||
/// diagnostics and the software animation-rate cap). See [`super::perf`].
|
||||
pub perf: super::perf::PerfState,
|
||||
}
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
|
||||
@@ -661,10 +661,29 @@ impl<A: App> Dispatch<WlCallback, super::SurfaceFocus> for AppData<A>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Perf guardrails: run the opt-in diagnostics and apply
|
||||
// the software animation-rate cap (~30 Hz). A throttled
|
||||
// frame keeps the vsync cadence with a bare callback but
|
||||
// skips the re-raster.
|
||||
let software = crate::render::is_software_render();
|
||||
let cap = state.app.cap_software_animation();
|
||||
if state.perf.animated_frame( software, cap )
|
||||
{
|
||||
state.view_dirty = true;
|
||||
state.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 ) =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
#[ cfg( feature = "test-support" ) ]
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::app::{ App, InvalidationScope, SurfaceTarget };
|
||||
@@ -62,6 +63,10 @@ pub( super ) fn apply_invalidation<A: App>( data: &mut AppData<A>, scope: Invali
|
||||
///
|
||||
/// `added` preserves the order of `next` (so creation order is deterministic);
|
||||
/// `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<Item = crate::app::OverlayId>,
|
||||
next: &[ crate::app::OverlayId ],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<A: App>( data: &mut AppData<A> )
|
||||
};
|
||||
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<A: App>( data: &mut AppData<A> )
|
||||
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<A: App>( data: &mut AppData<A> )
|
||||
// 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<A: App>( data: &mut AppData<A> )
|
||||
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<A: App>( data: &mut AppData<A> )
|
||||
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<A: App>( data: &mut AppData<A> )
|
||||
}
|
||||
popup.wl_surface().commit();
|
||||
let mut ss = SurfaceState::<A::Message>::new( SurfaceKind::Popup( popup ), 0.0, String::new() );
|
||||
ss.last_requested_size = spec.size;
|
||||
ss.last_requested_size = resolved_size;
|
||||
ss.last_popup_anchor = Some( anchor_rect );
|
||||
overlays_m.insert( spec.id, ss );
|
||||
continue;
|
||||
@@ -290,7 +303,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
||||
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<A: App>( data: &mut AppData<A> )
|
||||
// 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 );
|
||||
}
|
||||
|
||||
173
src/event_loop/perf.rs
Normal file
173
src/event_loop/perf.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Runtime performance guardrails.
|
||||
//!
|
||||
//! The idle/redraw model is efficient only if the app cooperates — a stuck
|
||||
//! [`App::is_animating`](crate::App::is_animating), an aggressive
|
||||
//! [`poll_interval`](crate::App::poll_interval), or continuous animation on
|
||||
//! the software backend all quietly burn CPU/battery. The docs warn about
|
||||
//! these, but the runtime can also **detect** them (opt-in dev diagnostics,
|
||||
//! `LTK_PERF_WARN=1`) and **mitigate** one of them (cap animation to ~30 Hz
|
||||
//! on the software renderer, opt-out via
|
||||
//! [`App::cap_software_animation`](crate::App::cap_software_animation)).
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{ Duration, Instant };
|
||||
|
||||
/// A settled animation should return `false` well before this; staying `true`
|
||||
/// this long is almost always a forgotten `is_animating = false`.
|
||||
const STUCK_THRESHOLD: Duration = Duration::from_secs( 10 );
|
||||
/// Continuous software-rendered animation past this is worth flagging on
|
||||
/// mobile, where it means sustained CPU with no GPU offload.
|
||||
const SW_ANIM_THRESHOLD: Duration = Duration::from_secs( 3 );
|
||||
/// Minimum gap between software-backend animation re-rasters (≈ 30 Hz).
|
||||
const SOFTWARE_ANIM_MIN_INTERVAL: Duration = Duration::from_millis( 33 );
|
||||
/// A `poll_interval` shorter than this defeats the event-driven idle model.
|
||||
const POLL_WARN_THRESHOLD: Duration = Duration::from_millis( 100 );
|
||||
|
||||
/// `true` when `LTK_PERF_WARN` is set to a non-empty, non-`0` value. Cached.
|
||||
fn perf_warn_enabled() -> bool
|
||||
{
|
||||
static ENABLED: OnceLock<bool> = OnceLock::new();
|
||||
*ENABLED.get_or_init( ||
|
||||
std::env::var( "LTK_PERF_WARN" )
|
||||
.map( |v| !v.is_empty() && v != "0" )
|
||||
.unwrap_or( false )
|
||||
)
|
||||
}
|
||||
|
||||
/// Per-runtime performance-guard state. Lives on `AppData`.
|
||||
pub struct PerfState
|
||||
{
|
||||
/// When the current continuous animation began (`None` while idle).
|
||||
animating_since: Option<Instant>,
|
||||
/// Last software-backend animation re-raster, for the 30 Hz cap.
|
||||
last_anim_draw: Instant,
|
||||
warned_stuck: bool,
|
||||
warned_software_anim: bool,
|
||||
warned_poll: bool,
|
||||
}
|
||||
|
||||
impl PerfState
|
||||
{
|
||||
pub fn new() -> Self
|
||||
{
|
||||
Self
|
||||
{
|
||||
animating_since: None,
|
||||
// Start in the past so the first animated frame is never throttled.
|
||||
// `checked_sub` guards against underflow shortly after boot.
|
||||
last_anim_draw: Instant::now().checked_sub( SOFTWARE_ANIM_MIN_INTERVAL ).unwrap_or_else( Instant::now ),
|
||||
warned_stuck: false,
|
||||
warned_software_anim: false,
|
||||
warned_poll: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset when the app stops animating, so the next animation starts fresh
|
||||
/// (and can warn again if it, too, gets stuck).
|
||||
pub fn animation_stopped( &mut self )
|
||||
{
|
||||
self.animating_since = None;
|
||||
self.warned_stuck = false;
|
||||
self.warned_software_anim = false;
|
||||
}
|
||||
|
||||
/// Called on every animated main-surface frame callback. Runs the opt-in
|
||||
/// diagnostics and applies the software animation-rate cap. Returns `true`
|
||||
/// if this frame should actually re-raster, `false` to throttle-skip it
|
||||
/// (the caller keeps the vsync cadence with a bare frame callback).
|
||||
pub fn animated_frame( &mut self, software: bool, cap_software: bool ) -> bool
|
||||
{
|
||||
let since = *self.animating_since.get_or_insert_with( Instant::now );
|
||||
|
||||
if perf_warn_enabled()
|
||||
{
|
||||
if !self.warned_stuck && since.elapsed() >= STUCK_THRESHOLD
|
||||
{
|
||||
eprintln!(
|
||||
"[ltk][perf] App::is_animating() has stayed true for {}s — a settled \
|
||||
animation must return false, or the loop redraws at the display rate \
|
||||
forever (battery drain).",
|
||||
STUCK_THRESHOLD.as_secs(),
|
||||
);
|
||||
self.warned_stuck = true;
|
||||
}
|
||||
if software && !self.warned_software_anim && since.elapsed() >= SW_ANIM_THRESHOLD
|
||||
{
|
||||
eprintln!(
|
||||
"[ltk][perf] continuous animation on the software renderer — sustained \
|
||||
CPU with no GPU offload (costly on mobile). Prefer event-driven redraws, \
|
||||
or a GLES-capable compositor.",
|
||||
);
|
||||
self.warned_software_anim = true;
|
||||
}
|
||||
}
|
||||
|
||||
if software && cap_software && self.last_anim_draw.elapsed() < SOFTWARE_ANIM_MIN_INTERVAL
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.last_anim_draw = Instant::now();
|
||||
true
|
||||
}
|
||||
|
||||
/// Warn once (under `LTK_PERF_WARN`) if the app's `poll_interval` is short
|
||||
/// enough to defeat the idle model.
|
||||
pub fn warn_poll_interval( &mut self, dur: Duration )
|
||||
{
|
||||
if perf_warn_enabled() && !self.warned_poll && dur < POLL_WARN_THRESHOLD
|
||||
{
|
||||
eprintln!(
|
||||
"[ltk][perf] poll_interval() of {}ms defeats the event-driven idle model — \
|
||||
wake the loop from your worker with set_channel_sender instead of polling.",
|
||||
dur.as_millis(),
|
||||
);
|
||||
self.warned_poll = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[ cfg( test ) ]
|
||||
mod tests
|
||||
{
|
||||
use super::*;
|
||||
|
||||
#[ test ]
|
||||
fn gles_animation_is_never_capped()
|
||||
{
|
||||
let mut p = PerfState::new();
|
||||
// Two back-to-back frames on the GPU backend both re-raster.
|
||||
assert!( p.animated_frame( false, true ) );
|
||||
assert!( p.animated_frame( false, true ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn software_animation_is_capped_between_rerasters()
|
||||
{
|
||||
let mut p = PerfState::new();
|
||||
// First software frame draws; an immediate second is throttled
|
||||
// (< 33 ms since the last re-raster).
|
||||
assert!( p.animated_frame( true, true ) );
|
||||
assert!( !p.animated_frame( true, true ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn software_cap_opt_out_keeps_full_rate()
|
||||
{
|
||||
let mut p = PerfState::new();
|
||||
assert!( p.animated_frame( true, false ) );
|
||||
assert!( p.animated_frame( true, false ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn stopping_resets_stuck_warning_state()
|
||||
{
|
||||
let mut p = PerfState::new();
|
||||
let _ = p.animated_frame( false, true );
|
||||
assert!( p.animating_since.is_some() );
|
||||
p.animation_stopped();
|
||||
assert!( p.animating_since.is_none() );
|
||||
}
|
||||
}
|
||||
@@ -331,6 +331,7 @@ pub( crate ) fn try_run<A: App>( 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<A: App>( 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 ),
|
||||
|
||||
@@ -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<A: App> AppData<A>
|
||||
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() ),
|
||||
|
||||
@@ -43,17 +43,17 @@ use crate::widget::Element;
|
||||
/// ```
|
||||
pub struct Column<Msg: Clone>
|
||||
{
|
||||
pub children: Vec<Element<Msg>>,
|
||||
pub( crate ) children: Vec<Element<Msg>>,
|
||||
/// Vertical gap between children. Stored as [`Length`] so a `Vmin(2.0)`
|
||||
/// 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<Length>,
|
||||
pub fit_content: bool,
|
||||
pub( crate ) padding: Length,
|
||||
pub( crate ) align_center_x: bool,
|
||||
pub( crate ) center_y: bool,
|
||||
pub( crate ) max_width: Option<Length>,
|
||||
pub( crate ) fit_content: bool,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Column<Msg>
|
||||
|
||||
@@ -41,12 +41,12 @@ use crate::widget::Element;
|
||||
/// ```
|
||||
pub struct Row<Msg: Clone>
|
||||
{
|
||||
pub children: Vec<Element<Msg>>,
|
||||
pub( crate ) children: Vec<Element<Msg>>,
|
||||
/// Horizontal gap between children. [`Length`]; default `8.0` px.
|
||||
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<Msg: Clone> Row<Msg>
|
||||
|
||||
@@ -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<Length>,
|
||||
pub( crate ) fixed_height: Option<Length>,
|
||||
/// Fixed width (overrides flexible behavior in a row). Same length-type
|
||||
/// semantics as [`Self::fixed_height`].
|
||||
pub fixed_width: Option<Length>,
|
||||
pub( crate ) fixed_width: Option<Length>,
|
||||
}
|
||||
|
||||
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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,10 +56,10 @@ pub struct Stack<Msg: Clone>
|
||||
/// `Option<Rect>` 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<Msg>, HAlign, VAlign, f32, f32, f32, Option<Rect>, Option<Rect> )>,
|
||||
pub( crate ) children: Vec<( Element<Msg>, HAlign, VAlign, f32, f32, f32, Option<Rect>, Option<Rect> )>,
|
||||
/// When `true`, [`preferred_size`](Self::preferred_size) reports the max of
|
||||
/// children's intrinsic widths and heights instead of `(max_width, tallest)`.
|
||||
pub fit_content: bool,
|
||||
pub( crate ) fit_content: bool,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Stack<Msg>
|
||||
|
||||
@@ -32,18 +32,18 @@ use crate::widget::Element;
|
||||
pub struct WrapGrid<Msg: Clone>
|
||||
{
|
||||
/// Child widgets laid out in row-major order.
|
||||
pub children: Vec<Element<Msg>>,
|
||||
pub( crate ) children: Vec<Element<Msg>>,
|
||||
/// 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<Msg: Clone> WrapGrid<Msg>
|
||||
|
||||
135
src/lib.rs
135
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<Length>`, 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<Length>`, 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<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
|
||||
//! // 40 % of the width in portrait, 5 % of the height in landscape.
|
||||
//! img_widget( rgba, w, h ).short_side( Length::orient( 40.0, 5.0 ) ).into()
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! ### When constant *physical* size matters instead
|
||||
//!
|
||||
//! Fluid units scale with the screen's pixels, not with real-world
|
||||
//! millimetres — and legibility is a function of physical (angular) size,
|
||||
//! not of what fraction of the screen a glyph fills. When a size must stay
|
||||
//! a **constant physical size** across very different displays, use the
|
||||
//! other mode: [`Length::dp`] (a density-independent pixel — `n ×`
|
||||
//! [`density`], the mainstream HiDPI unit), or [`LengthBase::Em`] for text
|
||||
//! relative to the root font size. The pre-calibrated
|
||||
//! [`theme::typography`] scale
|
||||
//! ([`theme::typography::h0`]…[`theme::typography::body_xs`]) is built on
|
||||
//! clamped `vmin`, so it stays fluid while respecting a readable px floor
|
||||
//! and ceiling — a good default for running text.
|
||||
//!
|
||||
//! ### The two modes, and choosing per app
|
||||
//!
|
||||
//! ltk offers both strategies as first-class citizens and lets the app
|
||||
//! pick — per value, or process-wide for every stock widget:
|
||||
//!
|
||||
//! - **Fluid** — [`Length::fluid`] and the raw `vmin` / `vmax` / `vw` /
|
||||
//! `vh` / `orient` units. Surface-proportional; tracks the short side
|
||||
//! (width in portrait, height in landscape). Best for full-screen system
|
||||
//! surfaces on known hardware (lock screen, greeter, splash, kiosk,
|
||||
//! launcher).
|
||||
//! - **Physical** — [`Length::dp`] plus [`set_density`] / [`density`].
|
||||
//! Constant real-world size, HiDPI-aware. Best for conventional windowed
|
||||
//! apps across an open-ended device set.
|
||||
//!
|
||||
//! Stock widgets carry one design pixel per dimension and resolve it
|
||||
//! through the process-wide [`WidgetScaling`] mode
|
||||
//! ([`set_widget_scaling`]): [`WidgetScaling::Fluid`] (the default) reads
|
||||
//! it as [`Length::fluid`], [`WidgetScaling::Physical`] as [`Length::dp`].
|
||||
//! An explicit [`Length`] on a widget always overrides the mode.
|
||||
//!
|
||||
//! ### Structural changes — branch in `view()`
|
||||
//!
|
||||
//! When the layout *structure* must change (sidebar → bottom tabs, two
|
||||
//! columns → one) rather than merely resize, branch in `view()` on
|
||||
//! `surface_width` / `surface_height`. Keep this for genuine
|
||||
//! restructuring; [`Length`] covers all pure sizing.
|
||||
//!
|
||||
//! ## Runtime-free embedding
|
||||
//!
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<BorrowedGlesTexture>
|
||||
@@ -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 ) ]
|
||||
|
||||
238
src/types.rs
238
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<f32> for Length
|
||||
@@ -640,6 +771,7 @@ impl From<LengthBase> 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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,12 +32,12 @@ mod tests;
|
||||
pub struct AnchoredOverlay<Msg: Clone>
|
||||
{
|
||||
/// The element to draw at the anchored position.
|
||||
pub child: Box<Element<Msg>>,
|
||||
pub( crate ) child: Box<Element<Msg>>,
|
||||
/// Stable identifier of the widget whose rect provides the anchor.
|
||||
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<Msg: Clone> AnchoredOverlay<Msg>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
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<Msg: Clone>
|
||||
{
|
||||
/// 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<Msg>,
|
||||
pub( crate ) on_press: Option<Msg>,
|
||||
/// 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<Msg>,
|
||||
pub( crate ) on_long_press: Option<Msg>,
|
||||
/// 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<Msg>,
|
||||
pub( crate ) on_drag_start: Option<Msg>,
|
||||
/// 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<Length>,
|
||||
/// Optional height for text buttons. `None` uses the theme's default
|
||||
/// (`theme::HEIGHT`); a [`Length`] scales the button box with the
|
||||
/// surface so it does not stay frozen while the rest of a fluid layout
|
||||
/// grows. Resolved in physical layout space, like all geometry.
|
||||
pub( crate ) height: Option<Length>,
|
||||
/// Optional stable identifier for focus management.
|
||||
pub id: Option<WidgetId>,
|
||||
pub( crate ) id: Option<WidgetId>,
|
||||
/// 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<crate::types::CursorShape>,
|
||||
pub( crate ) cursor: Option<crate::types::CursorShape>,
|
||||
/// 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<Msg: Clone>
|
||||
/// 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<String>,
|
||||
pub( crate ) repeating: bool,
|
||||
pub( crate ) tooltip: Option<String>,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Button<Msg>
|
||||
@@ -99,7 +110,9 @@ impl<Msg: Clone> Button<Msg>
|
||||
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<Msg: Clone> Button<Msg>
|
||||
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<Msg: Clone> Button<Msg>
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the label font size for text buttons. Accepts logical `f32`
|
||||
/// pixels or any [`Length`] (e.g. `Length::vmin( 2.2 ).clamp( 14.0,
|
||||
/// 22.0 )` to scale with the surface). No-op for icon buttons.
|
||||
pub fn font_size( mut self, size: impl Into<Length> ) -> Self
|
||||
{
|
||||
self.font_size = Some( size.into() );
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the button height for text buttons. Accepts logical `f32` pixels
|
||||
/// or any [`Length`] (e.g. `Length::vmin( 7.0 ).clamp( 40.0, 64.0 )` to
|
||||
/// scale the box with the surface). No-op for icon buttons, which are
|
||||
/// sized by [`Self::icon_size`].
|
||||
pub fn height( mut self, h: impl Into<Length> ) -> Self
|
||||
{
|
||||
self.height = Some( h.into() );
|
||||
self
|
||||
}
|
||||
|
||||
/// Resolve the label font size against the canvas viewport, matching how
|
||||
/// [`text`](crate::text) sizes its glyphs. An explicit override bypasses
|
||||
/// the mode; the default follows the process [`crate::WidgetScaling`].
|
||||
fn label_font_size( &self, canvas: &Canvas ) -> f32
|
||||
{
|
||||
self.font_size
|
||||
.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) )
|
||||
.unwrap_or_else( || canvas.font_px( theme::FONT_SIZE ) )
|
||||
}
|
||||
|
||||
/// Resolve the button height against the physical layout viewport (like
|
||||
/// all geometry). An explicit override bypasses the mode; the default
|
||||
/// follows the process [`crate::WidgetScaling`].
|
||||
fn resolved_height( &self, canvas: &Canvas ) -> f32
|
||||
{
|
||||
self.height
|
||||
.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
|
||||
.unwrap_or_else( || canvas.geom_px( theme::HEIGHT ) )
|
||||
}
|
||||
|
||||
/// Resolve the icon-button size: a positive [`Self::icon_size`] pins it,
|
||||
/// the `0.0` sentinel follows the widget-scaling mode.
|
||||
fn resolved_icon_size( &self, canvas: &Canvas ) -> f32
|
||||
{
|
||||
if self.icon_size > 0.0 { self.icon_size } else { canvas.geom_px( theme::HEIGHT ) }
|
||||
}
|
||||
|
||||
/// Assign a stable identifier for focus management.
|
||||
pub fn id( mut self, id: WidgetId ) -> Self
|
||||
{
|
||||
@@ -268,13 +329,13 @@ impl<Msg: Clone> Button<Msg>
|
||||
{
|
||||
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<Msg: Clone> Button<Msg>
|
||||
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<Msg: Clone> Button<Msg>
|
||||
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<Msg: Clone> Button<Msg>
|
||||
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<Msg: Clone> Button<Msg>
|
||||
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<Msg: Clone> Button<Msg>
|
||||
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,
|
||||
|
||||
@@ -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<i32, ()> = std::sync::Arc::new( |_| () );
|
||||
let b: Button<()> = Button::<i32>::new( "ok".into() )
|
||||
.font_size( Length::px( 21.0 ) )
|
||||
.map_msg( &f );
|
||||
assert_eq!( b.font_size, Some( Length::px( 21.0 ) ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn height_none_by_default_follows_widget_scaling()
|
||||
{
|
||||
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
|
||||
let b = Button::<()>::new( "ok".into() );
|
||||
let canvas = Canvas::new( 400, 800 );
|
||||
assert!( b.height.is_none() );
|
||||
// The default routes through the widget-scaling mode, not a raw constant.
|
||||
assert_eq!( b.preferred_size( 1000.0, &canvas ).1, canvas.geom_px( theme::HEIGHT ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn height_builder_scales_button_box_with_surface()
|
||||
{
|
||||
// vmin height on a 400×800 surface → 10 % of the smaller side (400) = 40 px,
|
||||
// resolved in physical layout space (not divided by dpi_scale).
|
||||
let b = Button::<()>::new( "ok".into() ).height( Length::vmin( 10.0 ) );
|
||||
let canvas = Canvas::new( 400, 800 );
|
||||
assert_eq!( b.preferred_size( 1000.0, &canvas ).1, 40.0 );
|
||||
}
|
||||
|
||||
@@ -32,19 +32,19 @@ mod tests;
|
||||
pub struct Carousel<Msg: Clone>
|
||||
{
|
||||
/// Child widgets in display order, left-to-right.
|
||||
pub children: Vec<Element<Msg>>,
|
||||
pub( crate ) children: Vec<Element<Msg>>,
|
||||
/// Optional stable identifier — used by the host as the key for its
|
||||
/// drag / inertia / snap state.
|
||||
pub id: Option<WidgetId>,
|
||||
pub( crate ) id: Option<WidgetId>,
|
||||
/// Each child's width as a fraction of the viewport width. 0.8 leaves
|
||||
/// 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<Msg: Clone> Carousel<Msg>
|
||||
|
||||
@@ -37,13 +37,13 @@ pub struct Checkbox<Msg: Clone>
|
||||
{
|
||||
/// 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<Msg>,
|
||||
pub( crate ) on_toggle: Option<Msg>,
|
||||
/// Optional label drawn to the right of the box.
|
||||
pub label: Option<String>,
|
||||
pub( crate ) label: Option<String>,
|
||||
/// Optional stable identifier for focus management.
|
||||
pub id: Option<WidgetId>,
|
||||
pub( crate ) id: Option<WidgetId>,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Checkbox<Msg>
|
||||
@@ -83,14 +83,15 @@ impl<Msg: Clone> Checkbox<Msg>
|
||||
|
||||
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
||||
{
|
||||
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<Msg: Clone> Checkbox<Msg>
|
||||
|
||||
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<Msg: Clone> Checkbox<Msg>
|
||||
|
||||
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() );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Color>
|
||||
/// a continuous hue strip.
|
||||
pub struct ColorPicker<Msg: Clone>
|
||||
{
|
||||
pub value: Color,
|
||||
pub on_change: Option<Arc<dyn Fn( Color ) -> Msg>>,
|
||||
pub( crate ) value: Color,
|
||||
pub( crate ) on_change: Option<Arc<dyn Fn( Color ) -> 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<Msg: Clone + 'static> ColorPicker<Msg>
|
||||
@@ -163,7 +163,7 @@ impl<Msg: Clone + 'static> ColorPicker<Msg>
|
||||
.radius( 12.0 )
|
||||
.padding( 0.0 )
|
||||
.into();
|
||||
let mut preview_row = row::<Msg>().spacing( theme::SPACING ).push(
|
||||
let mut preview_row = row::<Msg>().spacing( Length::widget( theme::SPACING ) ).push(
|
||||
container::<Msg>( swatch )
|
||||
.padding( 0.0 )
|
||||
.radius( 12.0 ),
|
||||
@@ -208,7 +208,7 @@ impl<Msg: Clone + 'static> ColorPicker<Msg>
|
||||
} );
|
||||
}
|
||||
column::<Msg>().spacing( 4.0 )
|
||||
.push( text( label ).size( theme::LABEL_FS ).color( theme::text_muted() ) )
|
||||
.push( text( label ).size( Length::widget( theme::LABEL_FS ) ).color( theme::text_muted() ) )
|
||||
.push( s )
|
||||
.into()
|
||||
};
|
||||
@@ -218,7 +218,7 @@ impl<Msg: Clone + 'static> ColorPicker<Msg>
|
||||
let b_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, v, value.a ) );
|
||||
let a_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, value.b, v ) );
|
||||
|
||||
let mut sliders = column::<Msg>().spacing( theme::SPACING )
|
||||
let mut sliders = column::<Msg>().spacing( Length::widget( theme::SPACING ) )
|
||||
.push( chan_slider( "R", value.r, r_build ) )
|
||||
.push( chan_slider( "G", value.g, g_build ) )
|
||||
.push( chan_slider( "B", value.b, b_build ) );
|
||||
@@ -267,18 +267,18 @@ impl<Msg: Clone + 'static> ColorPicker<Msg>
|
||||
} );
|
||||
}
|
||||
let hue_row: Element<Msg> = column::<Msg>().spacing( 4.0 )
|
||||
.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::<Msg>().spacing( theme::SPACING * 2.0 )
|
||||
let body = column::<Msg>().spacing( Length::widget( theme::SPACING * 2.0 ) )
|
||||
.push( preview_row )
|
||||
.push( sliders )
|
||||
.push( hue_row );
|
||||
|
||||
container::<Msg>( body )
|
||||
.background( theme::surface_alt() )
|
||||
.padding( theme::PADDING )
|
||||
.padding( Length::widget( theme::PADDING ) )
|
||||
.radius( theme::RADIUS )
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -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<Msg: Clone + 'static> Combo<Msg>
|
||||
// `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,
|
||||
|
||||
@@ -53,17 +53,17 @@ mod tests;
|
||||
/// ```
|
||||
pub struct Container<Msg: Clone>
|
||||
{
|
||||
pub child: Box<Element<Msg>>,
|
||||
pub( crate ) child: Box<Element<Msg>>,
|
||||
/// Optional background paint — flat colour, linear or radial
|
||||
/// gradient. Constructed via [`Container::background`], which
|
||||
/// accepts anything `Into<Paint>` (a plain [`Color`] gets
|
||||
/// promoted to [`Paint::Solid`] via the trait impl).
|
||||
pub background: Option<Paint>,
|
||||
pub( crate ) background: Option<Paint>,
|
||||
/// Slot id of a themed surface (resolved via
|
||||
/// [`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<String>,
|
||||
pub( crate ) surface: Option<String>,
|
||||
/// 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<Msg: Clone>
|
||||
/// 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<f32>,
|
||||
pub( crate ) max_width: Option<f32>,
|
||||
/// 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<Msg: Clone> Container<Msg>
|
||||
|
||||
@@ -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<Msg: Clone>
|
||||
{
|
||||
pub value: Date,
|
||||
pub view_year: i32,
|
||||
pub view_month: u8,
|
||||
pub today: Option<Date>,
|
||||
pub on_change: Option<Arc<dyn Fn( Date ) -> Msg>>,
|
||||
pub on_navigate: Option<Arc<dyn Fn( i32, u8 ) -> Msg>>,
|
||||
pub locale: Locale,
|
||||
pub width: Option<f32>,
|
||||
pub( crate ) value: Date,
|
||||
pub( crate ) view_year: i32,
|
||||
pub( crate ) view_month: u8,
|
||||
pub( crate ) today: Option<Date>,
|
||||
pub( crate ) on_change: Option<Arc<dyn Fn( Date ) -> Msg>>,
|
||||
pub( crate ) on_navigate: Option<Arc<dyn Fn( i32, u8 ) -> Msg>>,
|
||||
pub( crate ) locale: Locale,
|
||||
pub( crate ) width: Option<f32>,
|
||||
}
|
||||
|
||||
impl<Msg: Clone + 'static> DatePicker<Msg>
|
||||
|
||||
@@ -103,23 +103,23 @@ pub const SUBTITLE_SIZE: f32 = 14.0;
|
||||
/// and action buttons.
|
||||
pub struct Dialog<Msg: Clone>
|
||||
{
|
||||
pub title: Option<String>,
|
||||
pub subtitle: Option<String>,
|
||||
pub( crate ) title: Option<String>,
|
||||
pub( crate ) subtitle: Option<String>,
|
||||
/// 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<Box<Element<Msg>>>,
|
||||
pub actions: Vec<Element<Msg>>,
|
||||
pub modal: bool,
|
||||
pub( crate ) body: Option<Box<Element<Msg>>>,
|
||||
pub( crate ) actions: Vec<Element<Msg>>,
|
||||
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<Msg>,
|
||||
pub( crate ) dismiss_msg: Option<Msg>,
|
||||
/// 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<Msg>,
|
||||
pub max_width: f32,
|
||||
pub( crate ) cancel_msg: Option<Msg>,
|
||||
pub( crate ) max_width: f32,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Default for Dialog<Msg>
|
||||
|
||||
@@ -66,8 +66,8 @@ impl<Msg: Clone> Element<Msg>
|
||||
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<Msg: Clone> Element<Msg>
|
||||
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<Msg: Clone> Element<Msg>
|
||||
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<Msg: Clone> Element<Msg>
|
||||
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,
|
||||
|
||||
8
src/widget/external/mod.rs
vendored
8
src/widget/external/mod.rs
vendored
@@ -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.
|
||||
|
||||
@@ -36,8 +36,8 @@ use super::Element;
|
||||
/// yet implemented.
|
||||
pub struct Flex<Msg: Clone>
|
||||
{
|
||||
pub child: Box<Element<Msg>>,
|
||||
pub weight: u32,
|
||||
pub( crate ) child: Box<Element<Msg>>,
|
||||
pub( crate ) weight: u32,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Flex<Msg>
|
||||
|
||||
@@ -78,6 +78,10 @@ pub enum WidgetHandlers<Msg: Clone>
|
||||
on_change: Option<Arc<dyn Fn( f32 ) -> 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<Msg: Clone> Clone for WidgetHandlers<Msg>
|
||||
password_toggle_msg: password_toggle_msg.clone(),
|
||||
}
|
||||
}
|
||||
WidgetHandlers::Slider { on_change, axis, value } =>
|
||||
WidgetHandlers::Slider { on_change, axis, value, thumb_px } =>
|
||||
{
|
||||
WidgetHandlers::Slider { on_change: on_change.clone(), axis: *axis, value: *value }
|
||||
WidgetHandlers::Slider { on_change: on_change.clone(), axis: *axis, value: *value, thumb_px: *thumb_px }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,14 +260,26 @@ impl<Msg: Clone> WidgetHandlers<Msg>
|
||||
{
|
||||
match self
|
||||
{
|
||||
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`
|
||||
|
||||
@@ -30,17 +30,22 @@ use crate::render::Canvas;
|
||||
pub struct Image
|
||||
{
|
||||
/// Raw RGBA pixel data (4 bytes per pixel, straight alpha).
|
||||
pub rgba: Arc<Vec<u8>>,
|
||||
pub( crate ) rgba: Arc<Vec<u8>>,
|
||||
/// Pixel width of the source image.
|
||||
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<Length>,
|
||||
/// 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<Vec<u8>>, width: u32, height: u32 ) -> Self
|
||||
{
|
||||
Self { rgba, width, height, cover: false, display_size: None, opacity: 1.0 }
|
||||
Self { rgba, width, height, cover: false, display_size: None, short_side: None, opacity: 1.0 }
|
||||
}
|
||||
|
||||
/// Load an image from a file path. Supports PNG, JPEG, and other formats
|
||||
@@ -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<Length> ) -> 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();
|
||||
|
||||
@@ -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 ]
|
||||
|
||||
@@ -38,27 +38,27 @@ pub struct ListItem<Msg: Clone>
|
||||
{
|
||||
/// 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<String>,
|
||||
pub( crate ) subtitle: Option<String>,
|
||||
/// Optional right-aligned text (current setting, badge count, "›"
|
||||
/// disclosure). Drawn in muted colour.
|
||||
pub trailing: Option<String>,
|
||||
pub( crate ) trailing: Option<String>,
|
||||
/// Message emitted on tap. `None` keeps the item visible but inert.
|
||||
pub on_press: Option<Msg>,
|
||||
pub( crate ) on_press: Option<Msg>,
|
||||
/// Optional stable identifier for focus management.
|
||||
pub id: Option<WidgetId>,
|
||||
pub( crate ) id: Option<WidgetId>,
|
||||
/// `true` paints the row with the dark selected surface and white
|
||||
/// 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<Vec<u8>>, u32, u32 )>,
|
||||
pub( crate ) icon: Option<( Arc<Vec<u8>>, u32, u32 )>,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> ListItem<Msg>
|
||||
@@ -128,9 +128,14 @@ impl<Msg: Clone> ListItem<Msg>
|
||||
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<Msg: Clone> ListItem<Msg>
|
||||
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<Msg: Clone> ListItem<Msg>
|
||||
// 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 );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Msg: Clone>
|
||||
{
|
||||
pub label: String,
|
||||
pub view: Element<Msg>,
|
||||
pub( crate ) label: String,
|
||||
pub( crate ) view: Element<Msg>,
|
||||
}
|
||||
|
||||
/// Paginated tab container.
|
||||
@@ -61,9 +62,9 @@ pub struct NotebookPage<Msg: Clone>
|
||||
/// notebook with 50 pages costs the same to draw as one with 5.
|
||||
pub struct Notebook<Msg: Clone>
|
||||
{
|
||||
pub pages: Vec<NotebookPage<Msg>>,
|
||||
pub selected: usize,
|
||||
pub on_select: Option<Arc<dyn Fn( usize ) -> Msg>>,
|
||||
pub( crate ) pages: Vec<NotebookPage<Msg>>,
|
||||
pub( crate ) selected: usize,
|
||||
pub( crate ) on_select: Option<Arc<dyn Fn( usize ) -> Msg>>,
|
||||
}
|
||||
|
||||
impl<Msg: Clone + 'static> Notebook<Msg>
|
||||
@@ -138,7 +139,7 @@ impl<Msg: Clone + 'static> Notebook<Msg>
|
||||
};
|
||||
|
||||
column()
|
||||
.spacing( theme::SPACING )
|
||||
.spacing( Length::widget( theme::SPACING ) )
|
||||
.push( strip )
|
||||
.push( active_view )
|
||||
.into()
|
||||
|
||||
@@ -47,16 +47,16 @@ mod tests;
|
||||
/// ```
|
||||
pub struct Pressable<Msg: Clone>
|
||||
{
|
||||
pub child: Box<Element<Msg>>,
|
||||
pub on_press: Option<Msg>,
|
||||
pub on_long_press: Option<Msg>,
|
||||
pub( crate ) child: Box<Element<Msg>>,
|
||||
pub( crate ) on_press: Option<Msg>,
|
||||
pub( crate ) on_long_press: Option<Msg>,
|
||||
/// 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<Msg>,
|
||||
pub( crate ) on_drag_start: Option<Msg>,
|
||||
/// 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<Msg: Clone>
|
||||
/// 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<Msg>,
|
||||
pub( crate ) on_escape: Option<Msg>,
|
||||
/// 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<Msg: Clone>
|
||||
/// 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<WidgetId>,
|
||||
pub cursor: Option<crate::types::CursorShape>,
|
||||
pub( crate ) swallow: bool,
|
||||
pub( crate ) id: Option<WidgetId>,
|
||||
pub( crate ) cursor: Option<crate::types::CursorShape>,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Pressable<Msg>
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -40,14 +40,14 @@ pub struct Radio<Msg: Clone>
|
||||
{
|
||||
/// 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<Msg>,
|
||||
pub( crate ) on_select: Option<Msg>,
|
||||
/// Optional label drawn to the right of the circle.
|
||||
pub label: Option<String>,
|
||||
pub( crate ) label: Option<String>,
|
||||
/// Optional stable identifier for focus management.
|
||||
pub id: Option<WidgetId>,
|
||||
pub( crate ) id: Option<WidgetId>,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Radio<Msg>
|
||||
@@ -83,14 +83,15 @@ impl<Msg: Clone> Radio<Msg>
|
||||
|
||||
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
||||
{
|
||||
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<Msg: Clone> Radio<Msg>
|
||||
|
||||
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<Msg: Clone> Radio<Msg>
|
||||
|
||||
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() );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ mod tests;
|
||||
/// message to emit when it is tapped.
|
||||
pub struct LinkSpan<Msg>
|
||||
{
|
||||
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<Msg>
|
||||
/// hit rect per link line so taps land on the link rather than the paragraph.
|
||||
pub struct RichText<Msg: Clone>
|
||||
{
|
||||
pub content: String,
|
||||
pub size: Length,
|
||||
pub color: Color,
|
||||
pub link_color: Color,
|
||||
pub font: Option<( String, u16, FontStyle )>,
|
||||
pub links: Vec<LinkSpan<Msg>>,
|
||||
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<LinkSpan<Msg>>,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> RichText<Msg>
|
||||
|
||||
@@ -61,11 +61,11 @@ impl ScrollAxis
|
||||
pub struct Scroll<Msg: Clone>
|
||||
{
|
||||
/// The single child element drawn inside the scrollable viewport.
|
||||
pub child: Box<Element<Msg>>,
|
||||
pub( crate ) child: Box<Element<Msg>>,
|
||||
/// Optional stable identifier — used as scroll state key.
|
||||
pub id: Option<WidgetId>,
|
||||
pub( crate ) id: Option<WidgetId>,
|
||||
/// Which axis (or both) this viewport scrolls along.
|
||||
pub axis: ScrollAxis,
|
||||
pub( crate ) axis: ScrollAxis,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Scroll<Msg>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use crate::types::{ Color, Rect };
|
||||
use crate::types::{ Color, Length, Rect };
|
||||
use crate::render::Canvas;
|
||||
use 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<Length>,
|
||||
/// 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<Length>,
|
||||
}
|
||||
|
||||
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<Length> ) -> Self
|
||||
{
|
||||
self.thickness = t;
|
||||
self.thickness = Some( t.into() );
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the vertical padding above and below the line. Accepts
|
||||
/// logical `f32` pixels or any [`Length`]; pass `0.0` for a flush,
|
||||
/// padding-less divider. Without this it follows the widget-scaling mode.
|
||||
pub fn pad_v( mut self, p: impl Into<Length> ) -> Self
|
||||
{
|
||||
self.pad_v = Some( p.into() );
|
||||
self
|
||||
}
|
||||
|
||||
/// 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 );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -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<Rect>
|
||||
/// 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<Msg: Clone>
|
||||
{
|
||||
/// 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<Arc<dyn Fn(f32) -> Msg>>,
|
||||
pub( crate ) on_change: Option<Arc<dyn Fn(f32) -> Msg>>,
|
||||
/// Theme slot id for the track background pill. Defaults to the
|
||||
/// 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<crate::theme::Paint>,
|
||||
pub( crate ) track_paint: Option<crate::theme::Paint>,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Slider<Msg>
|
||||
@@ -204,15 +220,17 @@ impl<Msg: Clone> Slider<Msg>
|
||||
}
|
||||
|
||||
/// 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<Msg: Clone> Slider<Msg>
|
||||
/// 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<Msg: Clone> Slider<Msg>
|
||||
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<Msg: Clone> Slider<Msg>
|
||||
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<Msg: Clone> Slider<Msg>
|
||||
}
|
||||
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 );
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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 )
|
||||
}
|
||||
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -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<Msg: Clone>
|
||||
{
|
||||
pub labels: Vec<String>,
|
||||
pub selected: usize,
|
||||
pub on_select: Option<std::sync::Arc<dyn Fn( usize ) -> Msg>>,
|
||||
pub( crate ) labels: Vec<String>,
|
||||
pub( crate ) selected: usize,
|
||||
pub( crate ) on_select: Option<std::sync::Arc<dyn Fn( usize ) -> Msg>>,
|
||||
/// Background colour of the strip itself. `None` paints no
|
||||
/// background, letting the parent surface show through.
|
||||
pub strip_bg: Option<Color>,
|
||||
pub( crate ) strip_bg: Option<Color>,
|
||||
}
|
||||
|
||||
impl<Msg: Clone + 'static> TabBar<Msg>
|
||||
@@ -104,7 +104,7 @@ impl<Msg: Clone + 'static> TabBar<Msg>
|
||||
let cb = self.on_select.clone();
|
||||
let labels = self.labels;
|
||||
|
||||
let mut r = row::<Msg>().padding( theme::PADDING ).spacing( theme::SPACING );
|
||||
let mut r = row::<Msg>().padding( Length::widget( theme::PADDING ) ).spacing( Length::widget( theme::SPACING ) );
|
||||
for ( i, label ) in labels.into_iter().enumerate()
|
||||
{
|
||||
let is_active = i == selected;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -81,7 +81,7 @@ impl<Msg: Clone> TextEdit<Msg>
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Msg: Clone>
|
||||
{
|
||||
/// 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<Arc<dyn Fn(String) -> Msg>>,
|
||||
pub( crate ) on_change: Option<Arc<dyn Fn(String) -> Msg>>,
|
||||
/// Message emitted when the user presses Enter.
|
||||
pub on_submit: Option<Msg>,
|
||||
pub( crate ) on_submit: Option<Msg>,
|
||||
/// When `true`, the value is rendered as bullet characters (password mode).
|
||||
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<WidgetId>,
|
||||
pub( crate ) id: Option<WidgetId>,
|
||||
/// Override the pointer cursor shape on hover. `None` falls back
|
||||
/// to the I-beam default that matches every desktop convention.
|
||||
pub cursor: Option<crate::types::CursorShape>,
|
||||
pub( crate ) cursor: Option<crate::types::CursorShape>,
|
||||
/// Horizontal alignment of the displayed text inside the inner
|
||||
/// 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<f32>,
|
||||
/// 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<f32>,
|
||||
/// 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<Length>,
|
||||
/// 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<Msg: Clone>
|
||||
/// `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<Msg: Clone> TextEdit<Msg>
|
||||
@@ -181,7 +206,8 @@ impl<Msg: Clone> TextEdit<Msg>
|
||||
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<Msg: Clone> TextEdit<Msg>
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Length> ) -> 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<Msg: Clone> TextEdit<Msg>
|
||||
///
|
||||
/// 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<Msg: Clone> TextEdit<Msg>
|
||||
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<Msg: Clone> TextEdit<Msg>
|
||||
}
|
||||
|
||||
/// 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<Msg: Clone> TextEdit<Msg>
|
||||
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,
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<Msg: Clone>
|
||||
{
|
||||
pub value: Time,
|
||||
pub on_change: Option<Arc<dyn Fn( Time ) -> 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<Arc<dyn Fn( Time ) -> Msg>>,
|
||||
pub( crate ) minute_step: u8,
|
||||
pub( crate ) second_step: u8,
|
||||
pub( crate ) seconds: bool,
|
||||
pub( crate ) twelve_hour: bool,
|
||||
}
|
||||
|
||||
impl<Msg: Clone + 'static> TimePicker<Msg>
|
||||
@@ -305,7 +306,7 @@ impl<Msg: Clone + 'static> TimePicker<Msg>
|
||||
None =>
|
||||
{
|
||||
text( display )
|
||||
.size( theme::VAL_FS )
|
||||
.size( Length::widget( theme::VAL_FS ) )
|
||||
.color( theme::text() )
|
||||
.align_center()
|
||||
.into()
|
||||
@@ -317,7 +318,7 @@ impl<Msg: Clone + 'static> TimePicker<Msg>
|
||||
text_edit::<Msg>( "", 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<Msg: Clone + 'static> TimePicker<Msg>
|
||||
);
|
||||
let minute_col = stepper( minute_field, minute_up, minute_dn );
|
||||
|
||||
let mut units = row::<Msg>().spacing( theme::SPACING )
|
||||
let mut units = row::<Msg>().spacing( Length::widget( theme::SPACING ) )
|
||||
.push( hour_col )
|
||||
.push( 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<Msg: Clone + 'static> TimePicker<Msg>
|
||||
);
|
||||
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<Msg: Clone + 'static> TimePicker<Msg>
|
||||
// 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<Msg: Clone + 'static> TimePicker<Msg>
|
||||
// AM/PM), so they centre automatically inside the container.
|
||||
container::<Msg>( units )
|
||||
.background( theme::surface_alt() )
|
||||
.padding( theme::PADDING )
|
||||
.padding( Length::widget( theme::PADDING ) )
|
||||
.radius( theme::RADIUS )
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -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<Msg: Clone>
|
||||
/// 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<Msg>,
|
||||
pub( crate ) on_dismiss: Option<Msg>,
|
||||
/// Optional override for the pill background colour.
|
||||
pub bg: Option<Color>,
|
||||
pub( crate ) bg: Option<Color>,
|
||||
/// Optional override for the body text colour.
|
||||
pub fg: Option<Color>,
|
||||
pub( crate ) fg: Option<Color>,
|
||||
}
|
||||
|
||||
impl<Msg: Clone + 'static> Toast<Msg>
|
||||
@@ -165,14 +165,17 @@ impl<Msg: Clone + 'static> Toast<Msg>
|
||||
let bg = self.bg.unwrap_or_else( theme::bg );
|
||||
let fg = self.fg.unwrap_or_else( theme::text );
|
||||
|
||||
// Inline path: the pill lays out in the app's own (screen-sized)
|
||||
// surface and the container auto-sizes to the text, so fluid font /
|
||||
// padding scale with the screen without any clipping.
|
||||
let pill: Element<Msg> = container(
|
||||
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<Msg: Clone + 'static> Toast<Msg>
|
||||
|
||||
let pill: Element<Msg> = 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<Msg: Clone + 'static> Toast<Msg>
|
||||
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,
|
||||
|
||||
@@ -38,14 +38,14 @@ pub struct Toggle<Msg: Clone>
|
||||
{
|
||||
/// 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<Msg>,
|
||||
pub( crate ) on_toggle: Option<Msg>,
|
||||
/// Optional label drawn to the left of the track.
|
||||
pub label: Option<String>,
|
||||
pub( crate ) label: Option<String>,
|
||||
/// Optional stable identifier for focus management.
|
||||
pub id: Option<WidgetId>,
|
||||
pub( crate ) id: Option<WidgetId>,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Toggle<Msg>
|
||||
@@ -92,14 +92,15 @@ impl<Msg: Clone> Toggle<Msg>
|
||||
/// 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<Msg: Clone> Toggle<Msg>
|
||||
|
||||
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 );
|
||||
|
||||
@@ -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<Msg: Clone>
|
||||
{
|
||||
/// 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<Color>,
|
||||
pub( crate ) bg: Option<Color>,
|
||||
/// Optional override for the body text colour.
|
||||
pub fg: Option<Color>,
|
||||
pub( crate ) fg: Option<Color>,
|
||||
/// Optional message fired when the user taps outside the popup.
|
||||
pub on_dismiss: Option<Msg>,
|
||||
pub( crate ) on_dismiss: Option<Msg>,
|
||||
}
|
||||
|
||||
impl<Msg: Clone + 'static> Tooltip<Msg>
|
||||
@@ -129,12 +129,12 @@ impl<Msg: Clone + 'static> Tooltip<Msg>
|
||||
|
||||
let body: Element<Msg> = 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<Msg: Clone + 'static> Tooltip<Msg>
|
||||
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,
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::widget::Element;
|
||||
pub struct Viewport<Msg: Clone>
|
||||
{
|
||||
/// The child element to render inside the viewport.
|
||||
pub child: Box<Element<Msg>>,
|
||||
pub( crate ) child: Box<Element<Msg>>,
|
||||
/// Optional fixed width in logical pixels. When omitted the
|
||||
/// 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<Msg: Clone>
|
||||
/// 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<f32>,
|
||||
pub( crate ) width: Option<f32>,
|
||||
/// Optional fixed height in logical pixels. When omitted the viewport
|
||||
/// reports the child's natural height.
|
||||
pub height: Option<f32>,
|
||||
pub( crate ) height: Option<f32>,
|
||||
/// Logical pixels at the bottom edge that fade to transparent during the
|
||||
/// 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<Msg: Clone> Viewport<Msg>
|
||||
|
||||
@@ -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<Msg: Clone>
|
||||
{
|
||||
/// 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<Arc<dyn Fn(f32) -> Msg>>,
|
||||
pub( crate ) on_change: Option<Arc<dyn Fn(f32) -> 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<Msg: Clone> VSlider<Msg>
|
||||
@@ -93,8 +93,8 @@ impl<Msg: Clone> VSlider<Msg>
|
||||
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,
|
||||
|
||||
@@ -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 ]
|
||||
|
||||
@@ -38,23 +38,24 @@ pub enum WindowButtonKind
|
||||
pub struct WindowButton<Msg: Clone>
|
||||
{
|
||||
/// 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<Msg>,
|
||||
/// 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<Msg>,
|
||||
/// 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<WidgetId>,
|
||||
pub( crate ) id: Option<WidgetId>,
|
||||
/// 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<Msg: Clone> WindowButton<Msg>
|
||||
@@ -67,7 +68,7 @@ impl<Msg: Clone> WindowButton<Msg>
|
||||
{
|
||||
kind,
|
||||
on_press: None,
|
||||
size: theme::SIZE,
|
||||
size: 0.0,
|
||||
id: None,
|
||||
focusable: false,
|
||||
}
|
||||
@@ -112,9 +113,10 @@ impl<Msg: Clone> WindowButton<Msg>
|
||||
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 )
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
//!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ───────────────────────────────────
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![ cfg( feature = "test-support" ) ]
|
||||
|
||||
use ltk::test_support::diff_overlay_ids;
|
||||
use ltk::OverlayId;
|
||||
|
||||
|
||||
@@ -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 ) );
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![ cfg( feature = "test-support" ) ]
|
||||
|
||||
mod common;
|
||||
use common::{ lw_none, rect };
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![ cfg( feature = "test-support" ) ]
|
||||
|
||||
mod common;
|
||||
use common::{ lw_none, lw_button, rect };
|
||||
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user