responsive fluid/physical scaling, widget-API stabilization, and perf guardrails
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

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

View File

@@ -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 );
}
}