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.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
// SPDX-License-Identifier: LGPL-2.1-only
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// 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::render::Canvas;
|
||||||
use crate::widget::Element;
|
use crate::widget::Element;
|
||||||
|
|
||||||
@@ -23,14 +23,36 @@ use crate::widget::Element;
|
|||||||
/// .into()
|
/// .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 struct Column<Msg: Clone>
|
||||||
{
|
{
|
||||||
pub children: Vec<Element<Msg>>,
|
pub children: Vec<Element<Msg>>,
|
||||||
pub spacing: f32,
|
/// Vertical gap between children. Stored as [`Length`] so a `Vmin(2.0)`
|
||||||
pub padding: f32,
|
/// 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 align_center_x: bool,
|
||||||
pub center_y: bool,
|
pub center_y: bool,
|
||||||
pub max_width: Option<f32>,
|
pub max_width: Option<Length>,
|
||||||
pub fit_content: bool,
|
pub fit_content: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,8 +63,8 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
Self
|
Self
|
||||||
{
|
{
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
spacing: 8.0,
|
spacing: Length::px( 8.0 ),
|
||||||
padding: 16.0,
|
padding: Length::px( 16.0 ),
|
||||||
align_center_x: true,
|
align_center_x: true,
|
||||||
center_y: false,
|
center_y: false,
|
||||||
max_width: None,
|
max_width: None,
|
||||||
@@ -57,17 +79,20 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the vertical gap between children in pixels. Default: `8.0`.
|
/// Set the vertical gap between children. Default: `8.0` px. Accepts
|
||||||
pub fn spacing( mut self, s: f32 ) -> Self
|
/// 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
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the padding (all sides) in pixels. Default: `16.0`.
|
/// Set the padding (all sides). Default: `16.0` px. Accepts any
|
||||||
pub fn padding( mut self, p: f32 ) -> Self
|
/// [`Length`].
|
||||||
|
pub fn padding( mut self, p: impl Into<Length> ) -> Self
|
||||||
{
|
{
|
||||||
self.padding = p;
|
self.padding = p.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,14 +110,33 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Limit the content width in pixels. The column still reports `max_width` as
|
/// Limit the content width. Accepts any [`Length`]. The column still
|
||||||
/// its preferred width so the parent allocates the full available rect.
|
/// reports the parent's `max_width` as its preferred width so the
|
||||||
pub fn max_width( mut self, w: f32 ) -> Self
|
/// 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
|
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
|
/// Report the intrinsic content width as preferred width instead of filling
|
||||||
/// the available `max_width`. Use this when the column represents a card
|
/// 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
|
/// or widget meant to sit side-by-side with other children inside a
|
||||||
@@ -108,10 +152,10 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inner_w( &self, available: f32 ) -> f32
|
fn inner_w( &self, available: f32, canvas: &Canvas ) -> f32
|
||||||
{
|
{
|
||||||
let w = available - self.padding * 2.0;
|
let w = available - self.resolved_padding( canvas ) * 2.0;
|
||||||
self.max_width.map( |m| w.min( m ) ).unwrap_or( w )
|
self.resolved_max_width( canvas ).map( |m| w.min( m ) ).unwrap_or( w )
|
||||||
}
|
}
|
||||||
|
|
||||||
fn content_h( &self, inner_w: f32, canvas: &Canvas ) -> f32
|
fn content_h( &self, inner_w: f32, canvas: &Canvas ) -> f32
|
||||||
@@ -120,18 +164,19 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
self.children.iter()
|
self.children.iter()
|
||||||
.map( |c| match c
|
.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,
|
other => other.preferred_size( inner_w, canvas ).1,
|
||||||
} )
|
} )
|
||||||
.sum::<f32>()
|
.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`.
|
/// Return the preferred `(width, height)` given available `max_width`.
|
||||||
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
||||||
{
|
{
|
||||||
let inner_w = self.inner_w( max_width );
|
let inner_w = self.inner_w( max_width, canvas );
|
||||||
let total_h = self.content_h( inner_w, canvas ) + self.padding * 2.0;
|
let pad = self.resolved_padding( canvas );
|
||||||
|
let total_h = self.content_h( inner_w, canvas ) + pad * 2.0;
|
||||||
|
|
||||||
let w = if self.fit_content
|
let w = if self.fit_content
|
||||||
{
|
{
|
||||||
@@ -162,7 +207,7 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
other => other.preferred_size( inner_w, canvas ).0,
|
other => other.preferred_size( inner_w, canvas ).0,
|
||||||
} )
|
} )
|
||||||
.fold( 0.0_f32, f32::max );
|
.fold( 0.0_f32, f32::max );
|
||||||
( content_w + self.padding * 2.0 ).min( max_width )
|
( content_w + pad * 2.0 ).min( max_width )
|
||||||
} else {
|
} else {
|
||||||
max_width
|
max_width
|
||||||
};
|
};
|
||||||
@@ -175,14 +220,16 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
/// Layout children within rect and return (rect, child_index) pairs.
|
/// Layout children within rect and return (rect, child_index) pairs.
|
||||||
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
|
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.
|
// Flexible spacers and Scroll widgets claim remaining vertical space.
|
||||||
// Fixed-height spacers behave like normal fixed-size children.
|
// Fixed-height spacers behave like normal fixed-size children.
|
||||||
let total_weight: u32 = self.children.iter()
|
let total_weight: u32 = self.children.iter()
|
||||||
.map( |c| match c
|
.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,
|
Element::Scroll( _ ) => 1,
|
||||||
_ => 0,
|
_ => 0,
|
||||||
} )
|
} )
|
||||||
@@ -195,23 +242,23 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
{
|
{
|
||||||
0.0
|
0.0
|
||||||
} else if let Element::Spacer( s ) = c {
|
} else if let Element::Spacer( s ) = c {
|
||||||
s.fixed_height.unwrap_or( 0.0 )
|
s.resolved_height( canvas ).unwrap_or( 0.0 )
|
||||||
} else {
|
} else {
|
||||||
c.preferred_size( inner_w, canvas ).1
|
c.preferred_size( inner_w, canvas ).1
|
||||||
}
|
}
|
||||||
} )
|
} )
|
||||||
.sum::<f32>()
|
.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_h = rect.height - pad * 2.0;
|
||||||
let avail_spare = (avail_h - fixed_h).max( 0.0 );
|
let avail_spare = ( avail_h - fixed_h ).max( 0.0 );
|
||||||
|
|
||||||
// `center_y` only applies when there are no spacers.
|
// `center_y` only applies when there are no spacers.
|
||||||
let start_y = if total_weight == 0 && self.center_y
|
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 {
|
} else {
|
||||||
rect.y + self.padding
|
rect.y + pad
|
||||||
};
|
};
|
||||||
|
|
||||||
let start_x = rect.x + (rect.width - inner_w) / 2.0;
|
let start_x = rect.x + (rect.width - inner_w) / 2.0;
|
||||||
@@ -224,7 +271,7 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
{
|
{
|
||||||
Element::Spacer( s ) =>
|
Element::Spacer( s ) =>
|
||||||
{
|
{
|
||||||
let h = if let Some( fixed ) = s.fixed_height
|
let h = if let Some( fixed ) = s.resolved_height( canvas )
|
||||||
{
|
{
|
||||||
fixed
|
fixed
|
||||||
} else if total_weight > 0
|
} else if total_weight > 0
|
||||||
@@ -254,7 +301,7 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
start_x
|
start_x
|
||||||
};
|
};
|
||||||
result.push( ( Rect { x, y, width: w, height: h }, i ) );
|
result.push( ( Rect { x, y, width: w, height: h }, i ) );
|
||||||
y += h + self.spacing;
|
y += h + spacing;
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -330,16 +377,18 @@ mod tests
|
|||||||
#[ test ]
|
#[ test ]
|
||||||
fn inner_w_respects_padding_and_max_width()
|
fn inner_w_respects_padding_and_max_width()
|
||||||
{
|
{
|
||||||
|
let canvas = make_canvas();
|
||||||
let col = column::<()>().padding( 20.0 ).max_width( 100.0 );
|
let col = column::<()>().padding( 20.0 ).max_width( 100.0 );
|
||||||
// available = 200, minus padding*2 = 160, capped at max_width = 100
|
// 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 ]
|
#[ test ]
|
||||||
fn inner_w_without_max_width_subtracts_padding()
|
fn inner_w_without_max_width_subtracts_padding()
|
||||||
{
|
{
|
||||||
|
let canvas = make_canvas();
|
||||||
let col = column::<()>().padding( 10.0 );
|
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 ]
|
#[ test ]
|
||||||
@@ -356,4 +405,39 @@ mod tests
|
|||||||
let ( _, h ) = col.preferred_size( 100.0, &canvas );
|
let ( _, h ) = col.preferred_size( 100.0, &canvas );
|
||||||
assert_eq!( h, 16.0 );
|
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 );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// SPDX-License-Identifier: LGPL-2.1-only
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// 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::render::Canvas;
|
||||||
use crate::widget::Element;
|
use crate::widget::Element;
|
||||||
|
|
||||||
@@ -23,11 +23,29 @@ use crate::widget::Element;
|
|||||||
/// .into()
|
/// .into()
|
||||||
/// # }
|
/// # }
|
||||||
/// ```
|
/// ```
|
||||||
|
///
|
||||||
|
/// `spacing` and `padding` accept any [`crate::Length`]:
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// # use std::sync::Arc;
|
||||||
|
/// # use ltk::{ icon_button, row, Length, Element };
|
||||||
|
/// # #[ derive( Clone ) ] enum Msg { A, B }
|
||||||
|
/// # fn _ex( a_rgba: Arc<Vec<u8>>, b_rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
|
||||||
|
/// row()
|
||||||
|
/// // 2 % of the viewport's smaller side, never below 8 px.
|
||||||
|
/// .spacing( Length::vmin( 2.0 ).at_least( 8.0 ) )
|
||||||
|
/// .push( icon_button( a_rgba, w, h ).on_press( Msg::A ) )
|
||||||
|
/// .push( icon_button( b_rgba, w, h ).on_press( Msg::B ) )
|
||||||
|
/// .into()
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
pub struct Row<Msg: Clone>
|
pub struct Row<Msg: Clone>
|
||||||
{
|
{
|
||||||
pub children: Vec<Element<Msg>>,
|
pub children: Vec<Element<Msg>>,
|
||||||
pub spacing: f32,
|
/// Horizontal gap between children. [`Length`]; default `8.0` px.
|
||||||
pub padding: f32,
|
pub spacing: Length,
|
||||||
|
/// Padding on all sides. [`Length`]; default `0.0` px.
|
||||||
|
pub padding: Length,
|
||||||
pub align_right: bool,
|
pub align_right: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +53,13 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
{
|
{
|
||||||
pub fn new() -> Self
|
pub fn new() -> Self
|
||||||
{
|
{
|
||||||
Self { children: Vec::new(), spacing: 8.0, padding: 0.0, align_right: false }
|
Self
|
||||||
|
{
|
||||||
|
children: Vec::new(),
|
||||||
|
spacing: Length::px( 8.0 ),
|
||||||
|
padding: Length::px( 0.0 ),
|
||||||
|
align_right: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append a child widget or layout.
|
/// Append a child widget or layout.
|
||||||
@@ -45,20 +69,34 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the horizontal gap between children in pixels. Default: `8.0`.
|
/// Set the horizontal gap between children. Default: `8.0` px. Accepts
|
||||||
pub fn spacing( mut self, s: f32 ) -> Self
|
/// any [`Length`] so the gap can scale with the viewport.
|
||||||
|
pub fn spacing( mut self, s: impl Into<Length> ) -> Self
|
||||||
{
|
{
|
||||||
self.spacing = s;
|
self.spacing = s.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the padding (all sides) in pixels. Default: `0.0`.
|
/// Set the padding (all sides). Default: `0.0` px. Accepts any
|
||||||
pub fn padding( mut self, p: f32 ) -> Self
|
/// [`Length`].
|
||||||
|
pub fn padding( mut self, p: impl Into<Length> ) -> Self
|
||||||
{
|
{
|
||||||
self.padding = p;
|
self.padding = p.into();
|
||||||
self
|
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 )
|
||||||
|
}
|
||||||
|
|
||||||
/// Push the content block to the right edge of the available width.
|
/// Push the content block to the right edge of the available width.
|
||||||
pub fn align_right( mut self ) -> Self
|
pub fn align_right( mut self ) -> Self
|
||||||
{
|
{
|
||||||
@@ -69,16 +107,18 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
/// Return the preferred `(width, height)` given available `max_width`.
|
/// Return the preferred `(width, height)` given available `max_width`.
|
||||||
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
||||||
{
|
{
|
||||||
|
let pad = self.resolved_padding( canvas );
|
||||||
|
let spacing = self.resolved_spacing( canvas );
|
||||||
// Width contribution of every fixed (non-flex, non-flex-spacer) child.
|
// Width contribution of every fixed (non-flex, non-flex-spacer) child.
|
||||||
// Used to compute the residual width that wrap-style children will
|
// Used to compute the residual width that wrap-style children will
|
||||||
// actually render in, so their reported height matches the layout.
|
// actually render in, so their reported height matches the layout.
|
||||||
let inner_w = ( max_width - self.padding * 2.0 ).max( 0.0 );
|
let inner_w = ( max_width - pad * 2.0 ).max( 0.0 );
|
||||||
let gaps = self.spacing * self.children.len().saturating_sub( 1 ) as f32;
|
let gaps = spacing * self.children.len().saturating_sub( 1 ) as f32;
|
||||||
let fixed_w: f32 = self.children.iter()
|
let fixed_w: f32 = self.children.iter()
|
||||||
.filter( |c| match c
|
.filter( |c| match c
|
||||||
{
|
{
|
||||||
Element::Flex( _ ) => false,
|
Element::Flex( _ ) => false,
|
||||||
Element::Spacer( s ) => s.fixed_width.is_some(),
|
Element::Spacer( s ) => s.resolved_width( canvas ).is_some(),
|
||||||
_ => true,
|
_ => true,
|
||||||
} )
|
} )
|
||||||
.map( |c| c.preferred_size( max_width, canvas ).0 )
|
.map( |c| c.preferred_size( max_width, canvas ).0 )
|
||||||
@@ -103,7 +143,7 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
let has_flex = self.children.iter().any( |c| match c
|
let has_flex = self.children.iter().any( |c| match c
|
||||||
{
|
{
|
||||||
Element::Flex( _ ) => true,
|
Element::Flex( _ ) => true,
|
||||||
Element::Spacer( s ) => s.fixed_width.is_none(),
|
Element::Spacer( s ) => s.resolved_width( canvas ).is_none(),
|
||||||
_ => false,
|
_ => false,
|
||||||
} );
|
} );
|
||||||
|
|
||||||
@@ -115,11 +155,11 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
.map( |c| c.preferred_size( max_width, canvas ).0 )
|
.map( |c| c.preferred_size( max_width, canvas ).0 )
|
||||||
.sum::<f32>()
|
.sum::<f32>()
|
||||||
+ gaps
|
+ gaps
|
||||||
+ self.padding * 2.0;
|
+ pad * 2.0;
|
||||||
total_w.min( max_width )
|
total_w.min( max_width )
|
||||||
};
|
};
|
||||||
|
|
||||||
( w, max_h + self.padding * 2.0 )
|
( w, max_h + pad * 2.0 )
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {}
|
pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {}
|
||||||
@@ -134,7 +174,9 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
/// [`Row::align_right`]).
|
/// [`Row::align_right`]).
|
||||||
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
|
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
|
||||||
{
|
{
|
||||||
let inner_h = rect.height - self.padding * 2.0;
|
let pad = self.resolved_padding( canvas );
|
||||||
|
let spacing = self.resolved_spacing( canvas );
|
||||||
|
let inner_h = rect.height - pad * 2.0;
|
||||||
|
|
||||||
// Spacers and `Flex` wrappers report 0 width here; their real width
|
// Spacers and `Flex` wrappers report 0 width here; their real width
|
||||||
// comes from the flex distribution below.
|
// comes from the flex distribution below.
|
||||||
@@ -142,7 +184,7 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
.map( |c| c.preferred_size( rect.width, canvas ) )
|
.map( |c| c.preferred_size( rect.width, canvas ) )
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let gaps = self.spacing * self.children.len().saturating_sub( 1 ) as f32;
|
let gaps = spacing * self.children.len().saturating_sub( 1 ) as f32;
|
||||||
let fixed_w: f32 = self.children.iter().zip( sizes.iter() )
|
let fixed_w: f32 = self.children.iter().zip( sizes.iter() )
|
||||||
.filter( |( c, _ )| match c
|
.filter( |( c, _ )| match c
|
||||||
{
|
{
|
||||||
@@ -151,7 +193,7 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
// `Spacer::width(...)`-pinned spacers, contributes to the
|
// `Spacer::width(...)`-pinned spacers, contributes to the
|
||||||
// fixed-width tally.
|
// fixed-width tally.
|
||||||
Element::Flex( _ ) => false,
|
Element::Flex( _ ) => false,
|
||||||
Element::Spacer( s ) => s.fixed_width.is_some(),
|
Element::Spacer( s ) => s.resolved_width( canvas ).is_some(),
|
||||||
_ => true,
|
_ => true,
|
||||||
} )
|
} )
|
||||||
.map( |( _, ( w, _ ) )| *w )
|
.map( |( _, ( w, _ ) )| *w )
|
||||||
@@ -159,13 +201,13 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
|
|
||||||
let total_weight: u32 = self.children.iter()
|
let total_weight: u32 = self.children.iter()
|
||||||
.filter_map( |c| match c {
|
.filter_map( |c| match c {
|
||||||
Element::Spacer( s ) if s.fixed_width.is_none() => Some( s.weight ),
|
Element::Spacer( s ) if s.resolved_width( canvas ).is_none() => Some( s.weight ),
|
||||||
Element::Flex( f ) => Some( f.weight ),
|
Element::Flex( f ) => Some( f.weight ),
|
||||||
_ => None,
|
_ => None,
|
||||||
} )
|
} )
|
||||||
.sum();
|
.sum();
|
||||||
|
|
||||||
let inner_w = ( rect.width - self.padding * 2.0 ).max( 0.0 );
|
let inner_w = ( rect.width - pad * 2.0 ).max( 0.0 );
|
||||||
let leftover = ( inner_w - fixed_w - gaps ).max( 0.0 );
|
let leftover = ( inner_w - fixed_w - gaps ).max( 0.0 );
|
||||||
let has_spacers = total_weight > 0;
|
let has_spacers = total_weight > 0;
|
||||||
|
|
||||||
@@ -173,15 +215,15 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
{
|
{
|
||||||
// Spacers and `Flex` wrappers claim the leftover; the cluster
|
// Spacers and `Flex` wrappers claim the leftover; the cluster
|
||||||
// sits flush to the left edge of the inner rect.
|
// sits flush to the left edge of the inner rect.
|
||||||
( rect.x + self.padding, leftover / total_weight as f32 )
|
( rect.x + pad, leftover / total_weight as f32 )
|
||||||
}
|
}
|
||||||
else if self.align_right
|
else if self.align_right
|
||||||
{
|
{
|
||||||
( rect.x + rect.width - (fixed_w + gaps) - self.padding, 0.0 )
|
( rect.x + rect.width - ( fixed_w + gaps ) - pad, 0.0 )
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
( rect.x + (rect.width - fixed_w - gaps) / 2.0, 0.0 )
|
( rect.x + ( rect.width - fixed_w - gaps ) / 2.0, 0.0 )
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut x = start_x;
|
let mut x = start_x;
|
||||||
@@ -190,7 +232,7 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
{
|
{
|
||||||
let width = match child
|
let width = match child
|
||||||
{
|
{
|
||||||
Element::Spacer( s ) => match s.fixed_width
|
Element::Spacer( s ) => match s.resolved_width( canvas )
|
||||||
{
|
{
|
||||||
Some( fw ) => fw,
|
Some( fw ) => fw,
|
||||||
None => flex_unit * s.weight as f32,
|
None => flex_unit * s.weight as f32,
|
||||||
@@ -198,9 +240,9 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
Element::Flex( f ) => flex_unit * f.weight as f32,
|
Element::Flex( f ) => flex_unit * f.weight as f32,
|
||||||
_ => w,
|
_ => w,
|
||||||
};
|
};
|
||||||
let y = rect.y + self.padding + (inner_h - h) / 2.0;
|
let y = rect.y + pad + ( inner_h - h ) / 2.0;
|
||||||
result.push( ( Rect { x, y, width, height: h }, i ) );
|
result.push( ( Rect { x, y, width, height: h }, i ) );
|
||||||
x += width + self.spacing;
|
x += width + spacing;
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -296,4 +338,37 @@ mod tests
|
|||||||
let rect = Rect { x: 0., y: 0., width: 400., height: 48. };
|
let rect = Rect { x: 0., y: 0., width: 400., height: 48. };
|
||||||
assert!( r.layout( rect, &canvas ).is_empty() );
|
assert!( r.layout( rect, &canvas ).is_empty() );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn vmin_padding_doubles_around_content()
|
||||||
|
{
|
||||||
|
// 800x600 canvas → vmin = 600. 4 % = 24 px each side → 48 px height.
|
||||||
|
let canvas = make_canvas();
|
||||||
|
let r = row::<()>().padding( Length::vmin( 4.0 ) );
|
||||||
|
let ( _, h ) = r.preferred_size( 500.0, &canvas );
|
||||||
|
assert_eq!( h, 48.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn vmin_spacing_pins_visible_layout_gap()
|
||||||
|
{
|
||||||
|
// Two fixed-width spacers separated by a vmin spacing of 5 %.
|
||||||
|
// Canvas vmin = 600 → 30 px between the inner edges. With both
|
||||||
|
// spacers 10 px wide, the second one's `x` minus the first one's
|
||||||
|
// `x + width` must equal the gap regardless of where the row chose
|
||||||
|
// to anchor the cluster (centered, since there are no flex spacers).
|
||||||
|
let canvas = make_canvas();
|
||||||
|
let r = row::<()>()
|
||||||
|
.padding( 0.0 )
|
||||||
|
.spacing( Length::vmin( 5.0 ) )
|
||||||
|
.push( crate::spacer().width( 10.0 ) )
|
||||||
|
.push( crate::spacer().width( 10.0 ) );
|
||||||
|
let rect = Rect { x: 0., y: 0., width: 200., height: 48. };
|
||||||
|
let placed = r.layout( rect, &canvas );
|
||||||
|
assert_eq!( placed.len(), 2 );
|
||||||
|
let ( first_rect, _ ) = placed[ 0 ];
|
||||||
|
let ( second_rect, _ ) = placed[ 1 ];
|
||||||
|
let gap = second_rect.x - ( first_rect.x + first_rect.width );
|
||||||
|
assert!( ( gap - 30.0 ).abs() < 1e-3, "expected ~30 px gap, got {gap}" );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// SPDX-License-Identifier: LGPL-2.1-only
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
// 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::types::Length;
|
||||||
use crate::widget::Element;
|
use crate::widget::Element;
|
||||||
|
|
||||||
/// A flexible, invisible spacer that expands to fill available space.
|
/// A flexible, invisible spacer that expands to fill available space.
|
||||||
@@ -44,7 +46,23 @@ use crate::widget::Element;
|
|||||||
/// # fn _ex() -> Element<Msg> {
|
/// # fn _ex() -> Element<Msg> {
|
||||||
/// column()
|
/// column()
|
||||||
/// .push( text( "Header" ) )
|
/// .push( text( "Header" ) )
|
||||||
/// .push( spacer().height( 20.0 ) ) // Exactly 20px gap
|
/// .push( spacer().height( 20.0 ) ) // Exactly 20 px gap
|
||||||
|
/// .push( text( "Content" ) )
|
||||||
|
/// .into()
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// `.height(...)` and `.width(...)` also accept any [`crate::Length`], so the
|
||||||
|
/// gap can scale with the surface instead of being frozen at a px constant:
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// # use ltk::{ column, spacer, text, Length, Element };
|
||||||
|
/// # #[ derive( Clone ) ] enum Msg {}
|
||||||
|
/// # fn _ex() -> Element<Msg> {
|
||||||
|
/// column()
|
||||||
|
/// .push( text( "Header" ) )
|
||||||
|
/// // 6 % of the surface's smaller side, never below 16 px or above 64 px.
|
||||||
|
/// .push( spacer().height( Length::vmin( 6.0 ).clamp( 16.0, 64.0 ) ) )
|
||||||
/// .push( text( "Content" ) )
|
/// .push( text( "Content" ) )
|
||||||
/// .into()
|
/// .into()
|
||||||
/// # }
|
/// # }
|
||||||
@@ -53,10 +71,14 @@ pub struct Spacer
|
|||||||
{
|
{
|
||||||
/// Relative weight of this spacer (default 1).
|
/// Relative weight of this spacer (default 1).
|
||||||
pub weight: u32,
|
pub weight: u32,
|
||||||
/// Fixed height in pixels (overrides flexible behavior in a column).
|
/// Fixed height (overrides flexible behavior in a column). Accepts any
|
||||||
pub fixed_height: Option<f32>,
|
/// [`Length`] — pass an `f32`/`i32`/`u32` for the px case (kept for
|
||||||
/// Fixed width in pixels (overrides flexible behavior in a row).
|
/// backward compatibility with existing call sites), or
|
||||||
pub fixed_width: Option<f32>,
|
/// `Length::vmin( … )` etc. for viewport-relative gaps.
|
||||||
|
pub fixed_height: Option<Length>,
|
||||||
|
/// Fixed width (overrides flexible behavior in a row). Same length-type
|
||||||
|
/// semantics as [`Self::fixed_height`].
|
||||||
|
pub fixed_width: Option<Length>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Spacer
|
impl Spacer
|
||||||
@@ -68,34 +90,50 @@ impl Spacer
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set a fixed height for this spacer in pixels.
|
/// Set a fixed height for this spacer. Accepts any [`Length`]: a bare
|
||||||
/// When set, the spacer will occupy exactly this much vertical space
|
/// `24.0_f32` is treated as `Length::px( 24.0 )` for source-level
|
||||||
/// instead of expanding flexibly.
|
/// backwards compatibility; a `Length::vmin( 6.0 )` makes the gap
|
||||||
pub fn height( mut self, h: f32 ) -> Self
|
/// scale with the surface's smaller dimension.
|
||||||
|
pub fn height( mut self, h: impl Into<Length> ) -> Self
|
||||||
{
|
{
|
||||||
self.fixed_height = Some( h );
|
self.fixed_height = Some( h.into() );
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set a fixed width for this spacer in pixels.
|
/// Set a fixed width for this spacer. Mirrors [`Self::height`] for the
|
||||||
/// When set, the spacer occupies exactly this much horizontal space
|
/// horizontal axis.
|
||||||
/// inside a [`Row`](crate::layout::row::Row) instead of expanding
|
pub fn width( mut self, w: impl Into<Length> ) -> Self
|
||||||
/// flexibly. Mirrors [`Self::height`] for the horizontal axis — useful
|
|
||||||
/// to reserve a precise visual margin while a sibling
|
|
||||||
/// [`Flex`](crate::Flex) claims the remaining width.
|
|
||||||
pub fn width( mut self, w: f32 ) -> Self
|
|
||||||
{
|
{
|
||||||
self.fixed_width = Some( w );
|
self.fixed_width = Some( w.into() );
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `( fixed_width, fixed_height )`, falling back to `0.0` on the
|
/// Returns `( fixed_width, fixed_height )` resolved against the
|
||||||
/// axes that were not pinned. The parent layout distributes leftover
|
/// current canvas viewport, falling back to `0.0` on axes that were
|
||||||
/// along its main axis among the still-flexible spacers and `Flex`
|
/// not pinned. The parent layout distributes leftover along its main
|
||||||
/// wrappers, weighted by `weight`.
|
/// axis among the still-flexible spacers and `Flex` wrappers,
|
||||||
pub fn preferred_size( &self ) -> (f32, f32)
|
/// weighted by `weight`.
|
||||||
|
pub fn preferred_size( &self, canvas: &Canvas ) -> ( f32, f32 )
|
||||||
{
|
{
|
||||||
( self.fixed_width.unwrap_or( 0.0 ), self.fixed_height.unwrap_or( 0.0 ) )
|
let vp = canvas.viewport_logical();
|
||||||
|
let em = Length::EM_BASE_DEFAULT;
|
||||||
|
(
|
||||||
|
self.fixed_width .map( |l| l.resolve( vp, em ) ).unwrap_or( 0.0 ),
|
||||||
|
self.fixed_height.map( |l| l.resolve( vp, em ) ).unwrap_or( 0.0 ),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved fixed height in logical pixels, or `None` when the
|
||||||
|
/// spacer is flex. Cheaper than [`Self::preferred_size`] when the
|
||||||
|
/// layout only needs the main-axis size for one orientation.
|
||||||
|
pub fn resolved_height( &self, canvas: &Canvas ) -> Option<f32>
|
||||||
|
{
|
||||||
|
self.fixed_height.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) )
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolved_width( &self, canvas: &Canvas ) -> Option<f32>
|
||||||
|
{
|
||||||
|
self.fixed_width.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) )
|
||||||
}
|
}
|
||||||
|
|
||||||
/// No-op — spacers are invisible.
|
/// No-op — spacers are invisible.
|
||||||
@@ -141,3 +179,47 @@ pub fn spacer() -> Spacer
|
|||||||
{
|
{
|
||||||
Spacer { weight: 1, fixed_height: None, fixed_width: None }
|
Spacer { weight: 1, fixed_height: None, fixed_width: None }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod tests
|
||||||
|
{
|
||||||
|
use super::*;
|
||||||
|
use crate::render::Canvas;
|
||||||
|
|
||||||
|
fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) }
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn height_accepts_f32_as_pixels()
|
||||||
|
{
|
||||||
|
let s = spacer().height( 24.0 );
|
||||||
|
let canvas = make_canvas();
|
||||||
|
assert_eq!( s.resolved_height( &canvas ), Some( 24.0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn height_accepts_length_and_resolves_against_viewport()
|
||||||
|
{
|
||||||
|
// 10 % of the smaller side (= 600) = 60 px.
|
||||||
|
let s = spacer().height( Length::vmin( 10.0 ) );
|
||||||
|
let canvas = make_canvas();
|
||||||
|
assert_eq!( s.resolved_height( &canvas ), Some( 60.0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn width_accepts_length_and_resolves_against_viewport()
|
||||||
|
{
|
||||||
|
let s = spacer().width( Length::vw( 25.0 ) );
|
||||||
|
let canvas = make_canvas();
|
||||||
|
// 25 % of 800 = 200.
|
||||||
|
assert_eq!( s.resolved_width( &canvas ), Some( 200.0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn flex_spacer_has_no_resolved_dimensions()
|
||||||
|
{
|
||||||
|
let s = spacer().weight( 3 );
|
||||||
|
let canvas = make_canvas();
|
||||||
|
assert_eq!( s.resolved_height( &canvas ), None );
|
||||||
|
assert_eq!( s.resolved_width( &canvas ), None );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ impl<Msg: Clone> Stack<Msg>
|
|||||||
let content_w = self.children.iter()
|
let content_w = self.children.iter()
|
||||||
.map( |( c, _, _, _, _, _ )| match c
|
.map( |( c, _, _, _, _, _ )| match c
|
||||||
{
|
{
|
||||||
Element::Spacer( s ) => s.fixed_width.unwrap_or( 0.0 ),
|
Element::Spacer( s ) => s.resolved_width( canvas ).unwrap_or( 0.0 ),
|
||||||
Element::Separator( _ ) => 0.0,
|
Element::Separator( _ ) => 0.0,
|
||||||
Element::Scroll( _ ) => 0.0,
|
Element::Scroll( _ ) => 0.0,
|
||||||
Element::ProgressBar( _ ) => 0.0,
|
Element::ProgressBar( _ ) => 0.0,
|
||||||
|
|||||||
29
src/lib.rs
29
src/lib.rs
@@ -107,9 +107,36 @@
|
|||||||
//! Geometry and primitive values that flow through every builder:
|
//! Geometry and primitive values that flow through every builder:
|
||||||
//!
|
//!
|
||||||
//! - [`Color`], [`Rect`], [`Point`], [`Size`], [`Corners`], [`WidgetId`].
|
//! - [`Color`], [`Rect`], [`Point`], [`Size`], [`Corners`], [`WidgetId`].
|
||||||
|
//! - [`Length`] — a size/distance that may be absolute pixels
|
||||||
|
//! ([`LengthBase::Px`]) or relative to the surface viewport
|
||||||
|
//! ([`LengthBase::Vw`] / [`LengthBase::Vh`] / [`LengthBase::Vmin`] /
|
||||||
|
//! [`LengthBase::Vmax`]) or to the root font size ([`LengthBase::Em`]).
|
||||||
|
//! Every setter that takes a size, padding, spacing or font height
|
||||||
|
//! now accepts `impl Into<Length>`, so legacy `.size( 24.0 )` keeps
|
||||||
|
//! working while new code can write `.size( Length::vmin( 4.0 )
|
||||||
|
//! .clamp( 16.0, 32.0 ) )` for a typeface that scales with the screen.
|
||||||
//!
|
//!
|
||||||
//! See [`types`] for the full module with `//!` description.
|
//! See [`types`] for the full module with `//!` description.
|
||||||
//!
|
//!
|
||||||
|
//! ## Designing for multiple resolutions
|
||||||
|
//!
|
||||||
|
//! ltk supports three approaches to making a layout adapt to the screen,
|
||||||
|
//! ordered from most to least common:
|
||||||
|
//!
|
||||||
|
//! 1. **Relative [`Length`] values** for font sizes, padding, spacing and
|
||||||
|
//! spacer height/width. Default to `Length::vmin( pct ).clamp( lo, hi )`:
|
||||||
|
//! the percentage tracks the surface's smaller side (so portrait and
|
||||||
|
//! landscape behave coherently), and the px clamps protect both ends
|
||||||
|
//! of the spectrum from breaking the design.
|
||||||
|
//! 2. **Responsive typographic scale** via [`theme::typography::h0`]…
|
||||||
|
//! [`theme::typography::body_xs`] — pre-calibrated [`Length`] values
|
||||||
|
//! coherent with the default Sora-based theme. Calibrated so a
|
||||||
|
//! 1000-px smaller side reproduces the legacy px constants exactly.
|
||||||
|
//! 3. **`view()`-level branching on `surface_width` / `surface_height`**
|
||||||
|
//! when the layout structure itself must change (e.g. sidebar →
|
||||||
|
//! bottom-tabs below a breakpoint). Keep this for genuine restructuring,
|
||||||
|
//! not for sizing — `Length` covers sizing.
|
||||||
|
//!
|
||||||
//! ## Runtime-free embedding
|
//! ## Runtime-free embedding
|
||||||
//!
|
//!
|
||||||
//! Use [`core::UiSurface`] when you need ltk's layout, drawing and
|
//! Use [`core::UiSurface`] when you need ltk's layout, drawing and
|
||||||
@@ -202,7 +229,7 @@ pub use theme::{ decode_svg_bytes, icon_path as theme_icon_path, icon_rgba as th
|
|||||||
pub use gles_render::{ BorrowedGlesTexture, GlesVersion };
|
pub use gles_render::{ BorrowedGlesTexture, GlesVersion };
|
||||||
pub use render::is_software_render;
|
pub use render::is_software_render;
|
||||||
pub use wallpaper::{ WallpaperBundle, ImageData };
|
pub use wallpaper::{ WallpaperBundle, ImageData };
|
||||||
pub use types::{ Color, Corners, CursorShape, Point, Rect, Size, WidgetId };
|
pub use types::{ Color, Corners, CursorShape, Length, LengthBase, Point, Rect, Size, WidgetId };
|
||||||
pub use widget::{ Element, button, icon_button, text_edit, image as img_widget, text, container };
|
pub use widget::{ Element, button, icon_button, text_edit, image as img_widget, text, container };
|
||||||
pub use widget::button::ButtonVariant;
|
pub use widget::button::ButtonVariant;
|
||||||
pub use widget::slider::{ Slider, slider, SliderAxis };
|
pub use widget::slider::{ Slider, slider, SliderAxis };
|
||||||
|
|||||||
@@ -199,6 +199,31 @@ impl Canvas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `(width, height)` of the surface in **logical** pixels (physical
|
||||||
|
/// size divided by `dpi_scale`). This is the right viewport for
|
||||||
|
/// resolving [`crate::Length`] values, which are themselves in
|
||||||
|
/// logical units. Falls back to physical size if `dpi_scale` is
|
||||||
|
/// 0 or negative so a misconfigured canvas still returns a usable
|
||||||
|
/// non-zero viewport instead of `NaN`/`inf`.
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// # use ltk::core::Canvas;
|
||||||
|
/// let mut c = Canvas::new( 720, 1440 );
|
||||||
|
/// c.set_dpi_scale( 2.0 );
|
||||||
|
/// assert_eq!( c.viewport_logical(), ( 360.0, 720.0 ) );
|
||||||
|
/// ```
|
||||||
|
pub fn viewport_logical( &self ) -> ( f32, f32 )
|
||||||
|
{
|
||||||
|
let ( pw, ph ) = self.size();
|
||||||
|
let scale = self.dpi_scale();
|
||||||
|
if scale > 0.0
|
||||||
|
{
|
||||||
|
( pw as f32 / scale, ph as f32 / scale )
|
||||||
|
} else {
|
||||||
|
( pw as f32, ph as f32 )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Borrow the GLES texture backing this canvas, when the canvas
|
/// Borrow the GLES texture backing this canvas, when the canvas
|
||||||
/// is GPU-backed.
|
/// is GPU-backed.
|
||||||
pub fn borrowed_gles_texture( &self ) -> Option<BorrowedGlesTexture>
|
pub fn borrowed_gles_texture( &self ) -> Option<BorrowedGlesTexture>
|
||||||
@@ -642,3 +667,34 @@ impl Canvas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod viewport_tests
|
||||||
|
{
|
||||||
|
use super::Canvas;
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn viewport_logical_at_scale_one_matches_physical()
|
||||||
|
{
|
||||||
|
let c = Canvas::new( 800, 600 );
|
||||||
|
assert_eq!( c.viewport_logical(), ( 800.0, 600.0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn viewport_logical_divides_by_dpi_scale()
|
||||||
|
{
|
||||||
|
let mut c = Canvas::new( 720, 1440 );
|
||||||
|
c.set_dpi_scale( 2.0 );
|
||||||
|
assert_eq!( c.viewport_logical(), ( 360.0, 720.0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn viewport_logical_falls_back_to_physical_when_scale_is_zero()
|
||||||
|
{
|
||||||
|
// Guard the misconfigured-scale path: a divide-by-zero would
|
||||||
|
// poison every `Length::Vmin`/`Vw`/`Vh` resolution downstream.
|
||||||
|
let mut c = Canvas::new( 800, 600 );
|
||||||
|
c.set_dpi_scale( 0.0 );
|
||||||
|
assert_eq!( c.viewport_logical(), ( 800.0, 600.0 ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,17 @@
|
|||||||
//! Designed around the **Sora** typeface (Google Fonts). If Sora is not
|
//! Designed around the **Sora** typeface (Google Fonts). If Sora is not
|
||||||
//! installed on the system, ltk falls back to Liberation Sans / DejaVu Sans;
|
//! installed on the system, ltk falls back to Liberation Sans / DejaVu Sans;
|
||||||
//! glyph metrics will differ slightly but the scale still reads correctly.
|
//! glyph metrics will differ slightly but the scale still reads correctly.
|
||||||
|
//!
|
||||||
|
//! Two scales coexist: the historic **px** constants (`H0` through
|
||||||
|
//! `BODY_XS`) for code that wants a frozen pixel size, and the new
|
||||||
|
//! **responsive** scale ([`h0`], [`h1`], …, [`body_xs`]) that returns
|
||||||
|
//! viewport-relative [`crate::Length`] values clamped to the same px
|
||||||
|
//! range that used to be the constant. Call sites can mix freely:
|
||||||
|
//! `.size( typography::H2 )` still resolves to `Length::px( 24.0 )` via
|
||||||
|
//! `From<f32>`, while `.size( typography::h2() )` scales with the
|
||||||
|
//! surface's smaller dimension.
|
||||||
|
|
||||||
|
use crate::types::Length;
|
||||||
|
|
||||||
pub const H0: f32 = 50.0;
|
pub const H0: f32 = 50.0;
|
||||||
pub const H1: f32 = 34.0;
|
pub const H1: f32 = 34.0;
|
||||||
@@ -18,3 +29,59 @@ pub const BODY_XS: f32 = 12.0;
|
|||||||
/// Interlineado (line-height) multiplier recommended by the kit. Apply as
|
/// Interlineado (line-height) multiplier recommended by the kit. Apply as
|
||||||
/// `size * LINE_HEIGHT` when laying out multi-line text blocks.
|
/// `size * LINE_HEIGHT` when laying out multi-line text blocks.
|
||||||
pub const LINE_HEIGHT: f32 = 1.5;
|
pub const LINE_HEIGHT: f32 = 1.5;
|
||||||
|
|
||||||
|
// ─── Responsive scale ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The percentages are calibrated so the px clamps match the legacy constants
|
||||||
|
// at a 1000 px logical smaller side (a typical landscape tablet). On a Librem 5
|
||||||
|
// portrait (~720 px) headings round down sensibly; on a 4K desktop the upper
|
||||||
|
// clamp kicks in before display titles get absurd.
|
||||||
|
|
||||||
|
pub fn h0() -> Length { Length::vmin( 5.0 ).clamp( 32.0, 80.0 ) }
|
||||||
|
pub fn h1() -> Length { Length::vmin( 3.4 ).clamp( 24.0, 56.0 ) }
|
||||||
|
pub fn h2() -> Length { Length::vmin( 2.4 ).clamp( 18.0, 40.0 ) }
|
||||||
|
pub fn h3() -> Length { Length::vmin( 2.0 ).clamp( 16.0, 32.0 ) }
|
||||||
|
pub fn body() -> Length { Length::vmin( 1.6 ).clamp( 14.0, 22.0 ) }
|
||||||
|
pub fn body_s() -> Length { Length::vmin( 1.4 ).clamp( 12.0, 18.0 ) }
|
||||||
|
pub fn body_xs() -> Length { Length::vmin( 1.2 ).clamp( 11.0, 15.0 ) }
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod tests
|
||||||
|
{
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// 360-px-wide portrait phone — Vmin ratio under the clamp's lower bound,
|
||||||
|
/// so every scale snaps to its `min_px`.
|
||||||
|
#[ test ]
|
||||||
|
fn responsive_scale_pins_to_min_on_narrow_phones()
|
||||||
|
{
|
||||||
|
let vp = ( 360.0, 720.0 );
|
||||||
|
let em = Length::EM_BASE_DEFAULT;
|
||||||
|
assert_eq!( h0().resolve( vp, em ), 32.0 );
|
||||||
|
assert_eq!( body().resolve( vp, em ), 14.0 );
|
||||||
|
assert_eq!( body_xs().resolve( vp, em ), 11.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 1000-px smaller side — the calibration point. Numbers should be
|
||||||
|
/// "around" the legacy px constants without exceeding the upper clamp.
|
||||||
|
#[ test ]
|
||||||
|
fn responsive_scale_centers_around_legacy_px_constants()
|
||||||
|
{
|
||||||
|
let vp = ( 1000.0, 1000.0 );
|
||||||
|
let em = Length::EM_BASE_DEFAULT;
|
||||||
|
assert_eq!( h0().resolve( vp, em ), 50.0 );
|
||||||
|
assert_eq!( h2().resolve( vp, em ), 24.0 );
|
||||||
|
assert_eq!( body().resolve( vp, em ), 16.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 4K-class smaller side — every scale should saturate to its upper
|
||||||
|
/// clamp instead of growing absurdly.
|
||||||
|
#[ test ]
|
||||||
|
fn responsive_scale_pins_to_max_on_large_displays()
|
||||||
|
{
|
||||||
|
let vp = ( 2160.0, 3840.0 );
|
||||||
|
let em = Length::EM_BASE_DEFAULT;
|
||||||
|
assert_eq!( h0().resolve( vp, em ), 80.0 );
|
||||||
|
assert_eq!( body().resolve( vp, em ), 22.0 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
223
src/types.rs
223
src/types.rs
@@ -430,3 +430,226 @@ mod tests
|
|||||||
assert_eq!( r, e );
|
assert_eq!( r, e );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Length ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// One of the pure relative-or-absolute modes a [`Length`] can carry.
|
||||||
|
/// Split out so [`Length`] itself can stay `Copy` while still supporting
|
||||||
|
/// optional clamp bounds — the recursive `Clamp` variant of the original
|
||||||
|
/// sketch would have forced a `Box` allocation, which on a widget tree
|
||||||
|
/// that builds these values per frame is the wrong trade.
|
||||||
|
#[ derive( Debug, Clone, Copy, PartialEq ) ]
|
||||||
|
pub enum LengthBase
|
||||||
|
{
|
||||||
|
/// Absolute, in logical pixels.
|
||||||
|
Px( f32 ),
|
||||||
|
/// Percentage of the viewport's width (`Vw(10.0)` == 10 % of width).
|
||||||
|
Vw( f32 ),
|
||||||
|
/// Percentage of the viewport's height.
|
||||||
|
Vh( f32 ),
|
||||||
|
/// Percentage of the viewport's **smaller** dimension. The right
|
||||||
|
/// default for typography and gutters that must survive a
|
||||||
|
/// portrait/landscape rotation without growing absurd.
|
||||||
|
Vmin( f32 ),
|
||||||
|
/// Percentage of the viewport's **larger** dimension.
|
||||||
|
Vmax( f32 ),
|
||||||
|
/// Multiple of the root font size (typographic hierarchy: a heading
|
||||||
|
/// of `Em(2.0)` is twice the body size, regardless of viewport).
|
||||||
|
Em( f32 ),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LengthBase
|
||||||
|
{
|
||||||
|
fn resolve( &self, viewport: ( f32, f32 ), em_base: f32 ) -> f32
|
||||||
|
{
|
||||||
|
let ( vw, vh ) = viewport;
|
||||||
|
match self
|
||||||
|
{
|
||||||
|
LengthBase::Px( v ) => *v,
|
||||||
|
LengthBase::Vw( pct ) => vw * pct / 100.0,
|
||||||
|
LengthBase::Vh( pct ) => vh * pct / 100.0,
|
||||||
|
LengthBase::Vmin( pct ) => vw.min( vh ) * pct / 100.0,
|
||||||
|
LengthBase::Vmax( pct ) => vw.max( vh ) * pct / 100.0,
|
||||||
|
LengthBase::Em( mul ) => em_base * mul,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A size or distance value that may be expressed in absolute pixels or
|
||||||
|
/// relative to the rendering surface. Every widget API that used to take
|
||||||
|
/// `f32` for a size, padding, spacing or font height now takes
|
||||||
|
/// `impl Into<Length>`, so existing call sites keep compiling unchanged
|
||||||
|
/// while new code can switch to viewport-relative units for layouts that
|
||||||
|
/// must scale across screen sizes (portrait phone, landscape tablet,
|
||||||
|
/// 4K desktop) without per-target tweaks.
|
||||||
|
///
|
||||||
|
/// Resolution requires a viewport — passed in as `(width, height)` in
|
||||||
|
/// **logical** pixels — and an `em_base` (the body-text font size that
|
||||||
|
/// `Em` is a multiple of). All resolution funnels through
|
||||||
|
/// [`Length::resolve`], so widgets can stay backend-agnostic.
|
||||||
|
///
|
||||||
|
/// Construct directly via the [`LengthBase`] variants
|
||||||
|
/// (`Length::vmin( 18.0 )`, `Length::px( 24.0 )`, …) or implicitly from
|
||||||
|
/// `f32`/`i32`/`u32` for the px case so legacy `.size( 24.0 )` style
|
||||||
|
/// keeps compiling unchanged. Optionally chain `.clamp( min_px, max_px )`
|
||||||
|
/// to bound a relative value into a safe range.
|
||||||
|
#[ derive( Debug, Clone, Copy, PartialEq ) ]
|
||||||
|
pub struct Length
|
||||||
|
{
|
||||||
|
pub base: LengthBase,
|
||||||
|
/// Lower bound in absolute logical px. `None` means unbounded.
|
||||||
|
pub min_px: Option<f32>,
|
||||||
|
/// Upper bound in absolute logical px. `None` means unbounded.
|
||||||
|
pub max_px: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Length
|
||||||
|
{
|
||||||
|
/// Default font-size that [`LengthBase::Em`] is a multiple of. Matches
|
||||||
|
/// the `typography::BODY` constant of the default theme.
|
||||||
|
pub const EM_BASE_DEFAULT: f32 = 16.0;
|
||||||
|
|
||||||
|
pub const fn from_base( base: LengthBase ) -> Self
|
||||||
|
{
|
||||||
|
Self { base, min_px: None, max_px: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shorthand constructors. `Length::vmin( 18.0 )` reads better than
|
||||||
|
/// `Length::from_base( LengthBase::Vmin( 18.0 ) )` at every call site
|
||||||
|
/// and the brevity matters when these appear in tight view code.
|
||||||
|
pub const fn px( v: f32 ) -> Self { Self::from_base( LengthBase::Px( v ) ) }
|
||||||
|
pub const fn vw( v: f32 ) -> Self { Self::from_base( LengthBase::Vw( v ) ) }
|
||||||
|
pub const fn vh( v: f32 ) -> Self { Self::from_base( LengthBase::Vh( v ) ) }
|
||||||
|
pub const fn vmin( v: f32 ) -> Self { Self::from_base( LengthBase::Vmin( v ) ) }
|
||||||
|
pub const fn vmax( v: f32 ) -> Self { Self::from_base( LengthBase::Vmax( v ) ) }
|
||||||
|
pub const fn em( v: f32 ) -> Self { Self::from_base( LengthBase::Em( v ) ) }
|
||||||
|
|
||||||
|
/// Resolve to a concrete logical-pixel value given a viewport and an
|
||||||
|
/// `em_base` (the root font size that `Em` is a fraction of).
|
||||||
|
pub fn resolve( &self, viewport: ( f32, f32 ), em_base: f32 ) -> f32
|
||||||
|
{
|
||||||
|
let raw = self.base.resolve( viewport, em_base );
|
||||||
|
let lo = self.min_px;
|
||||||
|
let hi = self.max_px;
|
||||||
|
// If both bounds present, normalise their order so swapped args
|
||||||
|
// don't produce NaN out of f32::clamp.
|
||||||
|
let ( lo, hi ) = match ( lo, hi )
|
||||||
|
{
|
||||||
|
( Some( a ), Some( b ) ) if a > b => ( Some( b ), Some( a ) ),
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
let v = match lo { Some( a ) => raw.max( a ), None => raw };
|
||||||
|
match hi { Some( b ) => v.min( b ), None => v }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cap the resolved value to `[min_px, max_px]`. Bounds are
|
||||||
|
/// absolute px because the typical use is "this Vmin should never
|
||||||
|
/// shrink past readable nor balloon past comfortable"; bounding
|
||||||
|
/// a relative value with another relative value is rare enough to
|
||||||
|
/// not justify boxing the type. If you swap min/max the resolver
|
||||||
|
/// tolerates it instead of panicking.
|
||||||
|
pub fn clamp( mut self, min_px: f32, max_px: f32 ) -> Length
|
||||||
|
{
|
||||||
|
self.min_px = Some( min_px );
|
||||||
|
self.max_px = Some( max_px );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-sided bound: never resolve below `min_px`. Named `at_least`
|
||||||
|
/// (rather than `min`) to avoid clashing visually with `f32::min`,
|
||||||
|
/// which has the opposite semantics ("return the smaller of two").
|
||||||
|
pub fn at_least( mut self, min_px: f32 ) -> Length
|
||||||
|
{
|
||||||
|
self.min_px = Some( min_px );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-sided bound: never resolve above `max_px`. Counterpart to
|
||||||
|
/// [`Self::at_least`].
|
||||||
|
pub fn at_most( mut self, max_px: f32 ) -> Length
|
||||||
|
{
|
||||||
|
self.max_px = Some( max_px );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<f32> for Length
|
||||||
|
{
|
||||||
|
fn from( v: f32 ) -> Self { Length::px( v ) }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<i32> for Length
|
||||||
|
{
|
||||||
|
fn from( v: i32 ) -> Self { Length::px( v as f32 ) }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<u32> for Length
|
||||||
|
{
|
||||||
|
fn from( v: u32 ) -> Self { Length::px( v as f32 ) }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<LengthBase> for Length
|
||||||
|
{
|
||||||
|
fn from( base: LengthBase ) -> Self { Length::from_base( base ) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod length_tests
|
||||||
|
{
|
||||||
|
use super::Length;
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn px_is_passthrough()
|
||||||
|
{
|
||||||
|
assert_eq!( Length::px( 42.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 42.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn vw_vh_are_percent_of_viewport()
|
||||||
|
{
|
||||||
|
assert_eq!( Length::vw( 50.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 400.0 );
|
||||||
|
assert_eq!( Length::vh( 25.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 150.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn vmin_picks_smaller_side()
|
||||||
|
{
|
||||||
|
assert_eq!( Length::vmin( 10.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 60.0 );
|
||||||
|
assert_eq!( Length::vmin( 10.0 ).resolve( ( 600.0, 800.0 ), 16.0 ), 60.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn vmax_picks_larger_side()
|
||||||
|
{
|
||||||
|
assert_eq!( Length::vmax( 10.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 80.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn em_uses_em_base()
|
||||||
|
{
|
||||||
|
assert_eq!( Length::em( 2.0 ).resolve( ( 800.0, 600.0 ), 18.0 ), 36.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn clamp_bounds_relative_value()
|
||||||
|
{
|
||||||
|
// 50 % of the smaller side (= 300) capped to [100, 200] → 200.
|
||||||
|
let l = Length::vmin( 50.0 ).clamp( 100.0, 200.0 );
|
||||||
|
assert_eq!( l.resolve( ( 800.0, 600.0 ), 16.0 ), 200.0 );
|
||||||
|
|
||||||
|
// 1 % of the smaller side (= 6) lifted to the min of 50.
|
||||||
|
let l2 = Length::vmin( 1.0 ).clamp( 50.0, 200.0 );
|
||||||
|
assert_eq!( l2.resolve( ( 800.0, 600.0 ), 16.0 ), 50.0 );
|
||||||
|
|
||||||
|
// Caller swapped min/max — resolver tolerates without panic.
|
||||||
|
let l3 = Length::vmin( 50.0 ).clamp( 200.0, 100.0 );
|
||||||
|
assert_eq!( l3.resolve( ( 800.0, 600.0 ), 16.0 ), 200.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn f32_converts_to_px()
|
||||||
|
{
|
||||||
|
let l: Length = 24.0_f32.into();
|
||||||
|
assert_eq!( l.base, super::LengthBase::Px( 24.0 ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ impl<Msg: Clone> Element<Msg>
|
|||||||
Element::Row( r ) => r.preferred_size( max_width, canvas ),
|
Element::Row( r ) => r.preferred_size( max_width, canvas ),
|
||||||
Element::Stack( s ) => s.preferred_size( max_width, canvas ),
|
Element::Stack( s ) => s.preferred_size( max_width, canvas ),
|
||||||
Element::Text( t ) => t.preferred_size( max_width, canvas ),
|
Element::Text( t ) => t.preferred_size( max_width, canvas ),
|
||||||
Element::Spacer( s ) => s.preferred_size(),
|
Element::Spacer( s ) => s.preferred_size( canvas ),
|
||||||
Element::Scroll( s ) => s.preferred_size( max_width, canvas ),
|
Element::Scroll( s ) => s.preferred_size( max_width, canvas ),
|
||||||
Element::Viewport( v ) => v.preferred_size( max_width, canvas ),
|
Element::Viewport( v ) => v.preferred_size( max_width, canvas ),
|
||||||
Element::WrapGrid( g ) => g.preferred_size( max_width, canvas ),
|
Element::WrapGrid( g ) => g.preferred_size( max_width, canvas ),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
|||||||
use fontdue::Font;
|
use fontdue::Font;
|
||||||
|
|
||||||
use crate::theme::FontStyle;
|
use crate::theme::FontStyle;
|
||||||
use crate::types::{ Color, Rect };
|
use crate::types::{ Color, Length, Rect };
|
||||||
use crate::render::Canvas;
|
use crate::render::Canvas;
|
||||||
use super::Element;
|
use super::Element;
|
||||||
|
|
||||||
@@ -24,7 +24,10 @@ pub enum TextAlign
|
|||||||
pub struct Text
|
pub struct Text
|
||||||
{
|
{
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub size: f32,
|
/// Font size as a [`Length`]. Resolved against the surface's logical
|
||||||
|
/// viewport at layout time, so a `Length::Vmin( 5.0 )` heading scales
|
||||||
|
/// with the screen instead of being frozen at a px constant.
|
||||||
|
pub size: Length,
|
||||||
pub color: Color,
|
pub color: Color,
|
||||||
pub align: TextAlign,
|
pub align: TextAlign,
|
||||||
pub wrap: bool,
|
pub wrap: bool,
|
||||||
@@ -47,7 +50,7 @@ impl Text
|
|||||||
Self
|
Self
|
||||||
{
|
{
|
||||||
content: content.into(),
|
content: content.into(),
|
||||||
size: 16.0,
|
size: Length::px( 16.0 ),
|
||||||
color: Color::WHITE,
|
color: Color::WHITE,
|
||||||
align: TextAlign::Left,
|
align: TextAlign::Left,
|
||||||
wrap: false,
|
wrap: false,
|
||||||
@@ -56,6 +59,15 @@ impl Text
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the font size against the canvas viewport. Internal
|
||||||
|
/// helper: every method that needs an `f32` size for a `fontdue`
|
||||||
|
/// call routes through this so the field can stay a `Length`.
|
||||||
|
#[ inline ]
|
||||||
|
fn resolved_size( &self, canvas: &Canvas ) -> f32
|
||||||
|
{
|
||||||
|
self.size.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT )
|
||||||
|
}
|
||||||
|
|
||||||
pub fn no_truncate( mut self ) -> Self
|
pub fn no_truncate( mut self ) -> Self
|
||||||
{
|
{
|
||||||
self.truncate = false;
|
self.truncate = false;
|
||||||
@@ -81,9 +93,9 @@ impl Text
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn size( mut self, s: f32 ) -> Self
|
pub fn size( mut self, s: impl Into<Length> ) -> Self
|
||||||
{
|
{
|
||||||
self.size = s;
|
self.size = s.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,41 +138,49 @@ impl Text
|
|||||||
|
|
||||||
fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc<Font>> ) -> f32
|
fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc<Font>> ) -> f32
|
||||||
{
|
{
|
||||||
|
let size = self.resolved_size( canvas );
|
||||||
match font
|
match font
|
||||||
{
|
{
|
||||||
Some( f ) => canvas.measure_text_with_font( text, self.size, f ),
|
Some( f ) => canvas.measure_text_with_font( text, size, f ),
|
||||||
None => canvas.measure_text( text, self.size ),
|
None => canvas.measure_text( text, size ),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn measure_char( &self, ch: char, canvas: &Canvas, font: Option<&Arc<Font>> ) -> f32
|
fn measure_char( &self, ch: char, canvas: &Canvas, font: Option<&Arc<Font>> ) -> f32
|
||||||
{
|
{
|
||||||
|
let size = self.resolved_size( canvas );
|
||||||
match font
|
match font
|
||||||
{
|
{
|
||||||
Some( f ) => f.metrics( ch, self.size * canvas.dpi_scale() ).advance_width,
|
Some( f ) => f.metrics( ch, size * canvas.dpi_scale() ).advance_width,
|
||||||
None => canvas.font_metrics( ch, self.size ).advance_width,
|
None => canvas.font_metrics( ch, size ).advance_width,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn paint( &self, canvas: &mut Canvas, text: &str, x: f32, y: f32, font: Option<&Arc<Font>> )
|
fn paint( &self, canvas: &mut Canvas, text: &str, x: f32, y: f32, font: Option<&Arc<Font>> )
|
||||||
{
|
{
|
||||||
|
let size = self.resolved_size( canvas );
|
||||||
match font
|
match font
|
||||||
{
|
{
|
||||||
Some( f ) => canvas.draw_text_with_font( text, x, y, self.size, self.color, f ),
|
Some( f ) => canvas.draw_text_with_font( text, x, y, size, self.color, f ),
|
||||||
None => canvas.draw_text( text, x, y, self.size, self.color ),
|
None => canvas.draw_text( text, x, y, size, self.color ),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
|
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
|
||||||
{
|
{
|
||||||
let line_h = canvas.font_line_metrics( self.size )
|
let size = self.resolved_size( canvas );
|
||||||
.map( |m| m.ascent - m.descent )
|
// `new_line_size = ascent - descent + line_gap` is the standard
|
||||||
.unwrap_or( self.size );
|
// typographic line height; the previous `ascent - descent` discarded
|
||||||
|
// the font-declared leading and at large sizes left adjacent rows
|
||||||
|
// visibly overlapping when stacked tight in a column.
|
||||||
|
let line_h = canvas.font_line_metrics( size )
|
||||||
|
.map( |m| m.new_line_size )
|
||||||
|
.unwrap_or( size );
|
||||||
let font = self.resolve_font( canvas );
|
let font = self.resolve_font( canvas );
|
||||||
|
|
||||||
if self.wrap
|
if self.wrap
|
||||||
{
|
{
|
||||||
let lines = wrap_lines( &self.content, self.size, max_width, canvas, font.as_ref() );
|
let lines = wrap_lines( &self.content, size, max_width, canvas, font.as_ref() );
|
||||||
let h = line_h * lines.len().max( 1 ) as f32;
|
let h = line_h * lines.len().max( 1 ) as f32;
|
||||||
( max_width, h )
|
( max_width, h )
|
||||||
}
|
}
|
||||||
@@ -173,17 +193,18 @@ impl Text
|
|||||||
|
|
||||||
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool )
|
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool )
|
||||||
{
|
{
|
||||||
let ascent = canvas.font_line_metrics( self.size )
|
let size = self.resolved_size( canvas );
|
||||||
|
let ascent = canvas.font_line_metrics( size )
|
||||||
.map( |m| m.ascent )
|
.map( |m| m.ascent )
|
||||||
.unwrap_or( self.size * 0.8 );
|
.unwrap_or( size * 0.8 );
|
||||||
let line_h = canvas.font_line_metrics( self.size )
|
let line_h = canvas.font_line_metrics( size )
|
||||||
.map( |m| m.ascent - m.descent )
|
.map( |m| m.new_line_size )
|
||||||
.unwrap_or( self.size );
|
.unwrap_or( size );
|
||||||
let font = self.resolve_font( canvas );
|
let font = self.resolve_font( canvas );
|
||||||
|
|
||||||
if self.wrap
|
if self.wrap
|
||||||
{
|
{
|
||||||
let lines = wrap_lines( &self.content, self.size, rect.width, canvas, font.as_ref() );
|
let lines = wrap_lines( &self.content, size, rect.width, canvas, font.as_ref() );
|
||||||
for ( i, line ) in lines.iter().enumerate()
|
for ( i, line ) in lines.iter().enumerate()
|
||||||
{
|
{
|
||||||
let line_w = self.measure( line, canvas, font.as_ref() );
|
let line_w = self.measure( line, canvas, font.as_ref() );
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ fn new_uses_documented_defaults()
|
|||||||
{
|
{
|
||||||
let t = Text::new( "hello" );
|
let t = Text::new( "hello" );
|
||||||
assert_eq!( t.content, "hello" );
|
assert_eq!( t.content, "hello" );
|
||||||
assert_eq!( t.size, 16.0 );
|
assert_eq!( t.size, crate::Length::px( 16.0 ) );
|
||||||
assert_eq!( t.color, Color::WHITE );
|
assert_eq!( t.color, Color::WHITE );
|
||||||
assert_eq!( t.align, TextAlign::Left );
|
assert_eq!( t.align, TextAlign::Left );
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,14 @@ fn new_uses_documented_defaults()
|
|||||||
fn size_builder_overrides_default()
|
fn size_builder_overrides_default()
|
||||||
{
|
{
|
||||||
let t = Text::new( "" ).size( 32.0 );
|
let t = Text::new( "" ).size( 32.0 );
|
||||||
assert_eq!( t.size, 32.0 );
|
assert_eq!( t.size, crate::Length::px( 32.0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn size_builder_accepts_length()
|
||||||
|
{
|
||||||
|
let t = Text::new( "" ).size( crate::Length::vmin( 5.0 ) );
|
||||||
|
assert_eq!( t.size.base, crate::LengthBase::Vmin( 5.0 ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ test ]
|
#[ test ]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// the way `Spacer` distributes leftover space inside a `Column`.
|
// the way `Spacer` distributes leftover space inside a `Column`.
|
||||||
|
|
||||||
use ltk::core::Canvas;
|
use ltk::core::Canvas;
|
||||||
use ltk::{ stack, spacer, HAlign, VAlign, Rect };
|
use ltk::{ stack, spacer, HAlign, Length, VAlign, Rect };
|
||||||
|
|
||||||
fn make_canvas() -> Canvas
|
fn make_canvas() -> Canvas
|
||||||
{
|
{
|
||||||
@@ -181,29 +181,32 @@ fn stack_empty_preferred_size_height_is_zero()
|
|||||||
#[ test ]
|
#[ test ]
|
||||||
fn spacer_default_has_weight_one_and_no_fixed_size()
|
fn spacer_default_has_weight_one_and_no_fixed_size()
|
||||||
{
|
{
|
||||||
|
let canvas = make_canvas();
|
||||||
let s = spacer();
|
let s = spacer();
|
||||||
assert_eq!( s.weight, 1 );
|
assert_eq!( s.weight, 1 );
|
||||||
assert!( s.fixed_height.is_none() );
|
assert!( s.fixed_height.is_none() );
|
||||||
assert!( s.fixed_width.is_none() );
|
assert!( s.fixed_width.is_none() );
|
||||||
assert_eq!( s.preferred_size(), ( 0.0, 0.0 ) );
|
assert_eq!( s.preferred_size( &canvas ), ( 0.0, 0.0 ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ test ]
|
#[ test ]
|
||||||
fn spacer_height_pins_vertical_axis_only()
|
fn spacer_height_pins_vertical_axis_only()
|
||||||
{
|
{
|
||||||
|
let canvas = make_canvas();
|
||||||
let s = spacer().height( 24.0 );
|
let s = spacer().height( 24.0 );
|
||||||
assert_eq!( s.fixed_height, Some( 24.0 ) );
|
assert_eq!( s.fixed_height, Some( Length::px( 24.0 ) ) );
|
||||||
assert!( s.fixed_width.is_none() );
|
assert!( s.fixed_width.is_none() );
|
||||||
assert_eq!( s.preferred_size(), ( 0.0, 24.0 ) );
|
assert_eq!( s.preferred_size( &canvas ), ( 0.0, 24.0 ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ test ]
|
#[ test ]
|
||||||
fn spacer_width_pins_horizontal_axis_only()
|
fn spacer_width_pins_horizontal_axis_only()
|
||||||
{
|
{
|
||||||
|
let canvas = make_canvas();
|
||||||
let s = spacer().width( 12.0 );
|
let s = spacer().width( 12.0 );
|
||||||
assert_eq!( s.fixed_width, Some( 12.0 ) );
|
assert_eq!( s.fixed_width, Some( Length::px( 12.0 ) ) );
|
||||||
assert!( s.fixed_height.is_none() );
|
assert!( s.fixed_height.is_none() );
|
||||||
assert_eq!( s.preferred_size(), ( 12.0, 0.0 ) );
|
assert_eq!( s.preferred_size( &canvas ), ( 12.0, 0.0 ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ test ]
|
#[ test ]
|
||||||
|
|||||||
Reference in New Issue
Block a user