text_edit font sizing on Length; add Button::width and Text::line_height
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:
16
CHANGELOG.md
16
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).
|
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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 )?;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 );
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" ),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user