// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! 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::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` 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 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 ), /// Density-independent pixel: the design value times the pixel /// density in effect **when the length is resolved** (the canvas' /// own density, or the process [`density`]). See [`Length::dp`]. Dp( f32 ), } impl LengthBase { fn resolve( &self, viewport: ( f32, f32 ), em_base: f32, density: 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, LengthBase::Dp( v ) => *v * density, } } } /// 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`, 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`] (or [`Length::resolve_with_density`] where a /// canvas-local density applies), 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, /// Upper bound in absolute logical px. `None` means unbounded. pub max_px: Option, } 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 pixel 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. /// /// The multiplication happens at **resolution time**, not here: the /// value carries its design pixels, and [`Length::resolve`] applies /// the process [`density`] — or the canvas' own density /// ([`crate::Canvas::set_density`]) on canvas-routed resolution — so /// a density change takes effect on the next paint without /// reconstructing the view's lengths. pub const fn dp( px: f32 ) -> Self { Self::from_base( LengthBase::Dp( px ) ) } /// 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). /// [`LengthBase::Dp`] values use the process [`density`]; resolution /// paths that know a more local density (a canvas tied to a specific /// output) go through [`Self::resolve_with_density`] instead. pub fn resolve( &self, viewport: ( f32, f32 ), em_base: f32 ) -> f32 { self.resolve_with_density( viewport, em_base, density() ) } /// [`Self::resolve`] with an explicit pixel density for /// [`LengthBase::Dp`], instead of the process [`density`]. This is /// what [`crate::Canvas`]-routed resolution calls with the canvas' /// own density. pub fn resolve_with_density( &self, viewport: ( f32, f32 ), em_base: f32, density: f32 ) -> f32 { let raw = self.base.resolve( viewport, em_base, density ); 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 TEXT_SCALE_BITS: AtomicU32 = AtomicU32::new( 1.0_f32.to_bits() ); /// Set the global text scale multiplier applied to every resolved font /// size (the accessibility "large text" factor). Clamped to `[0.5, 3.0]`. /// The run loop keeps it synced to the desktop's /// `org.gnome.desktop.interface text-scaling-factor` GSettings key and /// repaints on change, so apps normally never call this themselves; /// embedders driving [`crate::core::UiSurface`] directly do. pub fn set_text_scale( s: f32 ) { TEXT_SCALE_BITS.store( s.clamp( 0.5, 3.0 ).to_bits(), Ordering::Relaxed ); } /// Current text scale multiplier. Default `1.0`. pub fn text_scale() -> f32 { f32::from_bits( TEXT_SCALE_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 for Length { fn from( v: f32 ) -> Self { Length::px( v ) } } impl From for Length { fn from( v: i32 ) -> Self { Length::px( v as f32 ) } } impl From for Length { fn from( v: u32 ) -> Self { Length::px( v as f32 ) } } impl From 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 ); } #[ test ] fn dp_resolves_against_explicit_density() { let l = Length::dp( 48.0 ); assert_eq!( l.resolve_with_density( ( 412.0, 900.0 ), 16.0, 2.0 ), 96.0 ); assert_eq!( l.resolve_with_density( ( 412.0, 900.0 ), 16.0, 1.0 ), 48.0 ); } #[ test ] fn dp_is_applied_at_resolution_time_not_construction() { use super::set_density; let _g = GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() ); // Construct while density is 1.0, resolve after it changes: the // length must follow the new density. let l = Length::dp( 48.0 ); set_density( 2.0 ); assert_eq!( l.resolve( ( 412.0, 900.0 ), 16.0 ), 96.0 ); set_density( 1.0 ); assert_eq!( l.resolve( ( 412.0, 900.0 ), 16.0 ), 48.0 ); } // Serialised: 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 ); } }