responsive fluid/physical scaling, widget-API stabilization, and perf guardrails
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
+88 -30
View File
@@ -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,