Documentation pass: every claim in docs/ and the meta files was audited against the source and the drift fixed — around ninety corrections. CONTRIBUTING and the CI workflow now run cargo test with --features test-support (the gated test_support module made both the documented commands and the CI build fail to compile), make example becomes make examples, make doctest-md and the debhelper requirement of make clean are documented, and patch shape asks for a CHANGELOG entry. theming.md loses the nonexistent surface.backdrop, gains the real gradient defaults (linear-rgb, oklab), the six slot variants including typography, the ten-field palette, a truthful effects-consumer table, the ThemePreference/from_hour API and a responsive-sizing note; the stale docstrings in src/theme that fed the drift are fixed too. architecture.md's "Known gaps" section is rewritten against reality (multi-touch slots, xdg-activation, a11y live regions and SetValue/Increment/Decrement are implemented), gains a module map, subsurfaces and window-lifecycle coverage, and correct crustace/loginmanager paths. widgets.md fixes the ten factual errors (stateless spinner, toast/combo via overlays(), tooltip hover contract, row has no max_width, scroll axes, multiline text_edit, dialog panic wording) and now states the column() 16 px default padding — the recurring ambush — plus row's differing 0 default and dialog's max_width. onboarding, README and cookbook get the remaining sweep: build/test instructions, complete example lists, img_widget, clipping-parity honesty, ~30 Hz software cap, read_rgba_pixels signature, tab indentation in snippets, and rustdoc-style links that rendered literally are gone everywhere. CHANGELOG is restructured per Keep a Changelog with the missing entries (window_resizable, claims_raw_touch, Row::align_top/fill_height, caret fixes, dependency pins) and the pad_v Added/Changed contradiction resolved. New adaptive-layout API: ltk::orientation() with the Orientation enum, backed by viewport_size()/set_viewport_size — the runtime records the main surface's physical dimensions on every configure, before App::on_resize, so view() can branch a layout on portrait vs landscape without hand-tracking resizes. The portrait rule matches Length::orient (square counts as portrait); embedders driving core::UiSurface call set_viewport_size themselves. Documented in the crate root's responsive-design section and architecture.md. Fluid-vs-fixed sizing fixes in widgets, all the same disease — fluid content inside a fixed-pixel box. TextEdit::fixed_width takes impl Into<Length> (f32 call sites keep compiling as px) and the time picker's digit fields move to Length::fluid( 72.0 ), matching their fluid font so digits can no longer outgrow the box. Dialog::max_width takes impl Into<Length> with a Length::fluid( 480.0 ) default so the card scales with the stock buttons inside it, and the card's interior no longer stacks the column() default 16 px padding on top of CARD_PADDING — that double inset squeezed the action row until its buttons clipped on narrow windows. App::on_pointer_axis now triggers a view rebuild and repaint; previously state mutated in the hook did not paint until the next unrelated event. Examples reworked to be honest demos: responsive's mode/density controls become stock buttons in a grid/column so they follow the modes they demonstrate instead of overflowing; dialog's openers stack vertically, and the example gains the app-level ESC handler so the ESC chain closes an open dialog first and quits second; widgets' tab strip now switches real per-tab pages; carousel gains pointer/touch drag through the horizontal-swipe hooks (crustace's pager pattern), one-tile-per-detent mouse wheel, and snap math driven by the real surface width from on_resize instead of a hardcoded 800; clip_path arranges its cells by ltk::orientation() and sizes them from the counter-axis of the flow.
943 lines
31 KiB
Rust
943 lines
31 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.
|
||
|
||
use std::sync::atomic::{ AtomicU8, AtomicU32, Ordering };
|
||
|
||
/// 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 }
|
||
}
|
||
}
|
||
|
||
/// One command of a vector path, in canvas (surface) coordinates. Fed to
|
||
/// [`Canvas::fill_path`](crate::Canvas::fill_path) / `stroke_path` to render
|
||
/// arbitrary shapes (e.g. an Android `Path` / a Lottie frame).
|
||
#[ derive( Clone, Copy, Debug, PartialEq ) ]
|
||
pub enum PathCmd
|
||
{
|
||
MoveTo( f32, f32 ),
|
||
LineTo( f32, f32 ),
|
||
QuadTo( f32, f32, f32, f32 ),
|
||
CubicTo( f32, f32, f32, f32, f32, f32 ),
|
||
Close,
|
||
}
|
||
|
||
/// 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 ),
|
||
/// Orientation-dependent percentage of the viewport's **short** side,
|
||
/// with a different proportion per orientation. In portrait (width ≤
|
||
/// height) it resolves to `portrait` % of the **width**; in landscape
|
||
/// (width > height) to `landscape` % of the **height**. Both axes are
|
||
/// the short side of their orientation, but the design proportion
|
||
/// differs — e.g. a logo that wants 40 % of the width when there is
|
||
/// vertical room to spare, but only 5 % of the (scarce) height when
|
||
/// laid out landscape.
|
||
Orient { portrait: f32, landscape: 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::Orient { portrait, landscape } =>
|
||
{
|
||
if vw <= vh
|
||
{
|
||
vw * portrait / 100.0
|
||
} else {
|
||
vh * landscape / 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 ) ) }
|
||
|
||
/// Orientation-aware size: `portrait` % of the **width** when the
|
||
/// viewport is portrait, `landscape` % of the **height** when it is
|
||
/// landscape. See [`LengthBase::Orient`]. Chain `.clamp( lo, hi )` to
|
||
/// bound the result in px as with any relative length.
|
||
pub const fn orient( portrait: f32, landscape: f32 ) -> Self
|
||
{
|
||
Self::from_base( LengthBase::Orient { portrait, landscape } )
|
||
}
|
||
|
||
/// **Fluid** design pixel (the [`WidgetScaling::Fluid`] mode). `px` is
|
||
/// the size at the reference surface set via [`set_fluid_reference`]
|
||
/// (defaults to 412 px — the eydos mobile reference width); the value
|
||
/// then scales as a fraction of the surface's **short** side (width in
|
||
/// portrait, height in landscape) and is auto-clamped to
|
||
/// `[px * `[`FLUID_MIN`]`, px * `[`FLUID_MAX`]`]` so it neither
|
||
/// collapses on a tiny surface nor balloons on a 4K one. A single
|
||
/// design number therefore yields a surface-proportional size with no
|
||
/// per-call percentages — this is how stock widgets stay fluid by
|
||
/// default. For explicit control use [`Length::vmin`] /
|
||
/// [`Length::orient`] with your own [`Length::clamp`].
|
||
pub fn fluid( px: f32 ) -> Self
|
||
{
|
||
let r = fluid_reference();
|
||
Length::vmin( px / r * 100.0 ).clamp( px * FLUID_MIN, px * FLUID_MAX )
|
||
}
|
||
|
||
/// **Density-independent** pixel (the [`WidgetScaling::Physical`] mode).
|
||
/// `px` is multiplied by the process [`density`] (derived from the
|
||
/// output's DPI, or set with [`set_density`]) to yield a **constant
|
||
/// physical size** across displays — the mainstream `dp` of Android /
|
||
/// Flutter / CSS. Unlike [`Length::fluid`] it does **not** scale with
|
||
/// the surface size, only with pixel density. Density defaults to
|
||
/// `1.0`, so `dp( n )` == `n` px until a density is set.
|
||
pub fn dp( px: f32 ) -> Self
|
||
{
|
||
Length::px( px * density() )
|
||
}
|
||
|
||
/// Resolve a stock-widget design pixel through the process-wide
|
||
/// [`widget_scaling`] mode: [`Length::fluid`] in [`WidgetScaling::Fluid`]
|
||
/// (the default), [`Length::dp`] in [`WidgetScaling::Physical`]. Widgets
|
||
/// route their intrinsic geometry / font constants through this (see
|
||
/// [`crate::Canvas::geom_px`] / [`crate::Canvas::font_px`]) so a single
|
||
/// process-level switch picks the adaptation strategy for every stock
|
||
/// widget at once, while explicit [`Length`] overrides still win.
|
||
pub fn widget( px: f32 ) -> Self
|
||
{
|
||
match widget_scaling()
|
||
{
|
||
WidgetScaling::Fluid => Length::fluid( px ),
|
||
WidgetScaling::Physical => Length::dp( px ),
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|
||
|
||
/// Lower auto-clamp factor of [`Length::fluid`]: a fluid value never
|
||
/// resolves below `px * FLUID_MIN`, so it stays usable on a tiny surface.
|
||
pub const FLUID_MIN: f32 = 0.7;
|
||
/// Upper auto-clamp factor of [`Length::fluid`]: a fluid value never
|
||
/// resolves above `px * FLUID_MAX`, so it stays tasteful on a huge surface.
|
||
pub const FLUID_MAX: f32 = 1.5;
|
||
|
||
static FLUID_REFERENCE_BITS: AtomicU32 = AtomicU32::new( 412.0_f32.to_bits() );
|
||
|
||
/// Set the reference surface (short-side px) that [`Length::fluid`]
|
||
/// interprets its design pixels against. Call once at startup (e.g. before
|
||
/// [`crate::run`]) to align the fluid scale to the surface mock-up the app
|
||
/// was designed for. Default: 412 px.
|
||
pub fn set_fluid_reference( reference_vmin: f32 )
|
||
{
|
||
FLUID_REFERENCE_BITS.store( reference_vmin.to_bits(), Ordering::Relaxed );
|
||
}
|
||
|
||
/// Current reference used by [`Length::fluid`] — the short-side px at which
|
||
/// `fluid( n )` resolves to `n` px before clamping.
|
||
pub fn fluid_reference() -> f32
|
||
{
|
||
f32::from_bits( FLUID_REFERENCE_BITS.load( Ordering::Relaxed ) )
|
||
}
|
||
|
||
static DENSITY_BITS: AtomicU32 = AtomicU32::new( 1.0_f32.to_bits() );
|
||
|
||
/// Set the process-wide pixel density used by [`Length::dp`] (the
|
||
/// [`WidgetScaling::Physical`] mode). Typically derived from the output's
|
||
/// physical DPI so `dp` sizes stay physically constant across displays.
|
||
/// Default: `1.0`.
|
||
pub fn set_density( d: f32 )
|
||
{
|
||
DENSITY_BITS.store( d.max( 0.0 ).to_bits(), Ordering::Relaxed );
|
||
}
|
||
|
||
/// Current pixel density — the factor [`Length::dp`] multiplies its design
|
||
/// pixels by. `1.0` until [`set_density`] is called.
|
||
pub fn density() -> f32
|
||
{
|
||
f32::from_bits( DENSITY_BITS.load( Ordering::Relaxed ) )
|
||
}
|
||
|
||
/// Orientation of the main surface, derived from the dimensions recorded
|
||
/// by [`set_viewport_size`].
|
||
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
|
||
pub enum Orientation
|
||
{
|
||
Portrait,
|
||
Landscape,
|
||
}
|
||
|
||
static VIEWPORT_W: AtomicU32 = AtomicU32::new( 0 );
|
||
static VIEWPORT_H: AtomicU32 = AtomicU32::new( 0 );
|
||
|
||
/// Record the main surface's physical dimensions. The runtime calls this
|
||
/// on every configure, before `App::on_resize`; embedders driving
|
||
/// [`core::UiSurface`](crate::core::UiSurface) directly should call it
|
||
/// themselves if they want [`viewport_size`] / [`orientation`] to reflect
|
||
/// their surface.
|
||
pub fn set_viewport_size( width: u32, height: u32 )
|
||
{
|
||
VIEWPORT_W.store( width, Ordering::Relaxed );
|
||
VIEWPORT_H.store( height, Ordering::Relaxed );
|
||
}
|
||
|
||
/// Physical dimensions of the main surface as of the last configure.
|
||
/// `( 0, 0 )` before the first one.
|
||
pub fn viewport_size() -> ( u32, u32 )
|
||
{
|
||
( VIEWPORT_W.load( Ordering::Relaxed ), VIEWPORT_H.load( Ordering::Relaxed ) )
|
||
}
|
||
|
||
/// Orientation of the main surface: [`Orientation::Landscape`] when wider
|
||
/// than tall, [`Orientation::Portrait`] otherwise (square counts as
|
||
/// portrait, matching [`Length::orient`]'s resolution rule). Usable
|
||
/// straight from `view()` to pick a row or a column arrangement without
|
||
/// tracking `on_resize` by hand — the runtime rebuilds the view on every
|
||
/// resize, so a layout branched on this follows the window live.
|
||
pub fn orientation() -> Orientation
|
||
{
|
||
let ( w, h ) = viewport_size();
|
||
if w > h { Orientation::Landscape } else { Orientation::Portrait }
|
||
}
|
||
|
||
/// How a stock widget adapts its intrinsic geometry to the display when the
|
||
/// app does not override it. The two modes ltk offers, chosen per process
|
||
/// with [`set_widget_scaling`]:
|
||
///
|
||
/// - [`WidgetScaling::Fluid`] — sizes scale as a fraction of the surface
|
||
/// (via [`Length::fluid`]): the design breathes with the screen, and a
|
||
/// size tracks the **short** side (width in portrait, height in
|
||
/// landscape). The default.
|
||
/// - [`WidgetScaling::Physical`] — sizes stay a constant physical size
|
||
/// (via [`Length::dp`] and [`density`]), the mainstream HiDPI model.
|
||
///
|
||
/// Both leave explicit [`Length`] overrides (`vmin` / `orient` / `dp` / …)
|
||
/// on individual widgets untouched — the mode only picks the meaning of the
|
||
/// theme's default design pixels.
|
||
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
|
||
pub enum WidgetScaling
|
||
{
|
||
/// Surface-proportional defaults. See [`Length::fluid`].
|
||
Fluid,
|
||
/// Constant-physical-size defaults. See [`Length::dp`].
|
||
Physical,
|
||
}
|
||
|
||
static WIDGET_SCALING_BITS: AtomicU8 = AtomicU8::new( 0 );
|
||
|
||
/// Set the process-wide [`WidgetScaling`] mode for stock-widget defaults.
|
||
/// Call once at startup. Default: [`WidgetScaling::Fluid`].
|
||
pub fn set_widget_scaling( mode: WidgetScaling )
|
||
{
|
||
let v = match mode { WidgetScaling::Fluid => 0, WidgetScaling::Physical => 1 };
|
||
WIDGET_SCALING_BITS.store( v, Ordering::Relaxed );
|
||
}
|
||
|
||
/// Current [`WidgetScaling`] mode. [`WidgetScaling::Fluid`] until
|
||
/// [`set_widget_scaling`] changes it.
|
||
pub fn widget_scaling() -> WidgetScaling
|
||
{
|
||
match WIDGET_SCALING_BITS.load( Ordering::Relaxed )
|
||
{
|
||
1 => WidgetScaling::Physical,
|
||
_ => WidgetScaling::Fluid,
|
||
}
|
||
}
|
||
|
||
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;
|
||
use crate::TEST_GLOBALS_LOCK as GLOBALS_LOCK;
|
||
|
||
#[ 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 orient_uses_width_pct_in_portrait_and_height_pct_in_landscape()
|
||
{
|
||
// Portrait 1080×2400: 40 % of the width.
|
||
assert_eq!( Length::orient( 40.0, 5.0 ).resolve( ( 1080.0, 2400.0 ), 16.0 ), 432.0 );
|
||
// Landscape 2400×1080: 5 % of the height.
|
||
assert_eq!( Length::orient( 40.0, 5.0 ).resolve( ( 2400.0, 1080.0 ), 16.0 ), 54.0 );
|
||
// Square viewport counts as portrait (width ≤ height).
|
||
assert_eq!( Length::orient( 10.0, 20.0 ).resolve( ( 500.0, 500.0 ), 16.0 ), 50.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 ) );
|
||
}
|
||
|
||
#[ test ]
|
||
fn fluid_equals_design_px_at_reference_surface()
|
||
{
|
||
// At a surface whose short side is the 412 px reference, fluid( n ) == n.
|
||
assert_eq!( Length::fluid( 48.0 ).resolve( ( 412.0, 900.0 ), 16.0 ), 48.0 );
|
||
}
|
||
|
||
#[ test ]
|
||
fn fluid_scales_with_surface_and_auto_clamps()
|
||
{
|
||
// Twice the reference short side → would double, but the +50 % cap
|
||
// (48 * FLUID_MAX = 72) holds it.
|
||
assert_eq!( Length::fluid( 48.0 ).resolve( ( 824.0, 1600.0 ), 16.0 ), 72.0 );
|
||
// A tiny surface → the -30 % floor (48 * FLUID_MIN = 33.6) holds it.
|
||
assert_eq!( Length::fluid( 48.0 ).resolve( ( 200.0, 400.0 ), 16.0 ), 33.6 );
|
||
// Fluid tracks the short side: same result portrait or landscape.
|
||
let p = Length::fluid( 48.0 ).resolve( ( 412.0, 1000.0 ), 16.0 );
|
||
let l = Length::fluid( 48.0 ).resolve( ( 1000.0, 412.0 ), 16.0 );
|
||
assert_eq!( p, l );
|
||
}
|
||
|
||
#[ test ]
|
||
fn dp_is_identity_at_default_density()
|
||
{
|
||
let _g = GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
|
||
// Density defaults to 1.0, so dp( n ) resolves to n regardless of viewport.
|
||
assert_eq!( super::density(), 1.0 );
|
||
assert_eq!( Length::dp( 48.0 ).resolve( ( 412.0, 900.0 ), 16.0 ), 48.0 );
|
||
assert_eq!( Length::dp( 48.0 ).resolve( ( 3840.0, 2160.0 ), 16.0 ), 48.0 );
|
||
}
|
||
|
||
// Serialised: this is the only test that mutates the process-wide density
|
||
// and widget-scaling globals, so it owns them start-to-finish and restores
|
||
// the defaults, keeping the other (read-only-default) tests deterministic.
|
||
#[ test ]
|
||
fn density_and_widget_scaling_modes()
|
||
{
|
||
use super::{ density, set_density, widget_scaling, set_widget_scaling, WidgetScaling };
|
||
|
||
let _g = GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
|
||
|
||
// Defaults.
|
||
assert_eq!( density(), 1.0 );
|
||
assert_eq!( widget_scaling(), WidgetScaling::Fluid );
|
||
assert_eq!( Length::widget( 48.0 ), Length::fluid( 48.0 ) );
|
||
|
||
// Density scales dp.
|
||
set_density( 3.0 );
|
||
assert_eq!( Length::dp( 48.0 ).resolve( ( 412.0, 900.0 ), 16.0 ), 144.0 );
|
||
|
||
// Physical mode routes widget() through dp.
|
||
set_widget_scaling( WidgetScaling::Physical );
|
||
assert_eq!( Length::widget( 48.0 ), Length::dp( 48.0 ) );
|
||
|
||
// Restore defaults for the rest of the suite.
|
||
set_density( 1.0 );
|
||
set_widget_scaling( WidgetScaling::Fluid );
|
||
}
|
||
}
|