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.
656 lines
20 KiB
Rust
656 lines
20 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
|
|
|
//! Geometry and primitive value types used across the public API.
|
|
//!
|
|
//! These are the cheap, copy-friendly types that flow through every
|
|
//! widget builder, layout method and runtime hook:
|
|
//!
|
|
//! - [`Color`] — RGBA in `[0.0, 1.0]` floats; `Color::WHITE`,
|
|
//! `Color::BLACK`, `Color::TRANSPARENT` constants and a `Color::hex(r, g, b)`
|
|
//! constructor for byte literals.
|
|
//! - [`Rect`] — axis-aligned `(x, y, width, height)`; the universal
|
|
//! layout / hit-test currency.
|
|
//! - [`Point`] — a 2D point used by hit testing and gesture progress.
|
|
//! - [`Size`] — a `(width, height)` pair without an origin.
|
|
//! - [`Corners`] — per-corner radius for the
|
|
//! [`Container`](crate::container()) widget and any other rounded
|
|
//! surface; coerces from `f32` for the uniform case.
|
|
//! - [`WidgetId`] — a stable `&'static str` identifier for focus
|
|
//! management, paired with [`crate::App::take_focus_request`].
|
|
//!
|
|
//! Every type is `Copy` (or `Clone`) so passing them by value is the
|
|
//! default. The crate root re-exports them all (`ltk::Color`,
|
|
//! `ltk::Rect`, …) so application code rarely needs the `ltk::types::`
|
|
//! prefix.
|
|
|
|
/// An RGBA color with floating-point channels in the range `[0.0, 1.0]`.
|
|
#[ derive( Debug, Clone, Copy, PartialEq ) ]
|
|
pub struct Color
|
|
{
|
|
/// Red channel `[0.0, 1.0]`.
|
|
pub r: f32,
|
|
/// Green channel `[0.0, 1.0]`.
|
|
pub g: f32,
|
|
/// Blue channel `[0.0, 1.0]`.
|
|
pub b: f32,
|
|
/// Alpha channel — `0.0` is fully transparent, `1.0` is fully opaque.
|
|
pub a: f32,
|
|
}
|
|
|
|
impl Color
|
|
{
|
|
/// Fully opaque white.
|
|
pub const WHITE: Self = Self { r: 1., g: 1., b: 1., a: 1. };
|
|
/// Fully opaque black.
|
|
pub const BLACK: Self = Self { r: 0., g: 0., b: 0., a: 1. };
|
|
/// Fully transparent black.
|
|
pub const TRANSPARENT: Self = Self { r: 0., g: 0., b: 0., a: 0. };
|
|
|
|
/// Create an opaque color from 8-bit `r`, `g`, `b` components.
|
|
pub const fn hex( r: u8, g: u8, b: u8 ) -> Self
|
|
{
|
|
Self { r: r as f32 / 255.0, g: g as f32 / 255.0, b: b as f32 / 255.0, a: 1.0 }
|
|
}
|
|
|
|
/// Create an opaque color from float `r`, `g`, `b` components in `[0.0, 1.0]`.
|
|
pub fn rgb( r: f32, g: f32, b: f32 ) -> Self
|
|
{
|
|
Self { r, g, b, a: 1. }
|
|
}
|
|
|
|
/// Create a color from float `r`, `g`, `b`, `a` components in `[0.0, 1.0]`.
|
|
pub fn rgba( r: f32, g: f32, b: f32, a: f32 ) -> Self
|
|
{
|
|
Self { r, g, b, a }
|
|
}
|
|
|
|
/// Convert to a [`tiny_skia::Color`] for rendering.
|
|
pub fn to_tiny_skia( self ) -> tiny_skia::Color
|
|
{
|
|
tiny_skia::Color::from_rgba( self.r, self.g, self.b, self.a )
|
|
.unwrap_or( tiny_skia::Color::BLACK )
|
|
}
|
|
}
|
|
|
|
/// A 2-D point in screen coordinates (pixels, top-left origin).
|
|
#[ derive( Debug, Clone, Copy, PartialEq, Default ) ]
|
|
pub struct Point
|
|
{
|
|
/// Horizontal position in pixels.
|
|
pub x: f32,
|
|
/// Vertical position in pixels.
|
|
pub y: f32,
|
|
}
|
|
|
|
/// A width/height pair in pixels.
|
|
#[ derive( Debug, Clone, Copy, PartialEq, Default ) ]
|
|
pub struct Size
|
|
{
|
|
/// Width in pixels.
|
|
pub width: f32,
|
|
/// Height in pixels.
|
|
pub height: f32,
|
|
}
|
|
|
|
/// An axis-aligned rectangle in screen coordinates.
|
|
#[ derive( Debug, Clone, Copy, PartialEq, Default ) ]
|
|
pub struct Rect
|
|
{
|
|
/// Left edge in pixels.
|
|
pub x: f32,
|
|
/// Top edge in pixels.
|
|
pub y: f32,
|
|
/// Width in pixels.
|
|
pub width: f32,
|
|
/// Height in pixels.
|
|
pub height: f32,
|
|
}
|
|
|
|
impl Rect
|
|
{
|
|
/// Returns `true` if `p` lies inside or on the boundary of this rect.
|
|
pub fn contains( &self, p: Point ) -> bool
|
|
{
|
|
p.x >= self.x
|
|
&& p.x <= self.x + self.width
|
|
&& p.y >= self.y
|
|
&& p.y <= self.y + self.height
|
|
}
|
|
|
|
/// Returns a new rect grown by `amount` pixels on every side.
|
|
pub fn expand( &self, amount: f32 ) -> Self
|
|
{
|
|
Self
|
|
{
|
|
x: self.x - amount,
|
|
y: self.y - amount,
|
|
width: self.width + amount * 2.0,
|
|
height: self.height + amount * 2.0,
|
|
}
|
|
}
|
|
|
|
/// Convert to [`tiny_skia::Rect`], returning `None` if dimensions are non-positive.
|
|
pub fn to_tiny_skia( &self ) -> Option<tiny_skia::Rect>
|
|
{
|
|
tiny_skia::Rect::from_xywh( self.x, self.y, self.width, self.height )
|
|
}
|
|
}
|
|
|
|
/// Per-corner radii for a rounded rect, ordered top-left → top-right →
|
|
/// bottom-right → bottom-left (clockwise from top-left, matching CSS
|
|
/// `border-radius`'s long form). All four values are independent
|
|
/// pixel radii — set any subset to `0.0` for a square corner, or use
|
|
/// the [`top`](Self::top), [`bottom`](Self::bottom),
|
|
/// [`left`](Self::left), [`right`](Self::right) shortcuts for the
|
|
/// common asymmetric cases.
|
|
///
|
|
/// The renderer caps each corner against the inscribed-circle limit
|
|
/// `min(width, height) / 2`, mirroring tiny-skia / browser behaviour:
|
|
/// passing absurdly large values is a "make this side a pill" idiom
|
|
/// rather than an error.
|
|
///
|
|
/// `f32` and `(f32, f32, f32, f32)` both convert via [`From`] so any
|
|
/// API taking `impl Into<Corners>` accepts a uniform radius literal
|
|
/// (`.radius( 16.0 )`), an explicit set (`.radius( ( 16.0, 16.0,
|
|
/// 0.0, 0.0 ) )`), or a constructed value (`.radius( Corners::top(
|
|
/// 16.0 ) )`) interchangeably.
|
|
#[ derive( Debug, Clone, Copy, PartialEq, Default ) ]
|
|
pub struct Corners
|
|
{
|
|
/// Top-left corner radius in pixels.
|
|
pub tl: f32,
|
|
/// Top-right corner radius in pixels.
|
|
pub tr: f32,
|
|
/// Bottom-right corner radius in pixels.
|
|
pub br: f32,
|
|
/// Bottom-left corner radius in pixels.
|
|
pub bl: f32,
|
|
}
|
|
|
|
impl Corners
|
|
{
|
|
/// All four corners square (radius `0`).
|
|
pub const ZERO: Self = Self { tl: 0.0, tr: 0.0, br: 0.0, bl: 0.0 };
|
|
|
|
/// Uniform radius on every corner — equivalent to `r.into()` and
|
|
/// the most common construction.
|
|
pub const fn all( r: f32 ) -> Self
|
|
{
|
|
Self { tl: r, tr: r, br: r, bl: r }
|
|
}
|
|
|
|
/// Rounded top corners, square bottom corners. Matches the CSS
|
|
/// shorthand `border-radius: r r 0 0` and the typical "card sits
|
|
/// flush against the bottom of the screen" pattern (docks,
|
|
/// bottom-anchored modals).
|
|
pub const fn top( r: f32 ) -> Self
|
|
{
|
|
Self { tl: r, tr: r, br: 0.0, bl: 0.0 }
|
|
}
|
|
|
|
/// Rounded bottom corners, square top corners. Mirror of
|
|
/// [`top`](Self::top) for top-anchored chrome.
|
|
pub const fn bottom( r: f32 ) -> Self
|
|
{
|
|
Self { tl: 0.0, tr: 0.0, br: r, bl: r }
|
|
}
|
|
|
|
/// Rounded left corners, square right corners.
|
|
pub const fn left( r: f32 ) -> Self
|
|
{
|
|
Self { tl: r, tr: 0.0, br: 0.0, bl: r }
|
|
}
|
|
|
|
/// Rounded right corners, square left corners.
|
|
pub const fn right( r: f32 ) -> Self
|
|
{
|
|
Self { tl: 0.0, tr: r, br: r, bl: 0.0 }
|
|
}
|
|
|
|
/// `true` when every corner is `<= 0` — the renderer can take
|
|
/// the fast straight-rect path.
|
|
pub fn is_zero( &self ) -> bool
|
|
{
|
|
self.tl <= 0.0 && self.tr <= 0.0 && self.br <= 0.0 && self.bl <= 0.0
|
|
}
|
|
|
|
/// `true` when every corner has the same radius. Used by the
|
|
/// software path to fall back to the single-radius cubic builder
|
|
/// when the asymmetric path would produce an identical curve.
|
|
pub fn is_uniform( &self ) -> bool
|
|
{
|
|
self.tl == self.tr && self.tr == self.br && self.br == self.bl
|
|
}
|
|
|
|
/// The largest of the four radii. Useful for sizing the shader
|
|
/// quad's anti-alias pad — the worst-case AA band has to cover
|
|
/// the steepest curve.
|
|
pub fn max( &self ) -> f32
|
|
{
|
|
self.tl.max( self.tr ).max( self.br ).max( self.bl )
|
|
}
|
|
|
|
/// Cap every corner to `min(width, height) / 2`, the inscribed-
|
|
/// circle limit a rounded box can't exceed without degenerating.
|
|
/// Mirrors the clamp the GLES shader applies internally; software
|
|
/// path callers use it before building the path so the cubic
|
|
/// control points stay inside the rect.
|
|
pub fn clamp_to_size( &self, width: f32, height: f32 ) -> Self
|
|
{
|
|
let cap = ( width.min( height ) * 0.5 ).max( 0.0 );
|
|
Self
|
|
{
|
|
tl: self.tl.min( cap ).max( 0.0 ),
|
|
tr: self.tr.min( cap ).max( 0.0 ),
|
|
br: self.br.min( cap ).max( 0.0 ),
|
|
bl: self.bl.min( cap ).max( 0.0 ),
|
|
}
|
|
}
|
|
|
|
/// Pack as `[ tl, tr, br, bl ]` for `glUniform4fv`. Order
|
|
/// matches the `vec4 u_radii` convention every fragment shader
|
|
/// in `gles_render::shaders` reads.
|
|
pub fn to_uniform( &self ) -> [ f32; 4 ]
|
|
{
|
|
[ self.tl, self.tr, self.br, self.bl ]
|
|
}
|
|
}
|
|
|
|
impl From<f32> for Corners
|
|
{
|
|
fn from( r: f32 ) -> Self { Self::all( r ) }
|
|
}
|
|
|
|
impl From<( f32, f32, f32, f32 )> for Corners
|
|
{
|
|
/// Tuple form, ordered `( tl, tr, br, bl )` — matches CSS shorthand.
|
|
fn from( t: ( f32, f32, f32, f32 ) ) -> Self
|
|
{
|
|
Self { tl: t.0, tr: t.1, br: t.2, bl: t.3 }
|
|
}
|
|
}
|
|
|
|
/// A stable widget identifier used for focus management.
|
|
///
|
|
/// Assign an id to a widget with `.id( WidgetId("my_widget") )`, then request
|
|
/// focus via [`App::take_focus_request`](crate::app::App::take_focus_request).
|
|
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
|
|
pub struct WidgetId( pub &'static str );
|
|
|
|
/// Pointer cursor shape, sent to the compositor via
|
|
/// `wp_cursor_shape_v1` when the pointer enters a widget that
|
|
/// declares one. Mirrors `cursor_icon::CursorIcon` 1:1 so the
|
|
/// runtime can convert losslessly. Compositors that do not advertise
|
|
/// `wp_cursor_shape_v1` ignore these — the user sees their default
|
|
/// system cursor.
|
|
#[ derive( Debug, Clone, Copy, PartialEq, Eq, Hash ) ]
|
|
pub enum CursorShape
|
|
{
|
|
Default,
|
|
ContextMenu,
|
|
Help,
|
|
/// "Hand" — clickable buttons, links.
|
|
Pointer,
|
|
/// "Spinning wheel" — work in progress, you can still interact.
|
|
Progress,
|
|
/// "Hourglass" — UI is busy and unresponsive.
|
|
Wait,
|
|
Cell,
|
|
Crosshair,
|
|
/// I-beam — text input fields.
|
|
Text,
|
|
VerticalText,
|
|
Alias,
|
|
Copy,
|
|
Move,
|
|
NoDrop,
|
|
NotAllowed,
|
|
/// Open hand — draggable but not yet dragging.
|
|
Grab,
|
|
/// Closed hand — currently dragging.
|
|
Grabbing,
|
|
EResize,
|
|
NResize,
|
|
NeResize,
|
|
NwResize,
|
|
SResize,
|
|
SeResize,
|
|
SwResize,
|
|
WResize,
|
|
EwResize,
|
|
NsResize,
|
|
NeswResize,
|
|
NwseResize,
|
|
ColResize,
|
|
RowResize,
|
|
AllScroll,
|
|
ZoomIn,
|
|
ZoomOut,
|
|
}
|
|
|
|
impl Default for CursorShape
|
|
{
|
|
fn default() -> Self { CursorShape::Default }
|
|
}
|
|
|
|
#[ cfg( test ) ]
|
|
mod tests
|
|
{
|
|
use super::*;
|
|
|
|
// ── Color ─────────────────────────────────────────────────────────────────
|
|
|
|
#[ test ]
|
|
fn color_hex_sets_rgb_and_full_alpha()
|
|
{
|
|
let c = Color::hex( 0xFF, 0x00, 0x80 );
|
|
assert!( ( c.r - 1.0 ).abs() < 1e-3 );
|
|
assert!( ( c.g - 0.0 ).abs() < 1e-6 );
|
|
assert!( ( c.b - 0x80 as f32 / 255.0 ).abs() < 1e-3 );
|
|
assert_eq!( c.a, 1.0 );
|
|
}
|
|
|
|
#[ test ]
|
|
fn color_rgba_stores_all_channels()
|
|
{
|
|
let c = Color::rgba( 0.1, 0.2, 0.3, 0.4 );
|
|
assert!( ( c.r - 0.1 ).abs() < 1e-6 );
|
|
assert!( ( c.g - 0.2 ).abs() < 1e-6 );
|
|
assert!( ( c.b - 0.3 ).abs() < 1e-6 );
|
|
assert!( ( c.a - 0.4 ).abs() < 1e-6 );
|
|
}
|
|
|
|
#[ test ]
|
|
fn color_white_constant_is_all_ones()
|
|
{
|
|
let c = Color::WHITE;
|
|
assert_eq!( c.r, 1. );
|
|
assert_eq!( c.g, 1. );
|
|
assert_eq!( c.b, 1. );
|
|
assert_eq!( c.a, 1. );
|
|
}
|
|
|
|
#[ test ]
|
|
fn color_transparent_has_zero_alpha()
|
|
{
|
|
assert_eq!( Color::TRANSPARENT.a, 0. );
|
|
}
|
|
|
|
#[ test ]
|
|
fn color_rgb_sets_full_alpha()
|
|
{
|
|
let c = Color::rgb( 0.5, 0.5, 0.5 );
|
|
assert_eq!( c.a, 1.0 );
|
|
}
|
|
|
|
// ── Rect ──────────────────────────────────────────────────────────────────
|
|
|
|
#[ test ]
|
|
fn rect_contains_interior_point()
|
|
{
|
|
let r = Rect { x: 10., y: 20., width: 100., height: 50. };
|
|
assert!( r.contains( Point { x: 60., y: 45. } ) );
|
|
}
|
|
|
|
#[ test ]
|
|
fn rect_contains_boundary_points()
|
|
{
|
|
let r = Rect { x: 0., y: 0., width: 100., height: 100. };
|
|
assert!( r.contains( Point { x: 0., y: 0. } ) );
|
|
assert!( r.contains( Point { x: 100., y: 100. } ) );
|
|
}
|
|
|
|
#[ test ]
|
|
fn rect_does_not_contain_exterior_points()
|
|
{
|
|
let r = Rect { x: 10., y: 20., width: 100., height: 50. };
|
|
assert!( !r.contains( Point { x: 5., y: 45. } ) );
|
|
assert!( !r.contains( Point { x: 60., y: 5. } ) );
|
|
assert!( !r.contains( Point { x: 200., y: 45. } ) );
|
|
assert!( !r.contains( Point { x: 60., y: 80. } ) );
|
|
}
|
|
|
|
#[ test ]
|
|
fn rect_expand_grows_in_all_directions()
|
|
{
|
|
let r = Rect { x: 10., y: 10., width: 80., height: 40. };
|
|
let e = r.expand( 5. );
|
|
assert_eq!( e.x, 5. );
|
|
assert_eq!( e.y, 5. );
|
|
assert_eq!( e.width, 90. );
|
|
assert_eq!( e.height, 50. );
|
|
}
|
|
|
|
#[ test ]
|
|
fn rect_expand_zero_is_identity()
|
|
{
|
|
let r = Rect { x: 1., y: 2., width: 3., height: 4. };
|
|
let e = r.expand( 0. );
|
|
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 ) );
|
|
}
|
|
}
|