text_edit font sizing on Length; add Button::width and Text::line_height
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

Unify TextEdit's font size with the button label. The button resolved its `font_size` Length in font space (`viewport_logical`, so the `× dpi_scale` at raster lands the right physical size), while `TextEdit::font_size` took a raw `f32` that the draw / hit-test paths treated as a logical size. A caller that resolved a Length against the physical surface and passed the result as that `f32` therefore double-counted `dpi_scale` and got a font that rendered too large. `TextEdit::font_size` now takes `impl Into<Length>` and is resolved against `viewport_logical` exactly like the button, so the two paths agree. The field stores `Option<Length>` (`None` follows the widget-scaling mode at the theme default), retiring the old `f32` sentinel (`0.0` = mode, negative = fluid design px). `font_size_fluid` becomes shorthand for `Length::fluid( n )`. The `Option<Length>` flows through the `WidgetHandlers::TextEdit` snapshot and `text_input_geometry` and is resolved at draw / hit-test time, both of which carry a canvas; the inner measure helpers (`wrapping`, `hit_test`) keep `f32` because they receive the already-resolved value. Backward compatible: `f32` call sites still compile via `f32: Into<Length>` (→ `Length::px`), with the same result as before.
Add `Button::width( impl Into<Length> )`. Text buttons size to their label plus padding; some layouts need a pinned width instead — a full-width or surface-proportional button. The new builder mirrors `height`: resolved in physical layout space, clamped to the available `max_width`, propagated through `map_msg`, and a no-op for icon buttons.
Add `Text::line_height( mult )`. Wrapped multi-line text used the font's declared leading (`new_line_size`), which is tight for some labels; the multiplier scales the gap between wrapped lines (`1.0`, the default, keeps the natural leading — every other `text` is unchanged — and a `0.5` floor keeps lines from overlapping). Applied uniformly in `preferred_size` and `draw` so the reported height and the drawn baselines stay consistent.
Tests: `button` gains a pinned-width and a size-to-content case; `text` gains a line-height default / clamp test and a check that doubling the line height doubles a wrapped block's reported height. Docs: `docs/widgets.md` `text` / `button` / `separator` sections updated for the new builders, and a `CHANGELOG.md` "Unreleased" section covering this batch alongside the responsive work already landed.
This commit is contained in:
2026-07-10 10:38:30 +02:00
parent ce893ac776
commit 8762ab9ce0
11 changed files with 176 additions and 62 deletions

View File

@@ -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). 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<Length>`, 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<Length>` (were `f32`), resolved like the button label (font space) / geometry space; the `f32` sentinels are gone (`f32` call sites still compile via `Into<Length>`).
- **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 ## [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. 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.

View File

@@ -37,15 +37,21 @@ patterns built from these widgets, see [`docs/cookbook.md`](./cookbook.md).
### `button` ### `button`
A standard text button. Activates on tap, Enter, or Space when focused. A standard text button. Activates on tap, Enter, or Space when focused.
`font_size`, `height` and `width` all take an `impl Into<Length>`, 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" **When**: any place a normal app would have a "Save" / "Cancel" / "Send"
control. control.
```rust,no_run ```rust,no_run
# use ltk::{ button, Element }; # use ltk::{ button, Element, Length };
# #[ derive( Clone ) ] enum Msg { Save } # #[ derive( Clone ) ] enum Msg { Save }
# fn _ex() -> Element<Msg> { # fn _ex() -> Element<Msg> {
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() .into()
# } # }
``` ```
@@ -297,18 +303,24 @@ without a known fraction.
### `text` ### `text`
A single-line label. Truncates with an ellipsis when wider than its A text label. By default it stays on one line and truncates with an
allocated rect. 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 ```rust,no_run
# use ltk::{ text, Color, Element }; # use ltk::{ text, Color, Element };
# #[ derive( Clone ) ] enum Msg {} # #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> ( Element<Msg>, Element<Msg> ) { # fn _ex() -> ( Element<Msg>, Element<Msg>, Element<Msg> ) {
let title = text( "Title" ).size( 24.0 ).color( Color::WHITE ); let title = text( "Title" ).size( 24.0 ).color( Color::WHITE );
let centred = text( "Centred" ).align_center(); 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` ### `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<Length>`; 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, **When**: visual breaks between settings groups, list categories,
content blocks. content blocks.

View File

@@ -8,7 +8,7 @@ use crate::app::App;
use crate::event_loop::app_data::AppData; use crate::event_loop::app_data::AppData;
use crate::event_loop::surface::SurfaceFocus; use crate::event_loop::surface::SurfaceFocus;
use crate::tree::find_widget; use crate::tree::find_widget;
use crate::types::Rect; use crate::types::{ Length, Rect };
use crate::widget::WidgetHandlers; use crate::widget::WidgetHandlers;
impl<A: App> AppData<A> impl<A: App> AppData<A>
@@ -74,7 +74,7 @@ impl<A: App> AppData<A>
&self, &self,
focus: SurfaceFocus, focus: SurfaceFocus,
idx: usize, idx: usize,
) -> Option<( Rect, String, bool, bool, crate::widget::text::TextAlign, f32 )> ) -> Option<( Rect, String, bool, bool, crate::widget::text::TextAlign, Option<Length> )>
{ {
let ss = self.surface( focus ); let ss = self.surface( focus );
let widget = find_widget( &ss.frame.widget_rects, idx )?; let widget = find_widget( &ss.frame.widget_rects, idx )?;

View File

@@ -79,6 +79,11 @@ pub struct Button<Msg: Clone>
/// surface so it does not stay frozen while the rest of a fluid layout /// surface so it does not stay frozen while the rest of a fluid layout
/// grows. Resolved in physical layout space, like all geometry. /// grows. Resolved in physical layout space, like all geometry.
pub( crate ) height: Option<Length>, pub( crate ) height: Option<Length>,
/// 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<Length>,
/// Optional stable identifier for focus management. /// Optional stable identifier for focus management.
pub( crate ) id: Option<WidgetId>, pub( crate ) id: Option<WidgetId>,
/// Whether this button participates in keyboard focus (Tab). Default: `true`. /// Whether this button participates in keyboard focus (Tab). Default: `true`.
@@ -113,6 +118,7 @@ impl<Msg: Clone> Button<Msg>
icon_size: 0.0, icon_size: 0.0,
font_size: None, font_size: None,
height: None, height: None,
width: None,
id: None, id: None,
focusable: true, focusable: true,
cursor: None, cursor: None,
@@ -150,6 +156,7 @@ impl<Msg: Clone> Button<Msg>
icon_size: 0.0, icon_size: 0.0,
font_size: None, font_size: None,
height: None, height: None,
width: None,
id: None, id: None,
focusable: true, focusable: true,
cursor: None, cursor: None,
@@ -256,6 +263,16 @@ impl<Msg: Clone> Button<Msg>
self 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<Length> ) -> Self
{
self.width = Some( w.into() );
self
}
/// Resolve the label font size against the canvas viewport, matching how /// Resolve the label font size against the canvas viewport, matching how
/// [`text`](crate::text) sizes its glyphs. An explicit override bypasses /// [`text`](crate::text) sizes its glyphs. An explicit override bypasses
/// the mode; the default follows the process [`crate::WidgetScaling`]. /// the mode; the default follows the process [`crate::WidgetScaling`].
@@ -328,9 +345,16 @@ impl<Msg: Clone> Button<Msg>
match &self.content match &self.content
{ {
ButtonContent::Text( label ) => ButtonContent::Text( label ) =>
{
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 ) ); 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 ); ( text_w + canvas.geom_px( theme::PAD_H ) * 2.0 ).min( max_width )
}
};
( w, self.resolved_height( canvas ) ) ( w, self.resolved_height( canvas ) )
} }
ButtonContent::Icon { .. } => ButtonContent::Icon { .. } =>
@@ -510,6 +534,7 @@ impl<Msg: Clone> Button<Msg>
icon_size: self.icon_size, icon_size: self.icon_size,
font_size: self.font_size, font_size: self.font_size,
height: self.height, height: self.height,
width: self.width,
id: self.id, id: self.id,
focusable: self.focusable, focusable: self.focusable,
cursor: self.cursor, cursor: self.cursor,

View File

@@ -150,3 +150,25 @@ fn height_builder_scales_button_box_with_surface()
let canvas = Canvas::new( 400, 800 ); let canvas = Canvas::new( 400, 800 );
assert_eq!( b.preferred_size( 1000.0, &canvas ).1, 40.0 ); 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 );
}

View File

@@ -2,7 +2,7 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc; use std::sync::Arc;
use crate::types::{ Point, Rect }; use crate::types::{ Length, Point, Rect };
use super::{ slider, text }; use super::{ slider, text };
/// Per-leaf interaction snapshot captured during layout. One variant per /// Per-leaf interaction snapshot captured during layout. One variant per
@@ -56,9 +56,9 @@ pub enum WidgetHandlers<Msg: Clone>
align: text::TextAlign, align: text::TextAlign,
/// Font size snapshot — needed by the hit-testing path so /// Font size snapshot — needed by the hit-testing path so
/// the runtime measures glyphs at the same size the renderer /// the runtime measures glyphs at the same size the renderer
/// drew them. Always the default `theme::FONT_SIZE` for /// drew them. `None` for fields that do not call
/// fields that do not call `.font_size( … )`. /// `.font_size( … )` (they follow the widget-scaling mode).
font_size: f32, font_size: Option<Length>,
/// `true` when the source field opted into select-all-on- /// `true` when the source field opted into select-all-on-
/// focus. The runtime reads this in `set_focus` to decide /// focus. The runtime reads this in `set_focus` to decide
/// whether the new selection should anchor at `0` (replace /// whether the new selection should anchor at `0` (replace

View File

@@ -51,6 +51,10 @@ pub struct Text
/// the active theme's font registry on every draw. `None` keeps /// the active theme's font registry on every draw. `None` keeps
/// the canvas default font (Sora Regular in `ltk-theme-default`). /// the canvas default font (Sora Regular in `ltk-theme-default`).
pub( crate ) font: Option<( String, u16, FontStyle )>, 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 impl Text
@@ -68,6 +72,7 @@ impl Text
wrap: false, wrap: false,
truncate: true, truncate: true,
font: None, font: None,
line_height: 1.0,
} }
} }
@@ -146,6 +151,15 @@ impl Text
self 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<Arc<Font>> fn resolve_font( &self, canvas: &Canvas ) -> Option<Arc<Font>>
{ {
self.font.as_ref().map( |( family, weight, style )| self.font.as_ref().map( |( family, weight, style )|
@@ -193,7 +207,7 @@ impl Text
// visibly overlapping when stacked tight in a column. // visibly overlapping when stacked tight in a column.
let line_h = canvas.font_line_metrics( size ) let line_h = canvas.font_line_metrics( size )
.map( |m| m.new_line_size ) .map( |m| m.new_line_size )
.unwrap_or( size ); .unwrap_or( size ) * self.line_height;
let font = self.resolve_font( canvas ); let font = self.resolve_font( canvas );
if self.wrap if self.wrap
@@ -217,7 +231,7 @@ impl Text
.unwrap_or( size * 0.8 ); .unwrap_or( size * 0.8 );
let line_h = canvas.font_line_metrics( size ) let line_h = canvas.font_line_metrics( size )
.map( |m| m.new_line_size ) .map( |m| m.new_line_size )
.unwrap_or( size ); .unwrap_or( size ) * self.line_height;
let font = self.resolve_font( canvas ); let font = self.resolve_font( canvas );
if self.wrap if self.wrap

View File

@@ -93,6 +93,30 @@ fn empty_content_still_reports_a_line_height()
assert!( h > 0.0 ); 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 ] #[ test ]
fn text_align_enum_implements_partial_eq() fn text_align_enum_implements_partial_eq()
{ {

View File

@@ -2,7 +2,7 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas; use crate::render::Canvas;
use crate::types::{ Point, Rect }; use crate::types::{ Length, Point, Rect };
use super::theme; use super::theme;
use super::wrapping::compute_visual_lines; use super::wrapping::compute_visual_lines;
@@ -26,7 +26,7 @@ pub( crate ) fn byte_offset_at(
secure: bool, secure: bool,
cursor_pos: usize, cursor_pos: usize,
align: crate::widget::text::TextAlign, align: crate::widget::text::TextAlign,
font_size: f32, font_size: Option<Length>,
) -> usize ) -> usize
{ {
if value.is_empty() { return 0; } if value.is_empty() { return 0; }

View File

@@ -26,21 +26,16 @@ pub use draw::password_toggle_hit_zone;
pub( crate ) use hit_test::byte_offset_at; pub( crate ) use hit_test::byte_offset_at;
pub( crate ) use cursor::{ cursor_visual_down, cursor_visual_end, cursor_visual_home, cursor_visual_up }; 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 /// Resolve the font size into a concrete logical px. `Some( len )` resolves
/// widget-scaling mode. A positive value is an explicit fixed size; `0.0` /// against [`Canvas::viewport_logical`] — the font space, so `× dpi_scale` at
/// follows the mode at the theme default; a negative value follows the mode /// raster yields the right physical size — exactly like the button label;
/// at the design px `-value` (see [`TextEdit::font_size_fluid`]). Shared by /// `None` follows the widget-scaling mode at the theme default. Shared by the
/// the draw and hit-test paths, which all carry a canvas. /// draw and hit-test paths, which all carry a canvas.
pub( crate ) fn resolve_font_size( canvas: &Canvas, fs: f32 ) -> f32 pub( crate ) fn resolve_font_size( canvas: &Canvas, fs: Option<Length> ) -> f32
{ {
if fs > 0.0
{
fs fs
} else if fs == 0.0 { .map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) )
canvas.font_px( theme::FONT_SIZE ) .unwrap_or_else( || canvas.font_px( theme::FONT_SIZE ) )
} else {
canvas.font_px( -fs )
}
} }
/// A text input field. /// A text input field.
@@ -150,13 +145,14 @@ pub struct TextEdit<Msg: Clone>
/// numeric inputs). /// numeric inputs).
pub( crate ) fixed_width: Option<f32>, pub( crate ) fixed_width: Option<f32>,
/// Font size in pixels for the single-line draw path. `0.0` (the /// Font size in pixels for the single-line draw path. `0.0` (the
/// default) follows the process [`crate::WidgetScaling`] mode (fluid by /// Label font size. `None` follows the process [`crate::WidgetScaling`]
/// default, via [`crate::Canvas::font_px`]); any positive value pins an /// mode at the theme default (via [`crate::Canvas::font_px`]); a
/// explicit size. The sentinel avoids threading a canvas through the /// [`Length`] pins it and is resolved in font space
/// handler snapshot; every consumer that reads it has a canvas and /// ([`crate::Canvas::viewport_logical`]), exactly like the button label.
/// resolves it via [`Self::effective_font_size`]. Multiline mode ignores /// Resolved via [`Self::effective_font_size`] by every consumer, all of
/// this and always uses the theme constant. /// which carry a canvas. Multiline mode ignores this and always uses the
pub( crate ) font_size: f32, /// theme constant.
pub( crate ) font_size: Option<Length>,
/// Optional single-line field height. `None` uses the theme default /// Optional single-line field height. `None` uses the theme default
/// (`theme::HEIGHT`); a [`Length`] scales the box with the surface, so /// (`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 /// a field can match a fluid button or grow with a fluid form. Resolved
@@ -206,7 +202,7 @@ impl<Msg: Clone> TextEdit<Msg>
align: super::text::TextAlign::Left, align: super::text::TextAlign::Left,
borderless: false, borderless: false,
fixed_width: None, fixed_width: None,
font_size: 0.0, font_size: None,
height: None, height: None,
select_on_focus: false, select_on_focus: false,
password_toggle: None, password_toggle: None,
@@ -240,24 +236,24 @@ impl<Msg: Clone> TextEdit<Msg>
} }
} }
/// Pin the single-line font size to an explicit fixed px. Without any /// Set the label font size. Accepts logical `f32` pixels or any
/// font-size call the size follows the process [`crate::WidgetScaling`] /// [`Length`] (e.g. `Length::vmin( 4.5 ).clamp( 16.0, 32.0 )` to scale
/// mode at the theme default; see [`Self::font_size_fluid`] to follow /// with the surface), resolved in font space exactly like the button
/// the mode at a custom design px. Ignored in multiline mode. /// label. Without any font-size call the size follows the process
pub fn font_size( mut self, px: f32 ) -> Self /// [`crate::WidgetScaling`] mode at the theme default. Ignored in
/// multiline mode.
pub fn font_size( mut self, size: impl Into<Length> ) -> Self
{ {
self.font_size = px.max( 1.0 ); self.font_size = Some( size.into() );
self self
} }
/// Set a font size that follows the process [`crate::WidgetScaling`] /// Set a font size that follows the process [`crate::WidgetScaling`]
/// mode at the given design px — the fluid counterpart of /// mode at the given design px — the fluid counterpart of
/// [`Self::font_size`]. Encoded as a negative sentinel so it flows /// [`Self::font_size`]. Shorthand for `.font_size( Length::fluid( n ) )`.
/// 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 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 self
} }

View File

@@ -444,7 +444,7 @@ fn defaults_for_new_inline_builders()
assert_eq!( t.align, super::super::text::TextAlign::Left ); assert_eq!( t.align, super::super::text::TextAlign::Left );
assert!( !t.borderless ); assert!( !t.borderless );
assert!( t.fixed_width.is_none() ); 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 ); assert!( !t.select_on_focus );
} }
@@ -473,25 +473,26 @@ fn fixed_width_builder_stores_value()
} }
#[ test ] #[ 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 ); let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size( 28.0 );
assert_eq!( t.font_size, 28.0 ); assert_eq!( t.font_size, Some( crate::types::Length::px( 28.0 ) ) );
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size( -3.0 ); let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size( crate::types::Length::vmin( 4.5 ) );
assert_eq!( t.font_size, 1.0 ); assert_eq!( t.font_size, Some( crate::types::Length::vmin( 4.5 ) ) );
} }
#[ test ] #[ 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; use crate::render::Canvas;
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() ); 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 ); let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size_fluid( 28.0 );
assert_eq!( t.font_size, -28.0 ); assert_eq!( t.font_size, Some( crate::types::Length::fluid( 28.0 ) ) );
// Negative sentinel resolves to the widget-scaling font of design px 28. // The fluid design px resolves the same as the mode's font_px( 28 ).
let canvas = Canvas::new( 412, 900 ); let canvas = Canvas::new( 412, 900 );
assert_eq!( t.effective_font_size( &canvas ), canvas.font_px( 28.0 ) ); 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() ); let d: TextEdit<()> = TextEdit::new( "".into(), "".into() );
assert_eq!( d.effective_font_size( &canvas ), canvas.font_px( super::theme::FONT_SIZE ) ); 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(); .into_element();
match widget.handlers() 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" ), _ => panic!( "expected TextEdit handler" ),
} }
} }