diff --git a/CHANGELOG.md b/CHANGELOG.md index 1553d1c..200d3df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Responsive sizing system** with two selectable modes via `WidgetScaling` (`Fluid` / `Physical`; `set_widget_scaling` / `widget_scaling`, default `Fluid`). New `Length` constructors — `orient( portrait, landscape )` (a percentage of the width in portrait, of the height in landscape), `fluid( px )` (surface-proportional, calibrated against `set_fluid_reference` and bounded by `FLUID_MIN` / `FLUID_MAX`), `dp( px )` (constant physical size scaled by `set_density` / `density`), and `widget( px )` (picks fluid or dp per the active mode). `Canvas::geom_px` (geometry, physical layout space) and `Canvas::font_px` (font, bridging the logical / physical split per mode) give widgets and apps one resolution path. +- **`Button::font_size` / `height` / `width`** and **`TextEdit::height`** builders, all `impl Into`, so control boxes scale with the surface. `Text::line_height( mult )` opens the gap between wrapped lines. `Separator::pad_v` (with `Length::px( 0.0 )` for a flush divider). +- **Performance guardrails**: opt-in diagnostics via `LTK_PERF_WARN=1` (stuck animation, sustained software-render animation, low `poll_interval`) and a ~30 Hz software-animation cap overridable with `App::cap_software_animation`. +- **`test-support` Cargo feature** gates the `test_support` module so third-party builds never see it (ltk's own `make test` enables it). + +### Changed + +- **`OverlaySpec::size`** is now `( Length, Length )` (was `( u32, u32 )`), resolved against the main surface when the overlay is materialized; wrap existing sizes in `Length::px( … )` for the old fixed behaviour. +- **`TextEdit::font_size`** and **`Separator::thickness` / `pad_v`** now take `impl Into` (were `f32`), resolved like the button label (font space) / geometry space; the `f32` sentinels are gone (`f32` call sites still compile via `Into`). +- **Renamed** `set_design_reference` / `design_reference` → `set_fluid_reference` / `fluid_reference`. **`Length::dp` changed meaning** — it used to be a surface-proportional value, and that behaviour now lives on `Length::fluid`; `dp` is the constant-physical-size unit. +- **Widget struct fields are now `pub( crate )`** (configured through builders), except the value / state types apps read or construct (`Time`, `Date`, `ComboState`). + ## [0.2.0] — 2026-06-25 This release adds the primitives an embedder needs to drive ltk as the render backend for a retained, externally-owned widget tree (for example projecting an Android view hierarchy onto an ltk surface). Each is kept general rather than tied to one consumer. diff --git a/docs/widgets.md b/docs/widgets.md index d020b4a..abd6e20 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -37,15 +37,21 @@ patterns built from these widgets, see [`docs/cookbook.md`](./cookbook.md). ### `button` A standard text button. Activates on tap, Enter, or Space when focused. +`font_size`, `height` and `width` all take an `impl Into`, so the +box can scale with the surface (e.g. a full-width form button) instead of +sizing to its label. **When**: any place a normal app would have a "Save" / "Cancel" / "Send" control. ```rust,no_run -# use ltk::{ button, Element }; +# use ltk::{ button, Element, Length }; # #[ derive( Clone ) ] enum Msg { Save } # fn _ex() -> Element { -button( "Save" ).on_press( Msg::Save ) +button( "Save" ) + .height( Length::vmin( 9.0 ).clamp( 44.0, 72.0 ) ) + .width( Length::orient( 95.0, 25.0 ) ) + .on_press( Msg::Save ) .into() # } ``` @@ -297,18 +303,24 @@ without a known fraction. ### `text` -A single-line label. Truncates with an ellipsis when wider than its -allocated rect. +A text label. By default it stays on one line and truncates with an +ellipsis when wider than its rect; `wrap( true )` instead word-wraps to +the layout width, and `line_height( mult )` scales the gap between the +wrapped lines (`1.0` = the font's natural leading). -**When**: titles, captions, anything non-interactive. +**When**: titles, captions, multi-line hints, anything non-interactive. ```rust,no_run # use ltk::{ text, Color, Element }; # #[ derive( Clone ) ] enum Msg {} -# fn _ex() -> ( Element, Element ) { +# fn _ex() -> ( Element, Element, Element ) { let title = text( "Title" ).size( 24.0 ).color( Color::WHITE ); let centred = text( "Centred" ).align_center(); -# ( title.into(), centred.into() ) +let hint = text( "Swipe up or press Enter to unlock" ) + .align_center() + .wrap( true ) + .line_height( 1.4 ); +# ( title.into(), centred.into(), hint.into() ) # } ``` @@ -424,7 +436,11 @@ card interactive. ### `separator` -A horizontal divider line with theme-default colour and 1 px thickness. +A horizontal divider line with a theme-default colour and a mode-scaled +thickness / vertical padding. `thickness` and `pad_v` take an `impl +Into`; both default to the process widget-scaling mode, and +passing `pad_v( 0.0 )` gives a flush, padding-less divider (distinct from +the mode default). **When**: visual breaks between settings groups, list categories, content blocks. diff --git a/src/event_loop/text_editing/ime.rs b/src/event_loop/text_editing/ime.rs index a26d9d2..1e9b812 100644 --- a/src/event_loop/text_editing/ime.rs +++ b/src/event_loop/text_editing/ime.rs @@ -8,7 +8,7 @@ use crate::app::App; use crate::event_loop::app_data::AppData; use crate::event_loop::surface::SurfaceFocus; use crate::tree::find_widget; -use crate::types::Rect; +use crate::types::{ Length, Rect }; use crate::widget::WidgetHandlers; impl AppData @@ -74,7 +74,7 @@ impl AppData &self, focus: SurfaceFocus, idx: usize, - ) -> Option<( Rect, String, bool, bool, crate::widget::text::TextAlign, f32 )> + ) -> Option<( Rect, String, bool, bool, crate::widget::text::TextAlign, Option )> { let ss = self.surface( focus ); let widget = find_widget( &ss.frame.widget_rects, idx )?; diff --git a/src/widget/button/mod.rs b/src/widget/button/mod.rs index 7f987e8..c6f989a 100644 --- a/src/widget/button/mod.rs +++ b/src/widget/button/mod.rs @@ -79,6 +79,11 @@ pub struct Button /// 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, + /// Optional fixed width for text buttons. `None` sizes the button to its + /// label plus horizontal padding; a [`Length`] pins the width (e.g. to + /// make a full-width or surface-proportional button), clamped to the + /// available width. Resolved in physical layout space. + pub( crate ) width: Option, /// Optional stable identifier for focus management. pub( crate ) id: Option, /// Whether this button participates in keyboard focus (Tab). Default: `true`. @@ -113,6 +118,7 @@ impl Button icon_size: 0.0, font_size: None, height: None, + width: None, id: None, focusable: true, cursor: None, @@ -150,6 +156,7 @@ impl Button icon_size: 0.0, font_size: None, height: None, + width: None, id: None, focusable: true, cursor: None, @@ -256,6 +263,16 @@ impl Button self } + /// Pin the button width for text buttons. Accepts logical `f32` pixels or + /// any [`Length`] (e.g. `Length::orient( 95.0, 25.0 )` for a + /// surface-proportional width). Without this the button sizes to its + /// label. Clamped to the available width. No-op for icon buttons. + pub fn width( mut self, w: impl Into ) -> Self + { + self.width = Some( w.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`]. @@ -329,8 +346,15 @@ impl Button { ButtonContent::Text( label ) => { - 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 ); + let w = match self.width + { + Some( l ) => l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ).min( max_width ), + None => + { + let text_w = canvas.measure_text( label, self.label_font_size( canvas ) ); + ( text_w + canvas.geom_px( theme::PAD_H ) * 2.0 ).min( max_width ) + } + }; ( w, self.resolved_height( canvas ) ) } ButtonContent::Icon { .. } => @@ -510,6 +534,7 @@ impl Button icon_size: self.icon_size, font_size: self.font_size, height: self.height, + width: self.width, id: self.id, focusable: self.focusable, cursor: self.cursor, diff --git a/src/widget/button/tests.rs b/src/widget/button/tests.rs index 03ba2f6..d601b92 100644 --- a/src/widget/button/tests.rs +++ b/src/widget/button/tests.rs @@ -150,3 +150,25 @@ fn height_builder_scales_button_box_with_surface() let canvas = Canvas::new( 400, 800 ); assert_eq!( b.preferred_size( 1000.0, &canvas ).1, 40.0 ); } + +#[ test ] +fn width_none_by_default_sizes_to_content() +{ + 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.width.is_none() ); + // No pinned width → sizes to the label + padding, narrower than a + // generous max_width. + assert!( b.preferred_size( 1000.0, &canvas ).0 < 1000.0 ); +} + +#[ test ] +fn width_builder_pins_button_width() +{ + // vmin width on a 400×800 surface → 10 % of the smaller side (400) = 40 px, + // resolved in physical layout space, clamped to the available max_width. + let b = Button::<()>::new( "ok".into() ).width( Length::vmin( 10.0 ) ); + let canvas = Canvas::new( 400, 800 ); + assert_eq!( b.preferred_size( 1000.0, &canvas ).0, 40.0 ); +} diff --git a/src/widget/handlers.rs b/src/widget/handlers.rs index c66a4fb..31a9706 100644 --- a/src/widget/handlers.rs +++ b/src/widget/handlers.rs @@ -2,7 +2,7 @@ // Copyright (C) 2026 Liberux Labs, S. L. use std::sync::Arc; -use crate::types::{ Point, Rect }; +use crate::types::{ Length, Point, Rect }; use super::{ slider, text }; /// Per-leaf interaction snapshot captured during layout. One variant per @@ -56,9 +56,9 @@ pub enum WidgetHandlers align: text::TextAlign, /// Font size snapshot — needed by the hit-testing path so /// the runtime measures glyphs at the same size the renderer - /// drew them. Always the default `theme::FONT_SIZE` for - /// fields that do not call `.font_size( … )`. - font_size: f32, + /// drew them. `None` for fields that do not call + /// `.font_size( … )` (they follow the widget-scaling mode). + font_size: Option, /// `true` when the source field opted into select-all-on- /// focus. The runtime reads this in `set_focus` to decide /// whether the new selection should anchor at `0` (replace diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs index af63c19..0d3aecb 100644 --- a/src/widget/text/mod.rs +++ b/src/widget/text/mod.rs @@ -51,6 +51,10 @@ pub struct Text /// the active theme's font registry on every draw. `None` keeps /// the canvas default font (Sora Regular in `ltk-theme-default`). pub( crate ) font: Option<( String, u16, FontStyle )>, + /// Multiplier applied to the font's natural line height when wrapping + /// onto multiple lines. `1.0` (default) uses the font-declared leading; + /// values above `1.0` open up the gap between wrapped lines. + pub( crate ) line_height: f32, } impl Text @@ -68,6 +72,7 @@ impl Text wrap: false, truncate: true, font: None, + line_height: 1.0, } } @@ -146,6 +151,15 @@ impl Text self } + /// Multiply the natural line height for wrapped text. `1.0` keeps the + /// font's declared leading; e.g. `1.4` opens the gap between wrapped + /// lines. Clamped to a `0.5` floor so lines never overlap. + pub fn line_height( mut self, mult: f32 ) -> Self + { + self.line_height = mult.max( 0.5 ); + self + } + fn resolve_font( &self, canvas: &Canvas ) -> Option> { self.font.as_ref().map( |( family, weight, style )| @@ -193,7 +207,7 @@ impl Text // visibly overlapping when stacked tight in a column. let line_h = canvas.font_line_metrics( size ) .map( |m| m.new_line_size ) - .unwrap_or( size ); + .unwrap_or( size ) * self.line_height; let font = self.resolve_font( canvas ); if self.wrap @@ -217,7 +231,7 @@ impl Text .unwrap_or( size * 0.8 ); let line_h = canvas.font_line_metrics( size ) .map( |m| m.new_line_size ) - .unwrap_or( size ); + .unwrap_or( size ) * self.line_height; let font = self.resolve_font( canvas ); if self.wrap diff --git a/src/widget/text/tests.rs b/src/widget/text/tests.rs index 489df37..d22ed45 100644 --- a/src/widget/text/tests.rs +++ b/src/widget/text/tests.rs @@ -93,6 +93,30 @@ fn empty_content_still_reports_a_line_height() assert!( h > 0.0 ); } +#[ test ] +fn line_height_builder_defaults_to_one_and_clamps() +{ + assert_eq!( Text::new( "" ).line_height, 1.0 ); + assert_eq!( Text::new( "" ).line_height( 1.5 ).line_height, 1.5 ); + // Floor keeps wrapped lines from overlapping even at absurd inputs. + assert_eq!( Text::new( "" ).line_height( 0.1 ).line_height, 0.5 ); +} + +#[ test ] +fn line_height_multiplies_wrapped_text_height() +{ + let canvas = make_canvas(); + // Long enough to wrap onto several lines at this width. + let long = "the quick brown fox jumps over the lazy dog"; + let base = Text::new( long ).size( 24.0 ).wrap( true ); + let doubled = Text::new( long ).size( 24.0 ).wrap( true ).line_height( 2.0 ); + let h_base = base.preferred_size( 120.0, &canvas ).1; + let h_doubled = doubled.preferred_size( 120.0, &canvas ).1; + // Wrapping is width-driven, so both report the same line count; doubling + // the line height therefore doubles the reported height. + assert!( ( h_doubled - 2.0 * h_base ).abs() < 1e-3, "h_base={h_base} h_doubled={h_doubled}" ); +} + #[ test ] fn text_align_enum_implements_partial_eq() { diff --git a/src/widget/text_edit/hit_test.rs b/src/widget/text_edit/hit_test.rs index 125713c..8edfaf3 100644 --- a/src/widget/text_edit/hit_test.rs +++ b/src/widget/text_edit/hit_test.rs @@ -2,7 +2,7 @@ // Copyright (C) 2026 Liberux Labs, S. L. use crate::render::Canvas; -use crate::types::{ Point, Rect }; +use crate::types::{ Length, Point, Rect }; use super::theme; use super::wrapping::compute_visual_lines; @@ -26,7 +26,7 @@ pub( crate ) fn byte_offset_at( secure: bool, cursor_pos: usize, align: crate::widget::text::TextAlign, - font_size: f32, + font_size: Option, ) -> usize { if value.is_empty() { return 0; } diff --git a/src/widget/text_edit/mod.rs b/src/widget/text_edit/mod.rs index 2b5a602..e061d45 100644 --- a/src/widget/text_edit/mod.rs +++ b/src/widget/text_edit/mod.rs @@ -26,21 +26,16 @@ 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 +/// Resolve the font size into a concrete logical px. `Some( len )` resolves +/// against [`Canvas::viewport_logical`] — the font space, so `× dpi_scale` at +/// raster yields the right physical size — exactly like the button label; +/// `None` follows the widget-scaling mode at the theme default. Shared by the +/// draw and hit-test paths, which all carry a canvas. +pub( crate ) fn resolve_font_size( canvas: &Canvas, fs: Option ) -> f32 { - if fs > 0.0 - { - fs - } else if fs == 0.0 { - canvas.font_px( theme::FONT_SIZE ) - } else { - canvas.font_px( -fs ) - } + fs + .map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) ) + .unwrap_or_else( || canvas.font_px( theme::FONT_SIZE ) ) } /// A text input field. @@ -150,13 +145,14 @@ pub struct TextEdit /// numeric inputs). pub( crate ) fixed_width: Option, /// 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, + /// Label font size. `None` follows the process [`crate::WidgetScaling`] + /// mode at the theme default (via [`crate::Canvas::font_px`]); a + /// [`Length`] pins it and is resolved in font space + /// ([`crate::Canvas::viewport_logical`]), exactly like the button label. + /// Resolved via [`Self::effective_font_size`] by every consumer, all of + /// which carry a canvas. Multiline mode ignores this and always uses the + /// theme constant. + pub( crate ) font_size: Option, /// 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 @@ -206,7 +202,7 @@ impl TextEdit align: super::text::TextAlign::Left, borderless: false, fixed_width: None, - font_size: 0.0, + font_size: None, height: None, select_on_focus: false, password_toggle: None, @@ -240,24 +236,24 @@ impl TextEdit } } - /// 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 + /// Set the label font size. Accepts logical `f32` pixels or any + /// [`Length`] (e.g. `Length::vmin( 4.5 ).clamp( 16.0, 32.0 )` to scale + /// with the surface), resolved in font space exactly like the button + /// label. Without any font-size call the size follows the process + /// [`crate::WidgetScaling`] mode at the theme default. Ignored in + /// multiline mode. + pub fn font_size( mut self, size: impl Into ) -> Self { - self.font_size = px.max( 1.0 ); + self.font_size = Some( size.into() ); 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`. + /// [`Self::font_size`]. Shorthand for `.font_size( Length::fluid( n ) )`. pub fn font_size_fluid( mut self, design_px: f32 ) -> Self { - self.font_size = -design_px.max( 1.0 ); + self.font_size = Some( Length::fluid( design_px.max( 1.0 ) ) ); self } diff --git a/src/widget/text_edit/tests.rs b/src/widget/text_edit/tests.rs index a46186a..5c93c33 100644 --- a/src/widget/text_edit/tests.rs +++ b/src/widget/text_edit/tests.rs @@ -444,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, 0.0 ); // sentinel: follows the widget-scaling mode + assert_eq!( t.font_size, None ); // follows the widget-scaling mode assert!( !t.select_on_focus ); } @@ -473,25 +473,26 @@ fn fixed_width_builder_stores_value() } #[ test ] -fn font_size_builder_clamps_to_at_least_one_pixel() +fn font_size_builder_stores_length() { let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size( 28.0 ); - assert_eq!( t.font_size, 28.0 ); - let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size( -3.0 ); - assert_eq!( t.font_size, 1.0 ); + assert_eq!( t.font_size, Some( crate::types::Length::px( 28.0 ) ) ); + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size( crate::types::Length::vmin( 4.5 ) ); + assert_eq!( t.font_size, Some( crate::types::Length::vmin( 4.5 ) ) ); } #[ test ] -fn font_size_fluid_encodes_negative_sentinel_and_resolves_via_mode() +fn font_size_fluid_follows_the_mode_at_the_design_px() { use crate::render::Canvas; let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() ); + crate::set_widget_scaling( crate::WidgetScaling::Fluid ); 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. + assert_eq!( t.font_size, Some( crate::types::Length::fluid( 28.0 ) ) ); + // The fluid design px resolves the same as the mode's font_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. + // The default follows 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 ) ); } @@ -556,7 +557,7 @@ fn font_size_propagates_to_widget_handler_snapshot() .into_element(); match widget.handlers() { - WidgetHandlers::TextEdit { font_size, .. } => assert!( ( font_size - 28.0 ).abs() < 1e-6 ), + WidgetHandlers::TextEdit { font_size, .. } => assert_eq!( font_size, Some( crate::types::Length::px( 28.0 ) ) ), _ => panic!( "expected TextEdit handler" ), } }