ltk: introduce viewport-relative Length so any size, padding, spacing or font height can scale with the surface instead of being frozen at a px constant, fix text::preferred_size to honour the font-declared line gap, and add a responsive typographic scale
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

The motivating bug was a lockscreen in a downstream app (eydos-loginmanager) where the clock at 87 px overlapped the date at 24 px on a Pinephone but not on a winit dev screen. The root cause split in two: the layout was wired with a single `f32` spacing constant that worked at the dev resolution and broke at the smaller one, and `text::Text::preferred_size` was returning `ascent - descent` for the line height — fontdue's terminology for "the minimum bounding box of an unaccented line", which deliberately drops the `line_gap` that every typographic renderer (Pango, CoreText, DirectWrite) reserves between adjacent rows. At Sora's 200/em line gap, an 87 px row was visually 17 px taller than the rect the column allocated for it; stacked tight against the row above, the descenders bled into the row below. This commit fixes both halves at the toolkit level so every consumer benefits without bolting on a per-screen `Sizing` helper in their own view code.
`types::Length` (with the `LengthBase` enum behind it) is the new currency for any "how big" or "how far apart" parameter. Six variants — `Px`, `Vw`, `Vh`, `Vmin`, `Vmax`, `Em` — cover the cases a real UI hits: absolute pixels for fixed-chrome decisions, viewport-relative percentages for sizes that have to survive a portrait/landscape rotation, and root-font-size multiples for typographic hierarchy. Optional `min_px` / `max_px` bounds attach to the same `Length` value via `.clamp( lo, hi )` (both ends), `.at_least( lo )` and `.at_most( hi )` (one-sided); the names are intentionally divergent from `f32::min`/`f32::max` to avoid being read with the opposite semantics (`x.min(24)` in std means "the smaller of x and 24", which is the inverse of what a min bound expresses). The bounds are stored as raw `f32` rather than nested `Length` values, which keeps `Length` `Copy` and avoids a `Box` allocation per widget per frame — the bounded-by-relative case (`Vmin(20).clamp(Vmin(10), Vmin(40))`) is rare enough that the trade is the right one. `From<f32>`, `From<i32>` and `From<u32>` are implemented so every legacy `.size( 24.0 )` / `.padding( 8.0 )` / `.spacing( 4.0 )` call keeps compiling unchanged; the migration is opt-in per call site. The `EM_BASE_DEFAULT = 16.0` constant matches `theme::typography::BODY` so `Length::em( 2.0 )` resolves consistently with the body-text default; a future change can thread a theme-supplied em base through without breaking the resolver shape.
The resolver — `Length::resolve( viewport: ( f32, f32 ), em_base: f32 ) -> f32` — runs at layout time against a viewport supplied by the renderer. `Canvas::viewport_logical()` is the new helper that exposes that viewport: it divides the canvas's physical size by `dpi_scale` and falls back to physical size when `dpi_scale <= 0.0`, guarding the misconfigured-canvas path so a Vmin call doesn't poison every downstream measurement with `NaN` or `inf`. The viewport is in **logical** pixels — matching what every wayland `xdg_toplevel.configure` event already hands the client — so `Length::vmin( 18.0 )` on a 360×720-logical Librem 5 portrait surface resolves to 64.8 px and the same expression on a 1600×900 dev screen resolves to 162 px, automatically.
Every widget setter that took an `f32` size, padding, spacing, max-width, or fixed dimension now takes `impl Into<Length>` and stores the value as `Length`:
- `widget::text::Text::size( impl Into<Length> )`; the `size` field is now `Length`. `Text::resolved_size( &Canvas )` is the internal accessor that every measurement / drawing path routes through, so the field can stay `Length` without churning the call sites. `preferred_size` and `draw` now read `new_line_size = ascent - descent + line_gap` from fontdue's `LineMetrics` (the fix for the original bug) — the baseline placement is unchanged, only the row height grows by the font's declared leading, which is what every stacked layout was implicitly relying on.
- `layout::Spacer::height( impl Into<Length> )` / `.width( impl Into<Length> )`; `fixed_height` / `fixed_width` are now `Option<Length>`. New `resolved_height( &Canvas )` / `resolved_width( &Canvas )` helpers replace the direct `s.fixed_height.unwrap_or( 0.0 )` reads in `layout::column`, `layout::row` and `layout::stack`. `Spacer::preferred_size` grows a `&Canvas` parameter for the same reason; `Element::preferred_size` passes the canvas through.
- `layout::Column::spacing` / `.padding` / `.max_width`, `layout::Row::spacing` / `.padding` — all take `impl Into<Length>` and store `Length`. Internal `resolved_spacing( &Canvas )`, `resolved_padding( &Canvas )`, `resolved_max_width( &Canvas )` helpers funnel every read, so the layout code paths stay readable. The column's `inner_w` private helper picks up a `&Canvas` argument; the test that used it directly is updated.
`theme::typography` keeps its historic `f32` constants (`H0`…`BODY_XS`, plus `LINE_HEIGHT`) so the migration is gradual, and adds a parallel responsive scale exposed as functions returning `Length`: `h0()`, `h1()`, `h2()`, `h3()`, `body()`, `body_s()`, `body_xs()`. Each is a `Length::vmin( pct ).clamp( min_px, max_px )` whose percentage is calibrated against a 1000-px smaller side reproducing the legacy px constant exactly, and whose px clamps protect both ends of the spectrum — a 360-px Pinephone hits the lower clamp on the larger headings, a 4K desktop hits the upper one. The tests in `theme::typography` exercise all three regimes (narrow phone, calibration point, large display) so future drift in the percentages or clamps is caught immediately.
`Canvas::viewport_logical` is the only render-surface API touched. None of the existing per-frame paths (`draw_text`, `measure_text`, `font_line_metrics`) change shape, so backends and external embedders aren't disturbed. The `dpi_scale` accessor already existed; this commit only adds the convenience that ratios it against the surface size to return the unit layout actually wants.
Test coverage rounds out the addition rather than just smoke-testing the happy path: 22 new tests, broken down as `types::length_tests` (7 — every variant, clamp with relative value, clamp with swapped bounds, `From<f32>`), `render::viewport_tests` (3 — scale 1, scale 2, scale 0 fallback), `theme::typography::tests` (3 — phone-clamped, calibrated, 4K-clamped), `layout::spacer::tests` (4 — px height, vmin height, vw width, flex spacer reports `None`), `layout::column::tests` (3 new — vmin spacing accumulates, vmin padding, vmin max-width caps inner-w), `layout::row::tests` (2 new — vmin padding, vmin spacing produces correct visible gap between non-flex children regardless of the row's centering anchor), and `widget::text::tests` (3 updated/new — defaults compare against `Length::px(16.0)`, `.size( f32 )` and `.size( Length )` both verified). The existing integration test in `tests/layout_stack_spacer.rs` is updated to call `Spacer::preferred_size( &canvas )` and compare `fixed_height` / `fixed_width` against `Some( Length::px( n ) )`.
Documentation is updated end-to-end so the new API is discoverable from `cargo doc` without grepping the source: `lib.rs` gets a new entry for `Length` under the **Types** section and a new **Designing for multiple resolutions** section that lists the three patterns (relative `Length` for sizing, responsive typography for hierarchy, `view()`-level branching on surface dimensions only when the structure itself must change). `Canvas::viewport_logical` ships with a runnable `assert_eq!` example covering the scale-2 case. The module-level docstrings for `Spacer`, `Column` and `Row` now show both an `f32` example (legacy, still valid) and a `Length::vmin( ... ).clamp( ... )` example for the responsive variant — `cargo doc` renders both side by side so the upgrade path is obvious.
Out of scope for this commit, deliberate: `WrapGrid::spacing_x` / `spacing_y` / `padding`, `widget::text_edit::TextEdit::font_size`, and `widget::image::Image::size` still take `f32`. None of them are on a critical responsive path right now, the `From<f32>` shim means migrating later is a one-line setter signature change per widget, and keeping this commit focused on the widgets the lockscreen actually uses keeps the diff reviewable. The line-gap fix in `text::preferred_size` already benefits `TextEdit` indirectly because its caret/row math reads from the same metrics helpers.
This commit is contained in:
2026-05-24 00:12:50 +02:00
parent c553c4df4b
commit 24f4d2703a
12 changed files with 764 additions and 119 deletions

View File

@@ -1,7 +1,7 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::Rect;
use crate::types::{ Length, Rect };
use crate::render::Canvas;
use crate::widget::Element;
@@ -23,14 +23,36 @@ use crate::widget::Element;
/// .into()
/// # }
/// ```
///
/// `padding`, `spacing` and `max_width` all accept any
/// [`crate::Length`], so a responsive layout reads as:
///
/// ```rust,no_run
/// # use ltk::{ button, column, text, Length, Element };
/// # #[ derive( Clone ) ] enum Msg { Ok }
/// # fn _ex() -> Element<Msg> {
/// column()
/// // Padding is 3 % of the viewport's smaller side, clamped to 16..48 px.
/// .padding( Length::vmin( 3.0 ).clamp( 16.0, 48.0 ) )
/// .spacing( Length::vmin( 1.5 ).at_least( 8.0 ) )
/// .max_width( Length::vw( 60.0 ).at_most( 720.0 ) )
/// .push( text( "Responsive" ) )
/// .push( button( "OK" ).on_press( Msg::Ok ) )
/// .into()
/// # }
/// ```
pub struct Column<Msg: Clone>
{
pub children: Vec<Element<Msg>>,
pub spacing: f32,
pub padding: f32,
/// Vertical gap between children. Stored as [`Length`] so a `Vmin(2.0)`
/// or `Em(0.5)` gap scales with the viewport instead of freezing at a
/// px constant.
pub spacing: Length,
/// Padding on all sides. Same [`Length`] semantics as `spacing`.
pub padding: Length,
pub align_center_x: bool,
pub center_y: bool,
pub max_width: Option<f32>,
pub max_width: Option<Length>,
pub fit_content: bool,
}
@@ -41,8 +63,8 @@ impl<Msg: Clone> Column<Msg>
Self
{
children: Vec::new(),
spacing: 8.0,
padding: 16.0,
spacing: Length::px( 8.0 ),
padding: Length::px( 16.0 ),
align_center_x: true,
center_y: false,
max_width: None,
@@ -57,17 +79,20 @@ impl<Msg: Clone> Column<Msg>
self
}
/// Set the vertical gap between children in pixels. Default: `8.0`.
pub fn spacing( mut self, s: f32 ) -> Self
/// Set the vertical gap between children. Default: `8.0` px. Accepts
/// any [`Length`] — pass an `f32` for the px case, or a relative
/// value like `Length::vmin( 2.0 )` to scale with the viewport.
pub fn spacing( mut self, s: impl Into<Length> ) -> Self
{
self.spacing = s;
self.spacing = s.into();
self
}
/// Set the padding (all sides) in pixels. Default: `16.0`.
pub fn padding( mut self, p: f32 ) -> Self
/// Set the padding (all sides). Default: `16.0` px. Accepts any
/// [`Length`].
pub fn padding( mut self, p: impl Into<Length> ) -> Self
{
self.padding = p;
self.padding = p.into();
self
}
@@ -85,14 +110,33 @@ impl<Msg: Clone> Column<Msg>
self
}
/// Limit the content width in pixels. The column still reports `max_width` as
/// its preferred width so the parent allocates the full available rect.
pub fn max_width( mut self, w: f32 ) -> Self
/// Limit the content width. Accepts any [`Length`]. The column still
/// reports the parent's `max_width` as its preferred width so the
/// parent allocates the full available rect.
pub fn max_width( mut self, w: impl Into<Length> ) -> Self
{
self.max_width = Some( w );
self.max_width = Some( w.into() );
self
}
#[ inline ]
fn resolved_spacing( &self, canvas: &Canvas ) -> f32
{
self.spacing.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT )
}
#[ inline ]
fn resolved_padding( &self, canvas: &Canvas ) -> f32
{
self.padding.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT )
}
#[ inline ]
fn resolved_max_width( &self, canvas: &Canvas ) -> Option<f32>
{
self.max_width.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) )
}
/// Report the intrinsic content width as preferred width instead of filling
/// the available `max_width`. Use this when the column represents a card
/// or widget meant to sit side-by-side with other children inside a
@@ -108,10 +152,10 @@ impl<Msg: Clone> Column<Msg>
self
}
fn inner_w( &self, available: f32 ) -> f32
fn inner_w( &self, available: f32, canvas: &Canvas ) -> f32
{
let w = available - self.padding * 2.0;
self.max_width.map( |m| w.min( m ) ).unwrap_or( w )
let w = available - self.resolved_padding( canvas ) * 2.0;
self.resolved_max_width( canvas ).map( |m| w.min( m ) ).unwrap_or( w )
}
fn content_h( &self, inner_w: f32, canvas: &Canvas ) -> f32
@@ -120,18 +164,19 @@ impl<Msg: Clone> Column<Msg>
self.children.iter()
.map( |c| match c
{
Element::Spacer( s ) => s.fixed_height.unwrap_or( 0.0 ),
Element::Spacer( s ) => s.resolved_height( canvas ).unwrap_or( 0.0 ),
other => other.preferred_size( inner_w, canvas ).1,
} )
.sum::<f32>()
+ self.spacing * (self.children.len().saturating_sub( 1 )) as f32
+ self.resolved_spacing( canvas ) * ( self.children.len().saturating_sub( 1 ) ) as f32
}
/// Return the preferred `(width, height)` given available `max_width`.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
let inner_w = self.inner_w( max_width );
let total_h = self.content_h( inner_w, canvas ) + self.padding * 2.0;
let inner_w = self.inner_w( max_width, canvas );
let pad = self.resolved_padding( canvas );
let total_h = self.content_h( inner_w, canvas ) + pad * 2.0;
let w = if self.fit_content
{
@@ -162,7 +207,7 @@ impl<Msg: Clone> Column<Msg>
other => other.preferred_size( inner_w, canvas ).0,
} )
.fold( 0.0_f32, f32::max );
( content_w + self.padding * 2.0 ).min( max_width )
( content_w + pad * 2.0 ).min( max_width )
} else {
max_width
};
@@ -175,14 +220,16 @@ impl<Msg: Clone> Column<Msg>
/// Layout children within rect and return (rect, child_index) pairs.
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
{
let inner_w = self.inner_w( rect.width );
let inner_w = self.inner_w( rect.width, canvas );
let pad = self.resolved_padding( canvas );
let spacing = self.resolved_spacing( canvas );
// Flexible spacers and Scroll widgets claim remaining vertical space.
// Fixed-height spacers behave like normal fixed-size children.
let total_weight: u32 = self.children.iter()
.map( |c| match c
{
Element::Spacer( s ) if s.fixed_height.is_none() => s.weight,
Element::Spacer( s ) if s.resolved_height( canvas ).is_none() => s.weight,
Element::Scroll( _ ) => 1,
_ => 0,
} )
@@ -195,23 +242,23 @@ impl<Msg: Clone> Column<Msg>
{
0.0
} else if let Element::Spacer( s ) = c {
s.fixed_height.unwrap_or( 0.0 )
s.resolved_height( canvas ).unwrap_or( 0.0 )
} else {
c.preferred_size( inner_w, canvas ).1
}
} )
.sum::<f32>()
+ self.spacing * (self.children.len().saturating_sub( 1 )) as f32;
+ spacing * ( self.children.len().saturating_sub( 1 ) ) as f32;
let avail_h = rect.height - self.padding * 2.0;
let avail_spare = (avail_h - fixed_h).max( 0.0 );
let avail_h = rect.height - pad * 2.0;
let avail_spare = ( avail_h - fixed_h ).max( 0.0 );
// `center_y` only applies when there are no spacers.
let start_y = if total_weight == 0 && self.center_y
{
rect.y + self.padding + avail_spare / 2.0
rect.y + pad + avail_spare / 2.0
} else {
rect.y + self.padding
rect.y + pad
};
let start_x = rect.x + (rect.width - inner_w) / 2.0;
@@ -224,7 +271,7 @@ impl<Msg: Clone> Column<Msg>
{
Element::Spacer( s ) =>
{
let h = if let Some( fixed ) = s.fixed_height
let h = if let Some( fixed ) = s.resolved_height( canvas )
{
fixed
} else if total_weight > 0
@@ -254,7 +301,7 @@ impl<Msg: Clone> Column<Msg>
start_x
};
result.push( ( Rect { x, y, width: w, height: h }, i ) );
y += h + self.spacing;
y += h + spacing;
}
result
}
@@ -330,16 +377,18 @@ mod tests
#[ test ]
fn inner_w_respects_padding_and_max_width()
{
let canvas = make_canvas();
let col = column::<()>().padding( 20.0 ).max_width( 100.0 );
// available = 200, minus padding*2 = 160, capped at max_width = 100
assert_eq!( col.inner_w( 200.0 ), 100.0 );
assert_eq!( col.inner_w( 200.0, &canvas ), 100.0 );
}
#[ test ]
fn inner_w_without_max_width_subtracts_padding()
{
let canvas = make_canvas();
let col = column::<()>().padding( 10.0 );
assert_eq!( col.inner_w( 200.0 ), 180.0 );
assert_eq!( col.inner_w( 200.0, &canvas ), 180.0 );
}
#[ test ]
@@ -356,4 +405,39 @@ mod tests
let ( _, h ) = col.preferred_size( 100.0, &canvas );
assert_eq!( h, 16.0 );
}
#[ test ]
fn vmin_spacing_resolves_against_canvas_viewport()
{
// Canvas is 800x600 → vmin = 600. 5 % of 600 = 30 px per gap.
// Three zero-height spacers → two gaps → 60 px total.
let canvas = make_canvas();
let col = column::<()>()
.padding( 0.0 )
.spacing( Length::vmin( 5.0 ) )
.push( crate::spacer() )
.push( crate::spacer() )
.push( crate::spacer() );
let ( _, h ) = col.preferred_size( 100.0, &canvas );
assert_eq!( h, 60.0 );
}
#[ test ]
fn vmin_padding_doubles_around_content()
{
// 4 % of 600 = 24 px padding on each side → 48 px on an empty column.
let canvas = make_canvas();
let col = column::<()>().padding( Length::vmin( 4.0 ) );
let ( _, h ) = col.preferred_size( 100.0, &canvas );
assert_eq!( h, 48.0 );
}
#[ test ]
fn vmin_max_width_caps_inner_w()
{
// 20 % of 600 = 120 px max-width.
let canvas = make_canvas();
let col = column::<()>().padding( 0.0 ).max_width( Length::vmin( 20.0 ) );
assert_eq!( col.inner_w( 200.0, &canvas ), 120.0 );
}
}