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}