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:
+88
-30
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user