Files
ltk/tests/layout_stack_spacer.rs
Pedro M. de Echanove Pasquin 24f4d2703a
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
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
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.
2026-05-24 00:12:50 +02:00

281 lines
9.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Integration coverage for `Stack` alignment / margin / translation and for
// the way `Spacer` distributes leftover space inside a `Column`.
use ltk::core::Canvas;
use ltk::{ stack, spacer, HAlign, Length, VAlign, Rect };
fn make_canvas() -> Canvas
{
Canvas::new( 800, 600 )
}
fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect
{
Rect { x, y, width: w, height: h }
}
// ── Stack: alignment ──────────────────────────────────────────────────────────
#[ test ]
fn stack_fill_child_takes_full_rect()
{
let canvas = make_canvas();
let s = stack::<()>().push( spacer().width( 100.0 ).height( 100.0 ) );
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
assert_eq!( layout.len(), 1 );
let ( child_rect, _ ) = layout[ 0 ];
// Fill ignores the spacer's preferred size and stretches across the
// entire stack rect.
assert_eq!( child_rect, rect( 0.0, 0.0, 400.0, 300.0 ) );
}
#[ test ]
fn stack_center_centers_child_inside_rect()
{
let canvas = make_canvas();
let child = spacer().width( 100.0 ).height( 50.0 );
let s = stack::<()>().push_aligned( child, HAlign::Center, VAlign::Center );
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
let ( child_rect, _ ) = layout[ 0 ];
assert_eq!( child_rect.x, 150.0 );
assert_eq!( child_rect.y, 125.0 );
assert_eq!( child_rect.width, 100.0 );
assert_eq!( child_rect.height, 50.0 );
}
#[ test ]
fn stack_end_bottom_anchors_to_bottom_right()
{
let canvas = make_canvas();
let child = spacer().width( 80.0 ).height( 40.0 );
let s = stack::<()>().push_aligned( child, HAlign::End, VAlign::Bottom );
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
let ( child_rect, _ ) = layout[ 0 ];
assert_eq!( child_rect.x, 320.0 );
assert_eq!( child_rect.y, 260.0 );
}
#[ test ]
fn stack_start_top_anchors_to_top_left()
{
let canvas = make_canvas();
let child = spacer().width( 40.0 ).height( 30.0 );
let s = stack::<()>().push_aligned( child, HAlign::Start, VAlign::Top );
let layout = s.layout( rect( 100.0, 50.0, 400.0, 300.0 ), &canvas );
let ( child_rect, _ ) = layout[ 0 ];
assert_eq!( child_rect.x, 100.0 );
assert_eq!( child_rect.y, 50.0 );
}
// ── Stack: margin ─────────────────────────────────────────────────────────────
#[ test ]
fn stack_margin_insets_fill_child_on_all_sides()
{
let canvas = make_canvas();
let child = spacer().width( 50.0 ).height( 50.0 );
let s = stack::<()>()
.push_aligned_margin( child, HAlign::Fill, VAlign::Fill, 12.0 );
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
let ( child_rect, _ ) = layout[ 0 ];
// Fill + margin = original rect inset on every side.
assert_eq!( child_rect, rect( 12.0, 12.0, 376.0, 276.0 ) );
}
#[ test ]
fn stack_margin_pushes_end_anchored_child_inward()
{
let canvas = make_canvas();
let child = spacer().width( 50.0 ).height( 50.0 );
let s = stack::<()>()
.push_aligned_margin( child, HAlign::End, VAlign::Top, 8.0 );
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
let ( child_rect, _ ) = layout[ 0 ];
// 400 - 50 - 8 = 342 from left edge; y starts at 8.
assert_eq!( child_rect.x, 342.0 );
assert_eq!( child_rect.y, 8.0 );
}
#[ test ]
fn stack_margin_larger_than_half_rect_clamps_inner_to_zero()
{
let canvas = make_canvas();
let child = spacer().width( 10.0 ).height( 10.0 );
let s = stack::<()>()
.push_aligned_margin( child, HAlign::Fill, VAlign::Fill, 200.0 );
// Stack rect is 100×100; margin 200 → inner is clamped to (0, 0).
let layout = s.layout( rect( 0.0, 0.0, 100.0, 100.0 ), &canvas );
let ( child_rect, _ ) = layout[ 0 ];
assert_eq!( child_rect.width, 0.0 );
assert_eq!( child_rect.height, 0.0 );
}
// ── Stack: translation ────────────────────────────────────────────────────────
#[ test ]
fn stack_translation_offsets_child_after_alignment()
{
let canvas = make_canvas();
let child = spacer().width( 40.0 ).height( 30.0 );
let s = stack::<()>()
.push_translated( child, HAlign::Start, VAlign::Top, 5.0, 7.0 );
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
let ( child_rect, _ ) = layout[ 0 ];
assert_eq!( child_rect.x, 5.0 );
assert_eq!( child_rect.y, 7.0 );
}
// ── Stack: order ─────────────────────────────────────────────────────────────
#[ test ]
fn stack_layout_preserves_child_order()
{
let canvas = make_canvas();
let s = stack::<()>()
.push( spacer().width( 10.0 ).height( 10.0 ) )
.push( spacer().width( 20.0 ).height( 20.0 ) )
.push( spacer().width( 30.0 ).height( 30.0 ) );
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
assert_eq!( layout.len(), 3 );
assert_eq!( layout[ 0 ].1, 0 );
assert_eq!( layout[ 1 ].1, 1 );
assert_eq!( layout[ 2 ].1, 2 );
}
// ── Stack: preferred_size ─────────────────────────────────────────────────────
#[ test ]
fn stack_preferred_size_returns_max_child_height()
{
let canvas = make_canvas();
let s = stack::<()>()
.push( spacer().width( 40.0 ).height( 30.0 ) )
.push( spacer().width( 60.0 ).height( 80.0 ) );
let ( w, h ) = s.preferred_size( 400.0, &canvas );
// Stack always reports `max_width` for width, and the tallest child for
// height — Stack is meant for overlaying, not flow.
assert_eq!( w, 400.0 );
assert_eq!( h, 80.0 );
}
#[ test ]
fn stack_empty_preferred_size_height_is_zero()
{
let canvas = make_canvas();
let s: ltk::Stack<()> = stack();
let ( _, h ) = s.preferred_size( 400.0, &canvas );
assert_eq!( h, 0.0 );
}
// ── Spacer: builder semantics ─────────────────────────────────────────────────
#[ test ]
fn spacer_default_has_weight_one_and_no_fixed_size()
{
let canvas = make_canvas();
let s = spacer();
assert_eq!( s.weight, 1 );
assert!( s.fixed_height.is_none() );
assert!( s.fixed_width.is_none() );
assert_eq!( s.preferred_size( &canvas ), ( 0.0, 0.0 ) );
}
#[ test ]
fn spacer_height_pins_vertical_axis_only()
{
let canvas = make_canvas();
let s = spacer().height( 24.0 );
assert_eq!( s.fixed_height, Some( Length::px( 24.0 ) ) );
assert!( s.fixed_width.is_none() );
assert_eq!( s.preferred_size( &canvas ), ( 0.0, 24.0 ) );
}
#[ test ]
fn spacer_width_pins_horizontal_axis_only()
{
let canvas = make_canvas();
let s = spacer().width( 12.0 );
assert_eq!( s.fixed_width, Some( Length::px( 12.0 ) ) );
assert!( s.fixed_height.is_none() );
assert_eq!( s.preferred_size( &canvas ), ( 12.0, 0.0 ) );
}
#[ test ]
fn spacer_weight_replaces_default()
{
let s = spacer().weight( 5 );
assert_eq!( s.weight, 5 );
}
// ── Spacer in Column: leftover distribution ───────────────────────────────────
// These use `column().layout(...)` directly (Column re-exports its `layout()`
// method as `pub`). Fixed-height children consume known space; flexible spacers
// must absorb the rest.
#[ test ]
fn flexible_spacer_in_column_consumes_leftover_height()
{
use ltk::column;
let canvas = make_canvas();
let col = column::<()>()
.padding( 0.0 )
.spacing( 0.0 )
.push( spacer().width( 50.0 ).height( 100.0 ) )
.push( spacer() ) // flexible
.push( spacer().width( 50.0 ).height( 100.0 ) );
let layout = col.layout( rect( 0.0, 0.0, 200.0, 500.0 ), &canvas );
assert_eq!( layout.len(), 3 );
// Two fixed children of 100 each + one spacer absorbing the leftover 300.
assert!( ( layout[ 0 ].0.height - 100.0 ).abs() < 1e-3 );
assert!( ( layout[ 1 ].0.height - 300.0 ).abs() < 1e-3 );
assert!( ( layout[ 2 ].0.height - 100.0 ).abs() < 1e-3 );
}
#[ test ]
fn weighted_spacers_split_leftover_proportionally()
{
use ltk::column;
let canvas = make_canvas();
let col = column::<()>()
.padding( 0.0 )
.spacing( 0.0 )
.push( spacer().weight( 1 ) )
.push( spacer().weight( 3 ) );
let layout = col.layout( rect( 0.0, 0.0, 200.0, 400.0 ), &canvas );
// Total weight 4; leftover 400; weight 1 → 100, weight 3 → 300.
assert!( ( layout[ 0 ].0.height - 100.0 ).abs() < 1e-3 );
assert!( ( layout[ 1 ].0.height - 300.0 ).abs() < 1e-3 );
}
#[ test ]
fn fixed_height_spacer_does_not_consume_leftover()
{
use ltk::column;
let canvas = make_canvas();
let col = column::<()>()
.padding( 0.0 )
.spacing( 0.0 )
.push( spacer().height( 50.0 ) ) // fixed — keeps its 50 px
.push( spacer() ); // flexible — absorbs the rest
let layout = col.layout( rect( 0.0, 0.0, 200.0, 500.0 ), &canvas );
assert!( ( layout[ 0 ].0.height - 50.0 ).abs() < 1e-3 );
assert!( ( layout[ 1 ].0.height - 450.0 ).abs() < 1e-3 );
}