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

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

View File

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