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.
281 lines
9.0 KiB
Rust
281 lines
9.0 KiB
Rust
// 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 );
|
||
}
|