waterui_core/
layout.rs

1//! Layout primitives and geometry types for the `WaterUI` layout system.
2//!
3//! # Logical Pixels (Points)
4//!
5//! All layout values in `WaterUI` use **logical pixels** (also called "points" or "dp").
6//! This is the same unit system used by design tools like Figma, Sketch, and Adobe XD,
7//! allowing seamless translation from design to implementation.
8//!
9//! - **1 logical pixel** = 1 point in design tools
10//! - Native backends handle conversion to physical pixels based on screen density
11//! - iOS: `UIKit` uses points natively (1pt = 1-3 physical pixels depending on device)
12//! - Android: Backend converts dp to physical pixels using `displayMetrics.density`
13//! - macOS: `AppKit` uses points (1pt = 1-2 physical pixels on Retina displays)
14//!
15//! This means `spacing: 8.0` or `width: 100.0` will appear the same physical size
16//! across all platforms and screen densities.
17//!
18//! # Example
19//!
20//! ```ignore
21//! // In Figma: Button with 16pt horizontal padding, 8pt vertical padding
22//! // In WaterUI: Same values work directly
23//! vstack((
24//!     text("Hello").padding(16.0),  // 16 logical pixels = 16pt in Figma
25//!     Divider,                       // 1pt thick line
26//! )).spacing(8.0)                    // 8 logical pixels between items
27//! ```
28
29use core::fmt::Debug;
30
31use alloc::vec::Vec;
32
33// ============================================================================
34// StretchAxis - Specifies which axis a view stretches on
35// ============================================================================
36
37/// Specifies which axis (or axes) a view wants to stretch to fill available space.
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
39pub enum StretchAxis {
40    /// No stretching - view uses its intrinsic size
41    #[default]
42    None,
43    /// Stretch horizontally only (expand width, use intrinsic height)
44    Horizontal,
45    /// Stretch vertically only (expand height, use intrinsic width)
46    Vertical,
47    /// Stretch in both directions (expand width and height)
48    Both,
49    /// Stretch along the parent container's main axis.
50    /// In `VStack`: expands vertically. In `HStack`: expands horizontally.
51    /// Used by Spacer.
52    MainAxis,
53    /// Stretch along the parent container's cross axis.
54    /// In `VStack`: expands horizontally. In `HStack`: expands vertically.
55    /// Used by Divider.
56    CrossAxis,
57}
58
59impl StretchAxis {
60    /// Returns true if this stretches horizontally.
61    #[must_use]
62    pub const fn stretches_horizontal(&self) -> bool {
63        matches!(self, Self::Horizontal | Self::Both)
64    }
65
66    /// Returns true if this stretches vertically.
67    #[must_use]
68    pub const fn stretches_vertical(&self) -> bool {
69        matches!(self, Self::Vertical | Self::Both)
70    }
71
72    /// Returns true if this stretches in any direction.
73    #[must_use]
74    pub const fn stretches_any(&self) -> bool {
75        !matches!(self, Self::None)
76    }
77}
78
79// ============================================================================
80// SubView Trait - Child View Proxy
81// ============================================================================
82
83/// A proxy for querying child view sizes during layout.
84///
85/// This trait allows layout containers to negotiate with children by asking
86/// "if I propose this size, how big would you be?" multiple times with
87/// different proposals.
88///
89/// # Pure Functions
90///
91/// All methods are pure (take `&self`) with no side effects. Caching of
92/// measurement results is handled by the native backend, not in Rust.
93pub trait SubView {
94    /// Query the child's size for a given proposal.
95    ///
96    /// This method may be called multiple times with different proposals
97    /// to probe the child's flexibility:
98    ///
99    /// - `ProposalSize::new(None, None)` - ideal/intrinsic size
100    /// - `ProposalSize::new(Some(0.0), None)` - minimum width
101    /// - `ProposalSize::new(Some(f32::INFINITY), None)` - maximum width
102    /// - `ProposalSize::new(Some(200.0), None)` - constrained width
103    fn size_that_fits(&self, proposal: ProposalSize) -> Size;
104
105    /// Which axis (or axes) this view stretches to fill available space.
106    ///
107    /// - `StretchAxis::None`: Content-sized, uses intrinsic size
108    /// - `StretchAxis::Horizontal`: Expands width only (e.g., `TextField`, Slider)
109    /// - `StretchAxis::Vertical`: Expands height only
110    /// - `StretchAxis::Both`: Greedy, fills all space (e.g., Spacer, Color)
111    ///
112    /// Layout containers use this to distribute remaining space appropriately:
113    /// - `VStack` checks `stretches_vertical()` for height distribution
114    /// - `HStack` checks `stretches_horizontal()` for width distribution
115    fn stretch_axis(&self) -> StretchAxis;
116
117    /// Layout priority for space distribution.
118    ///
119    /// Higher priority views are measured first and get space preference.
120    fn priority(&self) -> i32;
121}
122
123// ============================================================================
124// Layout Trait - Container Layout
125// ============================================================================
126
127/// A layout algorithm for arranging child views.
128///
129/// Layouts receive a size proposal from their parent, query their children
130/// to determine sizes, and then place children within the final bounds.
131///
132/// # Two-Phase Layout
133///
134/// 1. **Sizing** ([`size_that_fits`](Self::size_that_fits)): Determine how big
135///    this container should be given a proposal
136/// 2. **Placement** ([`place`](Self::place)): Position children within the
137///    final bounds
138///
139/// # Note on Safe Area
140///
141/// Safe area handling is intentionally **not** part of the Layout trait.
142/// Safe area is a platform-specific concept handled by backends. Views can
143/// use the `IgnoresSafeArea` metadata to opt out of safe area insets.
144pub trait Layout: Debug {
145    /// Calculate the size this layout wants given a proposal.
146    ///
147    /// The layout can query children multiple times with different proposals
148    /// to determine optimal sizing.
149    ///
150    /// # Arguments
151    ///
152    /// * `proposal` - The size proposed by the parent
153    /// * `children` - References to child proxies for size queries
154    fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size;
155
156    /// Place children within the given bounds.
157    ///
158    /// Called after sizing is complete. Returns a rect for each child
159    /// specifying its position and size within `bounds`.
160    ///
161    /// # Arguments
162    ///
163    /// * `bounds` - The rectangle this layout should fill
164    /// * `children` - References to child proxies (may query sizes again)
165    fn place(&self, bounds: Rect, children: &[&dyn SubView]) -> Vec<Rect>;
166
167    /// Which axis this container stretches to fill available space.
168    ///
169    /// - `VStack`: `.horizontal` (fills available width, intrinsic height)
170    /// - `HStack`: `.vertical` (fills available height, intrinsic width)
171    /// - `ZStack`: `.both` (fills all available space)
172    /// - Other layouts: `.none` by default
173    ///
174    /// This allows parent containers to know whether to expand this container
175    /// to fill available space on the cross axis.
176    fn stretch_axis(&self) -> StretchAxis {
177        StretchAxis::None
178    }
179}
180
181// ============================================================================
182// Geometry Types
183// ============================================================================
184
185/// Axis-aligned rectangle relative to its parent.
186#[derive(Clone, Copy, Debug, PartialEq)]
187pub struct Rect {
188    origin: Point,
189    size: Size,
190}
191
192impl Rect {
193    /// Creates a new [`Rect`] with the provided `origin` and `size`.
194    #[must_use]
195    pub const fn new(origin: Point, size: Size) -> Self {
196        Self { origin, size }
197    }
198
199    /// Creates a rectangle from origin (0, 0) with the given size.
200    #[must_use]
201    pub const fn from_size(size: Size) -> Self {
202        Self {
203            origin: Point::zero(),
204            size,
205        }
206    }
207
208    /// Returns the rectangle's origin (top-left corner).
209    #[must_use]
210    pub const fn origin(&self) -> Point {
211        self.origin
212    }
213
214    /// Returns the rectangle's size.
215    #[must_use]
216    pub const fn size(&self) -> &Size {
217        &self.size
218    }
219
220    /// Returns the rectangle's x-coordinate (left edge).
221    #[must_use]
222    pub const fn x(&self) -> f32 {
223        self.origin.x
224    }
225
226    /// Returns the rectangle's y-coordinate (top edge).
227    #[must_use]
228    pub const fn y(&self) -> f32 {
229        self.origin.y
230    }
231
232    /// Returns the rectangle's width.
233    #[must_use]
234    pub const fn width(&self) -> f32 {
235        self.size.width
236    }
237
238    /// Returns the rectangle's height.
239    #[must_use]
240    pub const fn height(&self) -> f32 {
241        self.size.height
242    }
243
244    /// Returns the minimum x-coordinate (left edge).
245    #[must_use]
246    pub const fn min_x(&self) -> f32 {
247        self.origin.x
248    }
249
250    /// Returns the minimum y-coordinate (top edge).
251    #[must_use]
252    pub const fn min_y(&self) -> f32 {
253        self.origin.y
254    }
255
256    /// Returns the maximum x-coordinate (right edge).
257    #[must_use]
258    pub const fn max_x(&self) -> f32 {
259        self.origin.x + self.size.width
260    }
261
262    /// Returns the maximum y-coordinate (bottom edge).
263    #[must_use]
264    pub const fn max_y(&self) -> f32 {
265        self.origin.y + self.size.height
266    }
267
268    /// Returns the midpoint x-coordinate.
269    #[must_use]
270    pub const fn mid_x(&self) -> f32 {
271        self.origin.x + self.size.width / 2.0
272    }
273
274    /// Returns the midpoint y-coordinate.
275    #[must_use]
276    pub const fn mid_y(&self) -> f32 {
277        self.origin.y + self.size.height / 2.0
278    }
279
280    /// Returns the center point of the rectangle.
281    #[must_use]
282    pub const fn center(&self) -> Point {
283        Point::new(self.mid_x(), self.mid_y())
284    }
285
286    /// Inset the rectangle by the given amounts on each edge.
287    #[must_use]
288    pub fn inset(&self, top: f32, bottom: f32, leading: f32, trailing: f32) -> Self {
289        Self::new(
290            Point::new(self.origin.x + leading, self.origin.y + top),
291            Size::new(
292                (self.size.width - leading - trailing).max(0.0),
293                (self.size.height - top - bottom).max(0.0),
294            ),
295        )
296    }
297}
298
299// ============================================================================
300// Size
301// ============================================================================
302
303/// Two-dimensional size expressed in points.
304#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Default)]
305pub struct Size {
306    /// The width in points.
307    pub width: f32,
308    /// The height in points.
309    pub height: f32,
310}
311
312impl Size {
313    /// Constructs a [`Size`] with the given `width` and `height`.
314    #[must_use]
315    pub const fn new(width: f32, height: f32) -> Self {
316        Self { width, height }
317    }
318
319    /// Creates a [`Size`] with zero width and height.
320    #[must_use]
321    pub const fn zero() -> Self {
322        Self {
323            width: 0.0,
324            height: 0.0,
325        }
326    }
327
328    /// Returns true if both dimensions are zero.
329    #[must_use]
330    pub const fn is_zero(&self) -> bool {
331        self.width == 0.0 && self.height == 0.0
332    }
333}
334
335// ============================================================================
336// Point
337// ============================================================================
338
339/// Absolute coordinate relative to a parent layout's origin.
340#[derive(Clone, Copy, Debug, PartialEq, Default)]
341pub struct Point {
342    /// The x-coordinate in points.
343    pub x: f32,
344    /// The y-coordinate in points.
345    pub y: f32,
346}
347
348impl Point {
349    /// Constructs a [`Point`] at the given `x` and `y`.
350    #[must_use]
351    pub const fn new(x: f32, y: f32) -> Self {
352        Self { x, y }
353    }
354
355    /// Creates a [`Point`] at the origin (0, 0).
356    #[must_use]
357    pub const fn zero() -> Self {
358        Self { x: 0.0, y: 0.0 }
359    }
360}
361
362// ============================================================================
363// ProposalSize
364// ============================================================================
365
366/// A size proposal from parent to child during layout negotiation.
367///
368/// Each dimension can be:
369/// - `None` - "Tell me your ideal size" (unspecified)
370/// - `Some(0.0)` - "Tell me your minimum size"
371/// - `Some(f32::INFINITY)` - "Tell me your maximum size"
372/// - `Some(value)` - "I suggest you use this size"
373///
374/// Children are free to return any size; the proposal is just a suggestion.
375#[derive(Clone, Copy, Debug, PartialEq, Default)]
376pub struct ProposalSize {
377    /// Width proposal: `None` = unspecified, `Some(f32)` = suggested width
378    pub width: Option<f32>,
379    /// Height proposal: `None` = unspecified, `Some(f32)` = suggested height
380    pub height: Option<f32>,
381}
382
383impl ProposalSize {
384    /// Creates a [`ProposalSize`] from optional width and height.
385    #[must_use]
386    pub fn new(width: impl Into<Option<f32>>, height: impl Into<Option<f32>>) -> Self {
387        Self {
388            width: width.into(),
389            height: height.into(),
390        }
391    }
392
393    /// Unspecified proposal - asks for ideal/intrinsic size.
394    pub const UNSPECIFIED: Self = Self {
395        width: None,
396        height: None,
397    };
398
399    /// Zero proposal - asks for minimum size.
400    pub const ZERO: Self = Self {
401        width: Some(0.0),
402        height: Some(0.0),
403    };
404
405    /// Infinite proposal - asks for maximum size.
406    pub const INFINITY: Self = Self {
407        width: Some(f32::INFINITY),
408        height: Some(f32::INFINITY),
409    };
410
411    /// Returns the width or a default value if unspecified.
412    #[must_use]
413    pub fn width_or(&self, default: f32) -> f32 {
414        self.width.unwrap_or(default)
415    }
416
417    /// Returns the height or a default value if unspecified.
418    #[must_use]
419    pub fn height_or(&self, default: f32) -> f32 {
420        self.height.unwrap_or(default)
421    }
422
423    /// Replace only the width, keeping the height.
424    #[must_use]
425    pub const fn with_width(self, width: Option<f32>) -> Self {
426        Self {
427            width,
428            height: self.height,
429        }
430    }
431
432    /// Replace only the height, keeping the width.
433    #[must_use]
434    pub const fn with_height(self, height: Option<f32>) -> Self {
435        Self {
436            width: self.width,
437            height,
438        }
439    }
440}
441
442// ============================================================================
443// Tests
444// ============================================================================
445
446#[cfg(test)]
447#[allow(clippy::float_cmp)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn test_rect_geometry() {
453        let rect = Rect::new(Point::new(10.0, 20.0), Size::new(100.0, 50.0));
454
455        assert_eq!(rect.min_x(), 10.0);
456        assert_eq!(rect.min_y(), 20.0);
457        assert_eq!(rect.max_x(), 110.0);
458        assert_eq!(rect.max_y(), 70.0);
459        assert_eq!(rect.mid_x(), 60.0);
460        assert_eq!(rect.mid_y(), 45.0);
461        assert_eq!(rect.width(), 100.0);
462        assert_eq!(rect.height(), 50.0);
463    }
464
465    #[test]
466    fn test_rect_inset() {
467        let rect = Rect::new(Point::new(0.0, 0.0), Size::new(100.0, 100.0));
468        let inset = rect.inset(10.0, 10.0, 20.0, 20.0);
469
470        assert_eq!(inset.x(), 20.0);
471        assert_eq!(inset.y(), 10.0);
472        assert_eq!(inset.width(), 60.0);
473        assert_eq!(inset.height(), 80.0);
474    }
475
476    #[test]
477    fn test_proposal_size() {
478        let proposal = ProposalSize::new(Some(100.0), None);
479
480        assert_eq!(proposal.width_or(0.0), 100.0);
481        assert_eq!(proposal.height_or(50.0), 50.0);
482
483        let with_height = proposal.with_height(Some(200.0));
484        assert_eq!(with_height.width, Some(100.0));
485        assert_eq!(with_height.height, Some(200.0));
486    }
487}