Skip to main content

azul_layout/solver3/
geometry.rs

1//! Box model geometry types and writing-mode support for the layout solver.
2//!
3//! Provides edge-size types (`EdgeSizes`, `ResolvedBoxProps`, `PackedBoxProps`),
4//! CSS value resolution (`UnresolvedMargin`, `UnresolvedEdge`, `ResolutionParams`),
5//! intrinsic sizing (`IntrinsicSizes`), and writing-mode context (`WritingModeContext`).
6
7use azul_core::{
8    geom::{LogicalPosition, LogicalRect, LogicalSize},
9    ui_solver::ResolvedOffsets,
10};
11use azul_css::props::{
12    basic::{pixel::PixelValue, PhysicalSize, PropertyContext, ResolutionContext, SizeMetric},
13    layout::LayoutWritingMode,
14    style::{StyleDirection, StyleTextOrientation},
15};
16
17#[derive(Copy, Debug, Clone, PartialEq, PartialOrd)]
18pub struct PositionedRectangle {
19    /// The outer bounds of the rectangle
20    pub bounds: LogicalRect,
21    /// Margin of the rectangle.
22    pub margin: ResolvedOffsets,
23    /// Border widths of the rectangle.
24    pub border: ResolvedOffsets,
25    /// Padding of the rectangle.
26    pub padding: ResolvedOffsets,
27}
28
29// +spec:box-model:83b3b8 - Box dimensions: content area with optional padding, border, margin areas
30/// Represents the four edges of a box for properties like margin, padding, border.
31// +spec:box-model:3b155c - "4 values assigned to sides" pattern (top, right, bottom, left) matching margin/inset shorthands
32// +spec:width-calculation:37f9e7 - CSS 2.2 §8.1 box dimensions: content, padding, border, margin areas with top/right/bottom/left segments
33#[derive(Debug, Clone, Copy, Default)]
34pub struct EdgeSizes {
35    pub top: f32,
36    pub right: f32,
37    pub bottom: f32,
38    pub left: f32,
39}
40
41impl EdgeSizes {
42    /// Sum of horizontal edges (left + right).
43    #[must_use] pub fn horizontal_sum(&self) -> f32 {
44        self.left + self.right
45    }
46
47    /// Sum of vertical edges (top + bottom).
48    #[must_use] pub fn vertical_sum(&self) -> f32 {
49        self.top + self.bottom
50    }
51
52    // +spec:block-formatting-context:440282 - vertical writing modes use analogous layout via main/cross axis abstraction
53    // +spec:block-formatting-context:a49f9e - line-relative directions mapped via writing mode
54    // +spec:block-formatting-context:387117 - writing-mode property maps block flow to vertical/horizontal axes
55    // +spec:box-model:4c01a3 - dimensional mapping: main=block axis, cross=inline axis per writing mode
56    // +spec:box-model:4c1a9f - physical-to-logical mapping of margin/padding/border for vertical writing modes
57    // +spec:box-model:9414ab - flow-relative mapping of box edges (margin/padding/border) per writing mode
58    // +spec:inline-formatting-context:2de457 - block/inline dimension mapping via writing mode
59    // +spec:inline-formatting-context:c6b91e - line-relative "over"/"under" mapped to physical top/bottom via writing mode
60    // +spec:writing-modes:00a918 - Abstract-to-physical mappings for block/inline to top/right/bottom/left
61    // +spec:writing-modes:14e6f0 - block-start/end depend only on writing-mode; inline-start/end also depend on direction (handled in positioning.rs)
62    // +spec:writing-modes:1c2101 - Abstract directional terms (top/right/bottom/left) to logical axes (main/cross) based on writing-mode
63    // +spec:writing-modes:1c5155 - line-relative mappings: over/under/line-left/line-right → top/bottom/left/right in horizontal-tb
64    // +spec:writing-modes:70daf1 - block/inline axis mapping per writing-mode for edge sizes
65    // +spec:writing-modes:f9af71 - flow-relative directions: block-start/end and inline-start/end mapped to physical edges
66    // +spec:writing-modes:60b023 - abstract-to-physical mapping: block axis = main, inline axis = cross
67    // +spec:writing-modes:829cd7 - flow-relative directions: block-start/end from writing-mode, inline-start/end from writing-mode+direction
68    // +spec:writing-modes:a2113d - block/inline axis mapping for writing modes (block-axis, inline-axis, block-start/end, inline-start/end)
69    // +spec:writing-modes:c0ae9c - abstract directional mappings from writing-mode/direction
70    // +spec:writing-modes:c91130 - Abstract box terminology: block/inline axis mapping per writing-mode
71    // +spec:writing-modes:cd31ce - flow-relative directions mapped to physical via writing mode
72    // +spec:writing-modes:fd8c18 - block/inline axis mapping based on writing mode
73    // +spec:writing-modes:0e549a - writing-mode computed value influences physical/logical axis mapping
74    /// Returns the size of the edge at the start of the main/block axis.
75    #[must_use] pub const fn main_start(&self, wm: LayoutWritingMode) -> f32 {
76        match wm {
77            LayoutWritingMode::HorizontalTb => self.top,
78            LayoutWritingMode::VerticalRl | LayoutWritingMode::VerticalLr => self.left,
79        }
80    }
81
82    /// Returns the size of the edge at the end of the main/block axis.
83    #[must_use] pub const fn main_end(&self, wm: LayoutWritingMode) -> f32 {
84        match wm {
85            LayoutWritingMode::HorizontalTb => self.bottom,
86            LayoutWritingMode::VerticalRl | LayoutWritingMode::VerticalLr => self.right,
87        }
88    }
89
90    /// Returns the sum of the start and end sizes on the main/block axis.
91    #[must_use] pub fn main_sum(&self, wm: LayoutWritingMode) -> f32 {
92        self.main_start(wm) + self.main_end(wm)
93    }
94
95    // +spec:block-formatting-context:6225cb - line-relative directions: vertical modes map line-over/under to top/bottom
96    /// Returns the size of the edge at the start of the cross/inline axis.
97    #[must_use] pub const fn cross_start(&self, wm: LayoutWritingMode) -> f32 {
98        match wm {
99            LayoutWritingMode::HorizontalTb => self.left,
100            LayoutWritingMode::VerticalRl | LayoutWritingMode::VerticalLr => self.top,
101        }
102    }
103
104    /// Returns the size of the edge at the end of the cross/inline axis.
105    #[must_use] pub const fn cross_end(&self, wm: LayoutWritingMode) -> f32 {
106        match wm {
107            LayoutWritingMode::HorizontalTb => self.right,
108            LayoutWritingMode::VerticalRl | LayoutWritingMode::VerticalLr => self.bottom,
109        }
110    }
111
112    /// Returns the sum of the start and end sizes on the cross/inline axis.
113    #[must_use] pub fn cross_sum(&self, wm: LayoutWritingMode) -> f32 {
114        self.cross_start(wm) + self.cross_end(wm)
115    }
116
117    // +spec:block-formatting-context:a49f9e - line-over/line-under are line-relative
118    //   block-axis edges: top/bottom in horizontal-tb, right/left in vertical modes.
119    // +spec:inline-formatting-context:c6b91e - line-relative "over"/"under" mapped to
120    //   physical edges via writing mode.
121    // +spec:writing-modes:1c5155 - line-relative mappings for over/under.
122    /// Returns the line-over edge (line-relative block-axis start).
123    ///
124    /// Per CSS Writing Modes L4 §6.3, the line-over side is `top` in
125    /// `horizontal-tb` and `right` in both vertical writing modes. Unlike
126    /// [`Self::main_start`] (a physical block-axis accessor), this is the
127    /// *line-relative* over side and coincides with `main_start` only in
128    /// `horizontal-tb`.
129    #[must_use] pub const fn line_over(&self, wm: LayoutWritingMode) -> f32 {
130        match wm {
131            LayoutWritingMode::HorizontalTb => self.top,
132            LayoutWritingMode::VerticalRl | LayoutWritingMode::VerticalLr => self.right,
133        }
134    }
135
136    /// Returns the line-under edge (line-relative block-axis end).
137    ///
138    /// Per CSS Writing Modes L4 §6.3, the line-under side is `bottom` in
139    /// `horizontal-tb` and `left` in both vertical writing modes.
140    #[must_use] pub const fn line_under(&self, wm: LayoutWritingMode) -> f32 {
141        match wm {
142            LayoutWritingMode::HorizontalTb => self.bottom,
143            LayoutWritingMode::VerticalRl | LayoutWritingMode::VerticalLr => self.left,
144        }
145    }
146
147    /// Returns the sum of the line-over and line-under edges.
148    #[must_use] pub fn line_over_under_sum(&self, wm: LayoutWritingMode) -> f32 {
149        self.line_over(wm) + self.line_under(wm)
150    }
151
152    // +spec:writing-modes:829cd7 - inline-start/end depend on writing-mode AND direction.
153    // +spec:writing-modes:c0ae9c - flow-relative inline mappings derive from
154    //   writing-mode + direction (RTL's inline-start = line-right).
155    // +spec:writing-modes:14e6f0 - inline-start/end depend on direction as well as wm.
156    /// Returns the flow-relative inline-start edge.
157    ///
158    /// The inline-start side is `line-left` (see [`Self::cross_start`]) when the
159    /// inline base direction is LTR, and `line-right` (see [`Self::cross_end`])
160    /// when it is RTL. This is the direction-aware counterpart that
161    /// `cross_start`/`cross_end` (which are direction-blind line-left/line-right)
162    /// cannot express on their own.
163    #[must_use] pub const fn inline_start(&self, wm: LayoutWritingMode, dir: StyleDirection) -> f32 {
164        match dir {
165            StyleDirection::Ltr => self.cross_start(wm),
166            StyleDirection::Rtl => self.cross_end(wm),
167        }
168    }
169
170    /// Returns the flow-relative inline-end edge.
171    ///
172    /// The inline-end side is `line-right` when the inline base direction is
173    /// LTR, and `line-left` when it is RTL.
174    #[must_use] pub const fn inline_end(&self, wm: LayoutWritingMode, dir: StyleDirection) -> f32 {
175        match dir {
176            StyleDirection::Ltr => self.cross_end(wm),
177            StyleDirection::Rtl => self.cross_start(wm),
178        }
179    }
180
181    /// Returns the sum of the inline-start and inline-end edges.
182    ///
183    /// Direction-independent (start + end covers the whole inline axis), but
184    /// takes `dir` for call-site symmetry with the individual accessors.
185    #[must_use] pub fn inline_sum(&self, wm: LayoutWritingMode, dir: StyleDirection) -> f32 {
186        self.inline_start(wm, dir) + self.inline_end(wm, dir)
187    }
188}
189
190// ============================================================================
191// UNRESOLVED VALUE TYPES (for lazy resolution during layout)
192// ============================================================================
193
194/// An unresolved CSS margin value.
195// +spec:box-model:ff1730 - margin properties apply to both continuous and paged media
196///
197/// Margins can be `auto` (for centering) or a length value that needs
198/// resolution against the containing block.
199#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
200pub enum UnresolvedMargin {
201    /// margin: 0 (default)
202    #[default]
203    Zero,
204    /// margin: auto (for centering, CSS 2.2 § 10.3.3)
205    Auto,
206    /// A length value (px, %, em, vh, etc.)
207    Length(PixelValue),
208}
209
210impl UnresolvedMargin {
211    /// Returns true if this is an auto margin
212    #[must_use] pub const fn is_auto(&self) -> bool {
213        matches!(self, Self::Auto)
214    }
215
216    /// Resolve this margin value to pixels.
217    ///
218    /// - `Auto` returns 0.0 (actual auto margin calculation happens in layout)
219    /// - `Zero` returns 0.0
220    /// - `Length` is resolved using the resolution context
221    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
222    #[must_use] pub fn resolve(&self, ctx: &ResolutionContext) -> f32 {
223        match self {
224            Self::Zero => 0.0,
225            // +spec:box-model:c921aa - auto margin-top/bottom used value is 0 for block-level non-replaced elements in normal flow
226            // +spec:box-model:e25fdc - auto margins treated as zero for abspos size computation
227            Self::Auto => 0.0, // Auto is handled separately in layout
228            Self::Length(pv) => pv.resolve_with_context(ctx, PropertyContext::Margin),
229        }
230    }
231}
232
233/// Unresolved edge sizes for margin/padding/border.
234///
235/// This stores the raw CSS values before resolution, allowing us to
236/// defer resolution until the containing block size is known.
237#[derive(Debug, Clone, Copy, Default)]
238pub struct UnresolvedEdge<T> {
239    pub top: T,
240    pub right: T,
241    pub bottom: T,
242    pub left: T,
243}
244
245impl<T> UnresolvedEdge<T> {
246    pub const fn new(top: T, right: T, bottom: T, left: T) -> Self {
247        Self { top, right, bottom, left }
248    }
249}
250
251impl UnresolvedEdge<UnresolvedMargin> {
252    /// Resolve all margin edges to pixel values.
253    #[must_use] pub fn resolve(&self, ctx: &ResolutionContext) -> EdgeSizes {
254        EdgeSizes {
255            top: self.top.resolve(ctx),
256            right: self.right.resolve(ctx),
257            bottom: self.bottom.resolve(ctx),
258            left: self.left.resolve(ctx),
259        }
260    }
261
262    /// Extract which margins are set to `auto`.
263    #[must_use] pub const fn get_margin_auto(&self) -> MarginAuto {
264        MarginAuto {
265            top: self.top.is_auto(),
266            right: self.right.is_auto(),
267            bottom: self.bottom.is_auto(),
268            left: self.left.is_auto(),
269        }
270    }
271}
272
273impl UnresolvedEdge<PixelValue> {
274    /// Resolve all edges to pixel values.
275    #[must_use] pub fn resolve(&self, ctx: &ResolutionContext, prop_ctx: PropertyContext) -> EdgeSizes {
276        EdgeSizes {
277            top: self.top.resolve_with_context(ctx, prop_ctx),
278            right: self.right.resolve_with_context(ctx, prop_ctx),
279            bottom: self.bottom.resolve_with_context(ctx, prop_ctx),
280            left: self.left.resolve_with_context(ctx, prop_ctx),
281        }
282    }
283}
284
285/// Parameters needed to resolve CSS values to pixels.
286#[derive(Debug, Clone, Copy)]
287pub struct ResolutionParams {
288    // +spec:inline-formatting-context:26c933 - LogicalSize maps inline/block dimensions to physical width/height per writing mode
289    /// The containing block size (for % resolution)
290    pub containing_block: LogicalSize,
291    /// The viewport size (for vh/vw resolution)
292    pub viewport_size: LogicalSize,
293    /// The element's computed font-size (for em resolution)
294    pub element_font_size: f32,
295    /// The root element's font-size (for rem resolution)
296    pub root_font_size: f32,
297}
298
299impl ResolutionParams {
300    /// Create a `ResolutionContext` from these parameters.
301    #[must_use] pub const fn to_resolution_context(&self) -> ResolutionContext {
302        ResolutionContext {
303            element_font_size: self.element_font_size,
304            // For non-font properties, `em` resolves against the element's own
305            // computed font-size, so parent_font_size == element_font_size here.
306            // Do NOT use this context for font-size resolution itself.
307            parent_font_size: self.element_font_size,
308            root_font_size: self.root_font_size,
309            element_size: None,
310            containing_block_size: PhysicalSize::new(
311                self.containing_block.width,
312                self.containing_block.height,
313            ),
314            viewport_size: PhysicalSize::new(
315                self.viewport_size.width,
316                self.viewport_size.height,
317            ),
318        }
319    }
320}
321
322// ============================================================================
323// UNRESOLVED BOX PROPS (new design)
324// ============================================================================
325
326/// Box properties with unresolved CSS values.
327///
328/// This stores the raw CSS values as parsed, deferring resolution until
329/// layout time when the containing block size is known.
330#[derive(Debug, Clone, Copy, Default)]
331pub struct UnresolvedBoxProps {
332    pub margin: UnresolvedEdge<UnresolvedMargin>,
333    pub padding: UnresolvedEdge<PixelValue>,
334    pub border: UnresolvedEdge<PixelValue>,
335}
336
337impl UnresolvedBoxProps {
338    /// Resolve all box properties to pixel values.
339    #[must_use] pub fn resolve(&self, params: &ResolutionParams) -> ResolvedBoxProps {
340        let ctx = params.to_resolution_context();
341        ResolvedBoxProps {
342            margin: self.margin.resolve(&ctx),
343            padding: self.padding.resolve(&ctx, PropertyContext::Padding),
344            border: self.border.resolve(&ctx, PropertyContext::BorderWidth),
345            margin_auto: self.margin.get_margin_auto(),
346        }
347    }
348}
349
350// ============================================================================
351// RESOLVED BOX PROPS (legacy name: BoxProps)
352// ============================================================================
353
354/// Tracks which margins are set to `auto` (for centering calculations).
355#[derive(Debug, Clone, Copy, Default)]
356#[allow(clippy::struct_excessive_bools)] // one independent bool per margin edge (auto flags)
357pub struct MarginAuto {
358    pub left: bool,
359    pub right: bool,
360    pub top: bool,
361    pub bottom: bool,
362}
363
364/// A fully resolved representation of a node's box model properties.
365// +spec:box-model:3e083b - content/padding/border/margin box model layers
366// +spec:box-model:a227ff - content/padding/border/margin edges defining box extents for overflow
367// +spec:containing-block:bca691 - box model edges: padding/border/margin boxes with content-box, padding-box, margin-box methods
368///
369/// All values are in pixels. This is the result of resolving `UnresolvedBoxProps`
370/// against a containing block.
371#[derive(Debug, Clone, Copy, Default)]
372pub struct ResolvedBoxProps {
373    pub margin: EdgeSizes,
374    pub padding: EdgeSizes,
375    pub border: EdgeSizes,
376    /// Tracks which margins are set to `auto`.
377    /// CSS 2.2 § 10.3.3: If both margin-left and margin-right are auto,
378    /// their used values are equal, centering the element within its container.
379    pub margin_auto: MarginAuto,
380}
381
382impl ResolvedBoxProps {
383    // +spec:box-model:be08c6 - inner size (content-box) from outer size minus border+padding, floored at zero
384    // +spec:writing-modes:a58616 - abstract dimensions: inline size maps to physical width/height per writing-mode
385    /// Calculates the inner content-box size from an outer border-box size,
386    /// correctly accounting for the specified writing mode.
387    #[must_use] pub fn inner_size(&self, outer_size: LogicalSize, wm: LayoutWritingMode) -> LogicalSize {
388        let outer_main = outer_size.main(wm);
389        let outer_cross = outer_size.cross(wm);
390
391        // The sum of padding and border along the cross (inline) axis.
392        let cross_axis_spacing = self.padding.cross_sum(wm) + self.border.cross_sum(wm);
393
394        // The sum of padding and border along the main (block) axis.
395        let main_axis_spacing = self.padding.main_sum(wm) + self.border.main_sum(wm);
396
397        // +spec:box-model:2589b1 - content size = border-box - border - padding, floored at zero
398        // +spec:box-model:3ab53d - if padding+border > border-box, content floors at 0px
399        let inner_main = (outer_main - main_axis_spacing).max(0.0);
400        let inner_cross = (outer_cross - cross_axis_spacing).max(0.0);
401
402        LogicalSize::from_main_cross(inner_main, inner_cross, wm)
403    }
404
405    // +spec:box-model:aa585e - Content/padding/border/margin edge relationships
406    // +spec:height-calculation:6c9abb - box model edges: margin > border > padding > content
407    /// Returns the content-box rect from a border-box rect.
408    /// Shrinks inward by border + padding on each side.
409    // +spec:box-model:1720a5 - content of a block box is confined to its content edges
410    #[must_use] pub fn content_box(&self, border_box: LogicalRect) -> LogicalRect {
411        let x = border_box.origin.x + self.border.left + self.padding.left;
412        let y = border_box.origin.y + self.border.top + self.padding.top;
413        let w = (border_box.size.width - self.border.horizontal_sum() - self.padding.horizontal_sum()).max(0.0);
414        let h = (border_box.size.height - self.border.vertical_sum() - self.padding.vertical_sum()).max(0.0);
415        LogicalRect { origin: LogicalPosition { x, y }, size: LogicalSize { width: w, height: h } }
416    }
417
418    /// Returns the padding-box rect from a border-box rect.
419    /// Shrinks inward by border on each side.
420    #[must_use] pub fn padding_box(&self, border_box: LogicalRect) -> LogicalRect {
421        let x = border_box.origin.x + self.border.left;
422        let y = border_box.origin.y + self.border.top;
423        let w = (border_box.size.width - self.border.horizontal_sum()).max(0.0);
424        let h = (border_box.size.height - self.border.vertical_sum()).max(0.0);
425        LogicalRect { origin: LogicalPosition { x, y }, size: LogicalSize { width: w, height: h } }
426    }
427
428    /// Returns the margin-box rect from a border-box rect.
429    /// Expands outward by margin on each side.
430    #[must_use] pub fn margin_box(&self, border_box: LogicalRect) -> LogicalRect {
431        let x = border_box.origin.x - self.margin.left;
432        let y = border_box.origin.y - self.margin.top;
433        let w = border_box.size.width + self.margin.horizontal_sum();
434        let h = border_box.size.height + self.margin.vertical_sum();
435        LogicalRect { origin: LogicalPosition { x, y }, size: LogicalSize { width: w, height: h } }
436    }
437
438    // +spec:box-model:0e75c1 - margin, padding, border contribute to layout bounds (default line-fit-edge: leading uses line-height model)
439    /// Total horizontal space consumed by margin + border + padding.
440    #[must_use] pub fn horizontal_mbp(&self) -> f32 {
441        self.margin.horizontal_sum() + self.border.horizontal_sum() + self.padding.horizontal_sum()
442    }
443
444    /// Total vertical space consumed by margin + border + padding.
445    #[must_use] pub fn vertical_mbp(&self) -> f32 {
446        self.margin.vertical_sum() + self.border.vertical_sum() + self.padding.vertical_sum()
447    }
448
449    /// Total horizontal space consumed by border + padding only (no margin).
450    #[must_use] pub fn horizontal_bp(&self) -> f32 {
451        self.border.horizontal_sum() + self.padding.horizontal_sum()
452    }
453
454    /// Total vertical space consumed by border + padding only (no margin).
455    #[must_use] pub fn vertical_bp(&self) -> f32 {
456        self.border.vertical_sum() + self.padding.vertical_sum()
457    }
458}
459
460/// Type alias for backwards compatibility.
461/// TODO: Remove this once all code uses `ResolvedBoxProps` directly.
462pub type BoxProps = ResolvedBoxProps;
463
464/// Packed representation of box model properties using i16×10 encoding.
465///
466/// Stores margin/padding/border as i16 values scaled by 10 (0.1px precision),
467/// reducing the hot struct from 52B to 26B. Range: ±3276.7px per edge.
468///
469/// Only used for storage in `LayoutNodeHot`. The layout solver unpacks to
470/// `ResolvedBoxProps` (f32) for computation.
471#[derive(Debug, Clone, Copy, Default)]
472#[repr(C)]
473pub struct PackedBoxProps {
474    pub margin: [i16; 4],     // top, right, bottom, left — ×10
475    pub padding: [i16; 4],    // ×10
476    pub border: [i16; 4],     // ×10
477    pub margin_auto: MarginAuto,
478}
479
480impl PackedBoxProps {
481    /// Pack a `ResolvedBoxProps` into compact i16×10 encoding.
482    #[inline]
483    #[must_use] pub fn pack(bp: &ResolvedBoxProps) -> Self {
484        Self {
485            margin: Self::pack_edge(&bp.margin),
486            padding: Self::pack_edge(&bp.padding),
487            border: Self::pack_edge(&bp.border),
488            margin_auto: bp.margin_auto,
489        }
490    }
491
492    /// Unpack to full `ResolvedBoxProps` with f32 values.
493    #[inline]
494    #[must_use] pub fn unpack(&self) -> ResolvedBoxProps {
495        ResolvedBoxProps {
496            margin: Self::unpack_edge(&self.margin),
497            padding: Self::unpack_edge(&self.padding),
498            border: Self::unpack_edge(&self.border),
499            margin_auto: self.margin_auto,
500        }
501    }
502
503    /// Convenience: unpack and call `inner_size` on the result.
504    #[inline]
505    #[must_use] pub fn inner_size(&self, outer_size: LogicalSize, wm: LayoutWritingMode) -> LogicalSize {
506        self.unpack().inner_size(outer_size, wm)
507    }
508
509    /// Convenience: unpack and call `content_box` on the result.
510    #[inline]
511    #[must_use] pub fn content_box(&self, border_box: LogicalRect) -> LogicalRect {
512        self.unpack().content_box(border_box)
513    }
514
515    /// Convenience: unpack and call `padding_box` on the result.
516    #[inline]
517    #[must_use] pub fn padding_box(&self, border_box: LogicalRect) -> LogicalRect {
518        self.unpack().padding_box(border_box)
519    }
520
521    /// Convenience: unpack and call `margin_box` on the result.
522    #[inline]
523    #[must_use] pub fn margin_box(&self, border_box: LogicalRect) -> LogicalRect {
524        self.unpack().margin_box(border_box)
525    }
526
527    /// Convenience: unpack and return horizontal MBP.
528    #[inline]
529    #[must_use] pub fn horizontal_mbp(&self) -> f32 {
530        self.unpack().horizontal_mbp()
531    }
532
533    /// Convenience: unpack and return vertical MBP.
534    #[inline]
535    #[must_use] pub fn vertical_mbp(&self) -> f32 {
536        self.unpack().vertical_mbp()
537    }
538
539    /// Convenience: unpack and return horizontal BP.
540    #[inline]
541    #[must_use] pub fn horizontal_bp(&self) -> f32 {
542        self.unpack().horizontal_bp()
543    }
544
545    /// Convenience: unpack and return vertical BP.
546    #[inline]
547    #[must_use] pub fn vertical_bp(&self) -> f32 {
548        self.unpack().vertical_bp()
549    }
550
551    #[inline]
552    #[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/counter/fixed-point cast
553    fn pack_edge(e: &EdgeSizes) -> [i16; 4] {
554        const MIN: f32 = i16::MIN as f32;
555        const MAX: f32 = i16::MAX as f32;
556        [
557            (e.top * 10.0).round().clamp(MIN, MAX) as i16,
558            (e.right * 10.0).round().clamp(MIN, MAX) as i16,
559            (e.bottom * 10.0).round().clamp(MIN, MAX) as i16,
560            (e.left * 10.0).round().clamp(MIN, MAX) as i16,
561        ]
562    }
563
564    #[inline]
565    #[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
566    fn unpack_edge(e: &[i16; 4]) -> EdgeSizes {
567        EdgeSizes {
568            top: f32::from(e[0]) * 0.1,
569            right: f32::from(e[1]) * 0.1,
570            bottom: f32::from(e[2]) * 0.1,
571            left: f32::from(e[3]) * 0.1,
572        }
573    }
574}
575
576// Re-export float and clear types from azul_css
577pub use azul_css::props::layout::{LayoutClear, LayoutFloat};
578
579// +spec:intrinsic-sizing:af39b6 - min-content, max-content, and stretch fit size definitions
580// min-content constraint, max-content constraint definitions
581// and fit-content sizes for both inline and block axes
582// +spec:height-calculation:e9ec84 - replaced elements have natural dimensions (width, height, ratio)
583/// Represents the intrinsic sizing information for an element, calculated
584/// without knowledge of the final containing block size.
585// +spec:intrinsic-sizing:127a10 - min-content, max-content, fit-content size definitions (css-sizing-3 §2.1)
586// +spec:intrinsic-sizing:21f2cb - defines min-content, max-content, and stretch-fit size terminology
587// +spec:width-calculation:1583c4 - min-content, max-content, fit-content intrinsic size definitions (§2.1)
588#[derive(Debug, Clone, Copy, Default)]
589pub struct IntrinsicSizes {
590    // +spec:width-calculation:b83d0a - min-content width ("preferred minimum width" in CSS2.1§10.3.5)
591    // +spec:writing-modes:1583c4 - min-content size in inline axis = size fitting contents with all soft wraps taken
592    /// §2.1 min-content inline size: inline size fitting contents if all soft wraps taken.
593    pub min_content_width: f32,
594    // +spec:width-calculation:0c74d3 - max-content width ("preferred width" in CSS2.1§10.3.5)
595    // +spec:writing-modes:6e85d3 - max-content inline size is the "ideal" size in the inline axis (writing-mode-dependent)
596    /// §2.1 max-content inline size: narrowest inline size if no soft wraps taken.
597    pub max_content_width: f32,
598    /// The width specified by CSS properties, if any.
599    pub preferred_width: Option<f32>,
600    /// §2.1 min-content block size: for block containers, tables, and inline boxes,
601    /// equivalent to max-content block size.
602    pub min_content_height: f32,
603    // +spec:writing-modes:8c94e2 - max-content block size is the "ideal" block size after layout
604    /// §2.1 max-content block size: "ideal" block size, usually content height after layout.
605    pub max_content_height: f32,
606    /// The height specified by CSS properties, if any.
607    pub preferred_height: Option<f32>,
608    // +spec:intrinsic-sizing:af39b6 - natural (intrinsic) aspect ratio of a replaced element
609    // +spec:height-calculation:e9ec84 - replaced-element natural width/height/ratio
610    /// The element's natural aspect ratio (inline / block), if it has one — e.g. a
611    /// replaced element with intrinsic dimensions. `None` for non-replaced content.
612    pub preferred_aspect_ratio: Option<f32>,
613}
614
615impl IntrinsicSizes {
616    // +spec:intrinsic-sizing:127a10 - fit-content = clamp(min-content, stretch-fit, max-content)
617    // +spec:intrinsic-sizing:21f2cb - stretch-fit size drawn from available (containing-block) space
618    /// CSS Sizing §2.1 fit-content **inline** size:
619    /// `clamp(min-content, stretch-fit, max-content)`, where the stretch-fit size is the
620    /// `available_inline_size` (the space the containing block offers in the inline axis).
621    /// Equal to `max(min_content, min(stretch_fit, max_content))`.
622    #[must_use]
623    pub const fn fit_content_width(&self, available_inline_size: f32) -> f32 {
624        available_inline_size
625            .min(self.max_content_width)
626            .max(self.min_content_width)
627    }
628
629    /// CSS Sizing §2.1 fit-content **block** size:
630    /// `clamp(min-content, stretch-fit, max-content)` in the block axis.
631    #[must_use]
632    pub const fn fit_content_height(&self, available_block_size: f32) -> f32 {
633        available_block_size
634            .min(self.max_content_height)
635            .max(self.min_content_height)
636    }
637}
638
639// ============================================================================
640// WRITING MODE SUPPORT
641// ============================================================================
642
643/// Captures the resolved writing mode context for a node.
644///
645/// This struct bundles together all the CSS properties that affect how
646/// logical directions (inline/block) map to physical directions (x/y).
647/// Spec agents should use this struct to implement writing-mode-aware layout.
648///
649/// # CSS Writing Modes Level 4
650///
651/// - `writing-mode` determines the block flow direction and inline base direction
652/// - `direction` determines the inline base direction (ltr or rtl)
653/// - `text-orientation` determines glyph orientation in vertical writing modes
654// +spec:block-formatting-context:333dcb - typographic mode captured by text_orientation field
655// +spec:block-formatting-context:66eb6d - text-orientation property (mixed|upright|sideways) integrated into WritingModeContext
656// +spec:block-formatting-context:8be1b0 - writing modes and vertical text orientation context (UTN#22)
657// +spec:display-property:0a39dc - text-orientation affects inline-level alignment via WritingModeContext
658// +spec:display-property:591355 - bidirectionality support via direction property in WritingModeContext
659#[derive(Debug, Clone, Copy, PartialEq, Eq)]
660pub struct WritingModeContext {
661    pub writing_mode: LayoutWritingMode,
662    pub direction: StyleDirection,
663    // +spec:block-formatting-context:925cfe - text-orientation mixed/upright for horizontal scripts in vertical mode
664    pub text_orientation: StyleTextOrientation,
665}
666
667impl Default for WritingModeContext {
668    fn default() -> Self {
669        Self {
670            writing_mode: LayoutWritingMode::HorizontalTb,
671            direction: StyleDirection::Ltr,
672            text_orientation: StyleTextOrientation::Mixed,
673        }
674    }
675}
676
677impl WritingModeContext {
678    /// Constructs a `WritingModeContext`, applying spec-mandated overrides.
679    // +spec:writing-modes:8307e4 - text-orientation: upright forces used direction to ltr
680    #[must_use] pub fn new(
681        writing_mode: LayoutWritingMode,
682        direction: StyleDirection,
683        text_orientation: StyleTextOrientation,
684    ) -> Self {
685        // CSS Writing Modes Level 4 §5.1: text-orientation: upright causes
686        // the used value of direction to be ltr, and all characters to be
687        // treated as strong LTR for bidi reordering purposes.
688        let used_direction = if text_orientation == StyleTextOrientation::Upright {
689            StyleDirection::Ltr
690        } else {
691            direction
692        };
693        Self {
694            writing_mode,
695            direction: used_direction,
696            text_orientation,
697        }
698    }
699
700    // +spec:writing-modes:458d31 - text-orientation:upright forces used direction to ltr
701    /// Returns the used value of `direction`.
702    ///
703    /// The upright override is already applied in `new()`, so this just
704    /// returns the stored direction.
705    #[must_use] pub const fn used_direction(&self) -> StyleDirection {
706        self.direction
707    }
708
709    // +spec:containing-block:c205e5 - orthogonal flow: child writing mode perpendicular to containing block's
710
711    // +spec:block-formatting-context:6225cb - vertical writing modes: line-over is right, line-under is left
712    // +spec:block-formatting-context:9a4269 - vertical vs horizontal script classification
713    /// Returns true if the writing mode is horizontal (`HorizontalTb`).
714    ///
715    /// When true, the inline axis is horizontal and the block axis is vertical.
716    #[must_use] pub const fn is_horizontal(&self) -> bool {
717        matches!(self.writing_mode, LayoutWritingMode::HorizontalTb)
718    }
719
720    /// Returns true if the inline size corresponds to the physical width.
721    ///
722    /// In horizontal writing modes, inline size = width.
723    /// In vertical writing modes, inline size = height.
724    // +spec:block-formatting-context:bb9845 - orthogonal flows: inline/block axis mapping
725    #[must_use] pub const fn inline_size_is_width(&self) -> bool {
726        self.is_horizontal()
727    }
728
729    /// Returns true if the block size corresponds to the physical height.
730    ///
731    /// In horizontal writing modes, block size = height.
732    /// In vertical writing modes, block size = width.
733    #[must_use] pub const fn block_size_is_height(&self) -> bool {
734        self.is_horizontal()
735    }
736
737    // +spec:writing-modes:32541a - direction property controls inline text direction via stylesheet
738    /// Returns true if the inline direction is reversed (RTL in horizontal,
739    /// or bottom-to-top in certain vertical modes).
740    #[must_use] pub fn is_inline_reversed(&self) -> bool {
741        self.used_direction() == StyleDirection::Rtl
742    }
743}
744
745#[cfg(test)]
746#[allow(clippy::float_cmp, clippy::unreadable_literal)]
747mod autotest_generated {
748    use super::*;
749
750    // ---------------------------------------------------------------- helpers
751
752    /// All writing modes, so every mapping test can be run exhaustively.
753    const ALL_WM: [LayoutWritingMode; 3] = [
754        LayoutWritingMode::HorizontalTb,
755        LayoutWritingMode::VerticalRl,
756        LayoutWritingMode::VerticalLr,
757    ];
758
759    fn edges(top: f32, right: f32, bottom: f32, left: f32) -> EdgeSizes {
760        EdgeSizes { top, right, bottom, left }
761    }
762
763    fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
764        LogicalRect {
765            origin: LogicalPosition { x, y },
766            size: LogicalSize { width: w, height: h },
767        }
768    }
769
770    /// `EdgeSizes`/`ResolvedBoxProps` carry no `PartialEq`, and the packed
771    /// encoding is lossy by design, so float comparisons need a tolerance.
772    fn close(a: f32, b: f32, eps: f32) -> bool {
773        (a - b).abs() <= eps
774    }
775
776    fn params(cb: LogicalSize, vp: LogicalSize, font: f32, root: f32) -> ResolutionParams {
777        ResolutionParams {
778            containing_block: cb,
779            viewport_size: vp,
780            element_font_size: font,
781            root_font_size: root,
782        }
783    }
784
785    /// 800x600 containing block, 1000x500 viewport, 16px element / 10px root font.
786    /// Every dimension is distinct so a transposed axis cannot pass by accident.
787    fn distinct_params() -> ResolutionParams {
788        params(
789            LogicalSize::new(800.0, 600.0),
790            LogicalSize::new(1000.0, 500.0),
791            16.0,
792            10.0,
793        )
794    }
795
796    fn props(margin: EdgeSizes, padding: EdgeSizes, border: EdgeSizes) -> ResolvedBoxProps {
797        ResolvedBoxProps { margin, padding, border, margin_auto: MarginAuto::default() }
798    }
799
800    // ================================================================
801    // EdgeSizes: sums
802    // ================================================================
803
804    #[test]
805    fn edge_sizes_sums_pick_the_right_pair_of_edges() {
806        let e = edges(1.0, 2.0, 4.0, 8.0); // top, right, bottom, left
807        assert_eq!(e.horizontal_sum(), 10.0, "horizontal = left + right");
808        assert_eq!(e.vertical_sum(), 5.0, "vertical = top + bottom");
809    }
810
811    #[test]
812    fn edge_sizes_default_is_all_zero() {
813        let e = EdgeSizes::default();
814        assert_eq!(e.horizontal_sum(), 0.0);
815        assert_eq!(e.vertical_sum(), 0.0);
816        for wm in ALL_WM {
817            assert_eq!(e.main_sum(wm), 0.0);
818            assert_eq!(e.cross_sum(wm), 0.0);
819        }
820    }
821
822    #[test]
823    fn edge_sizes_sums_saturate_to_infinity_instead_of_panicking() {
824        // f32::MAX + f32::MAX overflows to +inf; it must not panic or wrap.
825        let e = edges(f32::MAX, f32::MAX, f32::MAX, f32::MAX);
826        assert!(e.horizontal_sum().is_infinite() && e.horizontal_sum() > 0.0);
827        assert!(e.vertical_sum().is_infinite() && e.vertical_sum() > 0.0);
828    }
829
830    #[test]
831    fn edge_sizes_opposing_infinities_produce_nan_not_a_panic() {
832        // inf + (-inf) is NaN by IEEE-754. The point is that it is deterministic
833        // and does not trap — nothing downstream may assume a finite sum.
834        let e = edges(f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
835        assert!(e.horizontal_sum().is_nan());
836        assert!(e.vertical_sum().is_nan());
837    }
838
839    #[test]
840    fn edge_sizes_nan_edges_propagate_without_panicking() {
841        let e = edges(f32::NAN, 1.0, 2.0, 3.0);
842        assert!(e.vertical_sum().is_nan());
843        assert_eq!(e.horizontal_sum(), 4.0, "a NaN top must not poison left+right");
844        for wm in ALL_WM {
845            let _ = e.main_sum(wm);
846            let _ = e.cross_sum(wm);
847        }
848    }
849
850    // ================================================================
851    // EdgeSizes: writing-mode axis mapping
852    // ================================================================
853
854    #[test]
855    fn edge_sizes_axis_mapping_is_exact_for_every_writing_mode() {
856        let e = edges(1.0, 2.0, 4.0, 8.0); // top, right, bottom, left
857
858        // horizontal-tb: block (main) axis is vertical, inline (cross) axis is horizontal.
859        let h = LayoutWritingMode::HorizontalTb;
860        assert_eq!(e.main_start(h), 1.0, "main-start = top");
861        assert_eq!(e.main_end(h), 4.0, "main-end = bottom");
862        assert_eq!(e.cross_start(h), 8.0, "cross-start = left");
863        assert_eq!(e.cross_end(h), 2.0, "cross-end = right");
864
865        // vertical-*: block (main) axis is horizontal, inline (cross) axis is vertical.
866        for wm in [LayoutWritingMode::VerticalRl, LayoutWritingMode::VerticalLr] {
867            assert_eq!(e.main_start(wm), 8.0, "main-start = left");
868            assert_eq!(e.main_end(wm), 2.0, "main-end = right");
869            assert_eq!(e.cross_start(wm), 1.0, "cross-start = top");
870            assert_eq!(e.cross_end(wm), 4.0, "cross-end = bottom");
871        }
872    }
873
874    // +spec:block-formatting-context:a49f9e - line-over/line-under accessors.
875    #[test]
876    fn edge_sizes_line_over_under_is_line_relative_not_physical() {
877        let e = edges(1.0, 2.0, 4.0, 8.0); // top, right, bottom, left
878
879        // horizontal-tb: over = top, under = bottom (coincides with main_start/end).
880        let h = LayoutWritingMode::HorizontalTb;
881        assert_eq!(e.line_over(h), 1.0, "line-over = top in horizontal-tb");
882        assert_eq!(e.line_under(h), 4.0, "line-under = bottom in horizontal-tb");
883        assert_eq!(e.line_over(h), e.main_start(h), "over == main-start only in horizontal-tb");
884
885        // vertical modes: over = right, under = left (CSS Writing Modes L4 §6.3).
886        // Crucially this differs from main_start/main_end (left/right), proving the
887        // line-relative accessor is not just an alias of the physical block axis.
888        for wm in [LayoutWritingMode::VerticalRl, LayoutWritingMode::VerticalLr] {
889            assert_eq!(e.line_over(wm), 2.0, "line-over = right in vertical modes");
890            assert_eq!(e.line_under(wm), 8.0, "line-under = left in vertical modes");
891            assert_ne!(e.line_over(wm), e.main_start(wm), "over != main-start in vertical modes");
892        }
893
894        // Sum picks the line-relative pair, independent of writing mode orientation.
895        assert_eq!(e.line_over_under_sum(h), 5.0);
896        assert_eq!(e.line_over_under_sum(LayoutWritingMode::VerticalRl), 10.0);
897    }
898
899    // +spec:writing-modes:829cd7 / c0ae9c - direction-aware inline-start/end accessors.
900    #[test]
901    fn edge_sizes_inline_start_end_depend_on_direction() {
902        let e = edges(1.0, 2.0, 4.0, 8.0); // top, right, bottom, left
903
904        for wm in ALL_WM {
905            // LTR: inline-start = line-left = cross_start; inline-end = line-right = cross_end.
906            assert_eq!(
907                e.inline_start(wm, StyleDirection::Ltr),
908                e.cross_start(wm),
909                "LTR inline-start = cross-start (line-left)"
910            );
911            assert_eq!(
912                e.inline_end(wm, StyleDirection::Ltr),
913                e.cross_end(wm),
914                "LTR inline-end = cross-end (line-right)"
915            );
916
917            // RTL: the two ends swap — inline-start moves to line-right.
918            assert_eq!(
919                e.inline_start(wm, StyleDirection::Rtl),
920                e.cross_end(wm),
921                "RTL inline-start = cross-end (line-right)"
922            );
923            assert_eq!(
924                e.inline_end(wm, StyleDirection::Rtl),
925                e.cross_start(wm),
926                "RTL inline-end = cross-start (line-left)"
927            );
928
929            // The sum covers the whole inline axis regardless of direction.
930            assert_eq!(
931                e.inline_sum(wm, StyleDirection::Ltr),
932                e.inline_sum(wm, StyleDirection::Rtl),
933                "inline-sum is direction-independent"
934            );
935            assert_eq!(e.inline_sum(wm, StyleDirection::Ltr), e.cross_sum(wm));
936        }
937
938        // Concrete horizontal-tb values: LTR start=left(8), RTL start=right(2).
939        let h = LayoutWritingMode::HorizontalTb;
940        assert_eq!(e.inline_start(h, StyleDirection::Ltr), 8.0);
941        assert_eq!(e.inline_start(h, StyleDirection::Rtl), 2.0);
942    }
943
944    #[test]
945    fn edge_sizes_main_and_cross_sums_partition_the_four_edges() {
946        // Whatever the writing mode, main+cross must account for each edge exactly
947        // once — a transposition bug in one arm would break this identity.
948        let e = edges(1.0, 2.0, 4.0, 8.0);
949        for wm in ALL_WM {
950            assert_eq!(e.main_start(wm) + e.main_end(wm), e.main_sum(wm));
951            assert_eq!(e.cross_start(wm) + e.cross_end(wm), e.cross_sum(wm));
952            assert_eq!(
953                e.main_sum(wm) + e.cross_sum(wm),
954                e.horizontal_sum() + e.vertical_sum(),
955                "{wm:?}: main+cross must cover all four edges"
956            );
957        }
958    }
959
960    #[test]
961    fn edge_sizes_axis_accessors_survive_extreme_values() {
962        let e = edges(f32::MAX, f32::MIN, f32::INFINITY, f32::NEG_INFINITY);
963        for wm in ALL_WM {
964            let _ = e.main_start(wm);
965            let _ = e.main_end(wm);
966            let _ = e.cross_start(wm);
967            let _ = e.cross_end(wm);
968            let _ = e.main_sum(wm);
969            let _ = e.cross_sum(wm);
970        }
971    }
972
973    // ================================================================
974    // UnresolvedMargin
975    // ================================================================
976
977    #[test]
978    fn unresolved_margin_is_auto_only_for_the_auto_variant() {
979        assert!(UnresolvedMargin::Auto.is_auto());
980        assert!(!UnresolvedMargin::Zero.is_auto());
981        assert!(!UnresolvedMargin::Length(PixelValue::const_px(10)).is_auto());
982        // A zero-valued length is still *not* `auto`.
983        assert!(!UnresolvedMargin::Length(PixelValue::zero()).is_auto());
984        assert!(!UnresolvedMargin::default().is_auto(), "default is Zero, not Auto");
985    }
986
987    #[test]
988    fn unresolved_margin_auto_and_zero_both_resolve_to_zero_px() {
989        let ctx = distinct_params().to_resolution_context();
990        assert_eq!(UnresolvedMargin::Zero.resolve(&ctx), 0.0);
991        // `auto` is resolved later by the layout algorithm; here it must read as 0.
992        assert_eq!(UnresolvedMargin::Auto.resolve(&ctx), 0.0);
993    }
994
995    #[test]
996    fn unresolved_margin_length_resolves_by_metric() {
997        let ctx = distinct_params().to_resolution_context(); // 800x600 cb, 16px font, 10px root
998        let r = |pv: PixelValue| UnresolvedMargin::Length(pv).resolve(&ctx);
999
1000        assert_eq!(r(PixelValue::px(12.5)), 12.5);
1001        assert_eq!(r(PixelValue::em(2.0)), 32.0, "em = 2 x element font-size");
1002        assert_eq!(r(PixelValue::rem(2.0)), 20.0, "rem = 2 x root font-size");
1003    }
1004
1005    #[test]
1006    fn unresolved_margin_percent_resolves_against_containing_block_width() {
1007        // CSS 2.1 §8.3: percentage margins ALWAYS refer to the containing block
1008        // *width* — never the height. With a 800x600 block, 50% must be 400, not 300.
1009        let ctx = distinct_params().to_resolution_context();
1010        let got = UnresolvedMargin::Length(PixelValue::percent(50.0)).resolve(&ctx);
1011        assert_eq!(got, 400.0);
1012        assert_ne!(got, 300.0, "percentage margin must not use the block height");
1013    }
1014
1015    #[test]
1016    fn unresolved_margin_nan_length_resolves_to_zero_not_nan() {
1017        // PixelValue stores a fixed-point isize, and `f32 as isize` saturates NaN
1018        // to 0 — so a NaN can never escape into layout as a NaN margin.
1019        let ctx = distinct_params().to_resolution_context();
1020        let got = UnresolvedMargin::Length(PixelValue::px(f32::NAN)).resolve(&ctx);
1021        assert!(!got.is_nan(), "a NaN px value must not survive resolution");
1022        assert_eq!(got, 0.0);
1023    }
1024
1025    #[test]
1026    fn unresolved_margin_huge_length_saturates_to_a_finite_value() {
1027        let ctx = distinct_params().to_resolution_context();
1028        for v in [f32::MAX, f32::MIN, f32::INFINITY, f32::NEG_INFINITY] {
1029            let got = UnresolvedMargin::Length(PixelValue::px(v)).resolve(&ctx);
1030            assert!(got.is_finite(), "px({v}) resolved to a non-finite {got}");
1031        }
1032    }
1033
1034    #[test]
1035    fn unresolved_margin_percent_of_a_nan_containing_block_is_nan_not_a_panic() {
1036        // The containing block is a raw f32 and is NOT sanitized, so a NaN block
1037        // size *does* propagate. Documenting the real behaviour: deterministic NaN,
1038        // no panic — the guard has to live at the caller, not here.
1039        let p = params(
1040            LogicalSize::new(f32::NAN, f32::NAN),
1041            LogicalSize::new(1000.0, 500.0),
1042            16.0,
1043            10.0,
1044        );
1045        let got = UnresolvedMargin::Length(PixelValue::percent(50.0)).resolve(&p.to_resolution_context());
1046        assert!(got.is_nan());
1047    }
1048
1049    // ================================================================
1050    // UnresolvedEdge
1051    // ================================================================
1052
1053    #[test]
1054    fn unresolved_edge_new_assigns_fields_in_top_right_bottom_left_order() {
1055        // The classic CSS shorthand transposition bug: argument order is TRBL.
1056        let e = UnresolvedEdge::new(1_u8, 2, 4, 8);
1057        assert_eq!(e.top, 1);
1058        assert_eq!(e.right, 2);
1059        assert_eq!(e.bottom, 4);
1060        assert_eq!(e.left, 8);
1061    }
1062
1063    #[test]
1064    fn unresolved_edge_new_accepts_extreme_payloads() {
1065        let e = UnresolvedEdge::new(f32::NAN, f32::INFINITY, f32::MAX, f32::MIN);
1066        assert!(e.top.is_nan());
1067        assert!(e.right.is_infinite());
1068        assert_eq!(e.bottom, f32::MAX);
1069        assert_eq!(e.left, f32::MIN);
1070    }
1071
1072    #[test]
1073    fn get_margin_auto_flags_exactly_the_auto_sides() {
1074        let e = UnresolvedEdge::new(
1075            UnresolvedMargin::Zero,                                 // top
1076            UnresolvedMargin::Auto,                                 // right
1077            UnresolvedMargin::Length(PixelValue::const_px(5)),      // bottom
1078            UnresolvedMargin::Auto,                                 // left
1079        );
1080        let a = e.get_margin_auto();
1081        assert!(!a.top);
1082        assert!(a.right);
1083        assert!(!a.bottom);
1084        assert!(a.left);
1085    }
1086
1087    #[test]
1088    fn get_margin_auto_on_default_edge_flags_nothing() {
1089        let a = UnresolvedEdge::<UnresolvedMargin>::default().get_margin_auto();
1090        assert!(!a.top && !a.right && !a.bottom && !a.left);
1091    }
1092
1093    #[test]
1094    fn unresolved_margin_edge_resolve_keeps_each_side_separate() {
1095        let ctx = distinct_params().to_resolution_context();
1096        let e = UnresolvedEdge::new(
1097            UnresolvedMargin::Length(PixelValue::px(1.0)),   // top
1098            UnresolvedMargin::Length(PixelValue::px(2.0)),   // right
1099            UnresolvedMargin::Auto,                          // bottom -> 0
1100            UnresolvedMargin::Length(PixelValue::px(8.0)),   // left
1101        );
1102        let r = e.resolve(&ctx);
1103        assert_eq!(r.top, 1.0);
1104        assert_eq!(r.right, 2.0);
1105        assert_eq!(r.bottom, 0.0, "auto resolves to 0 px here");
1106        assert_eq!(r.left, 8.0);
1107    }
1108
1109    #[test]
1110    fn pixel_edge_resolve_drops_percentages_on_border_width() {
1111        // CSS Backgrounds 3 §4.1: `%` is not a valid border-width. The resolver
1112        // must yield 0 rather than silently resolving against the containing block.
1113        let ctx = distinct_params().to_resolution_context();
1114        let e = UnresolvedEdge::new(
1115            PixelValue::percent(50.0),
1116            PixelValue::percent(50.0),
1117            PixelValue::percent(50.0),
1118            PixelValue::percent(50.0),
1119        );
1120        let r = e.resolve(&ctx, PropertyContext::BorderWidth);
1121        assert_eq!(r.top, 0.0);
1122        assert_eq!(r.right, 0.0);
1123        assert_eq!(r.bottom, 0.0);
1124        assert_eq!(r.left, 0.0);
1125    }
1126
1127    #[test]
1128    fn pixel_edge_resolve_uses_block_width_for_vertical_padding_percentages() {
1129        // CSS 2.1 §8.4: padding-top/bottom percentages also refer to the containing
1130        // block WIDTH. With 800x600, every side must land on 80, never 60.
1131        let ctx = distinct_params().to_resolution_context();
1132        let e = UnresolvedEdge::new(
1133            PixelValue::percent(10.0),
1134            PixelValue::percent(10.0),
1135            PixelValue::percent(10.0),
1136            PixelValue::percent(10.0),
1137        );
1138        let r = e.resolve(&ctx, PropertyContext::Padding);
1139        assert_eq!(r.top, 80.0, "padding-top % must use the width");
1140        assert_eq!(r.bottom, 80.0, "padding-bottom % must use the width");
1141        assert_eq!(r.left, 80.0);
1142        assert_eq!(r.right, 80.0);
1143    }
1144
1145    #[test]
1146    fn pixel_edge_resolve_of_viewport_units_uses_the_viewport_not_the_block() {
1147        let ctx = distinct_params().to_resolution_context(); // viewport 1000x500
1148        let e = UnresolvedEdge::new(
1149            PixelValue::from_metric(SizeMetric::Vw, 10.0),
1150            PixelValue::from_metric(SizeMetric::Vh, 10.0),
1151            PixelValue::from_metric(SizeMetric::Vmin, 10.0),
1152            PixelValue::from_metric(SizeMetric::Vmax, 10.0),
1153        );
1154        let r = e.resolve(&ctx, PropertyContext::Padding);
1155        assert_eq!(r.top, 100.0, "10vw of 1000");
1156        assert_eq!(r.right, 50.0, "10vh of 500");
1157        assert_eq!(r.bottom, 50.0, "10vmin of min(1000,500)");
1158        assert_eq!(r.left, 100.0, "10vmax of max(1000,500)");
1159    }
1160
1161    // ================================================================
1162    // ResolutionParams
1163    // ================================================================
1164
1165    #[test]
1166    fn to_resolution_context_maps_every_field() {
1167        let ctx = distinct_params().to_resolution_context();
1168        assert_eq!(ctx.element_font_size, 16.0);
1169        assert_eq!(ctx.root_font_size, 10.0);
1170        assert_eq!(ctx.containing_block_size.width, 800.0);
1171        assert_eq!(ctx.containing_block_size.height, 600.0);
1172        assert_eq!(ctx.viewport_size.width, 1000.0);
1173        assert_eq!(ctx.viewport_size.height, 500.0);
1174        assert!(ctx.element_size.is_none(), "element size is unknown pre-layout");
1175    }
1176
1177    #[test]
1178    fn to_resolution_context_aliases_parent_font_size_onto_the_element_font_size() {
1179        // Documented quirk: for non-font properties `em` resolves against the
1180        // element's OWN font-size, so the context deliberately reports
1181        // parent == element. Using it for font-size resolution would be wrong.
1182        let ctx = distinct_params().to_resolution_context();
1183        assert_eq!(ctx.parent_font_size, ctx.element_font_size);
1184        assert_eq!(ctx.parent_font_size, 16.0);
1185    }
1186
1187    #[test]
1188    fn to_resolution_context_passes_extreme_values_through_unchanged() {
1189        let p = params(
1190            LogicalSize::new(f32::MAX, f32::NAN),
1191            LogicalSize::new(f32::INFINITY, 0.0),
1192            0.0,
1193            f32::MIN,
1194        );
1195        let ctx = p.to_resolution_context();
1196        assert_eq!(ctx.containing_block_size.width, f32::MAX);
1197        assert!(ctx.containing_block_size.height.is_nan());
1198        assert!(ctx.viewport_size.width.is_infinite());
1199        assert_eq!(ctx.element_font_size, 0.0);
1200        assert_eq!(ctx.root_font_size, f32::MIN);
1201    }
1202
1203    #[test]
1204    fn zero_font_size_context_resolves_em_to_zero_without_dividing_by_it() {
1205        let p = params(LogicalSize::zero(), LogicalSize::zero(), 0.0, 0.0);
1206        let ctx = p.to_resolution_context();
1207        assert_eq!(PixelValue::em(10.0).resolve_with_context(&ctx, PropertyContext::Margin), 0.0);
1208        assert_eq!(PixelValue::rem(10.0).resolve_with_context(&ctx, PropertyContext::Margin), 0.0);
1209        // A zero-sized viewport must not turn vmin/vmax into NaN.
1210        let vmin = PixelValue::from_metric(SizeMetric::Vmin, 50.0);
1211        assert_eq!(vmin.resolve_with_context(&ctx, PropertyContext::Padding), 0.0);
1212    }
1213
1214    // ================================================================
1215    // UnresolvedBoxProps
1216    // ================================================================
1217
1218    #[test]
1219    fn unresolved_box_props_default_resolves_to_an_all_zero_box() {
1220        let r = UnresolvedBoxProps::default().resolve(&distinct_params());
1221        assert_eq!(r.horizontal_mbp(), 0.0);
1222        assert_eq!(r.vertical_mbp(), 0.0);
1223        assert!(!r.margin_auto.left && !r.margin_auto.right);
1224    }
1225
1226    #[test]
1227    fn unresolved_box_props_resolve_applies_the_right_property_context_per_edge() {
1228        let p = distinct_params(); // 800x600 block
1229        let b = UnresolvedBoxProps {
1230            margin: UnresolvedEdge::new(
1231                UnresolvedMargin::Auto,
1232                UnresolvedMargin::Length(PixelValue::percent(10.0)), // -> 80 (width)
1233                UnresolvedMargin::Zero,
1234                UnresolvedMargin::Auto,
1235            ),
1236            padding: UnresolvedEdge::new(
1237                PixelValue::percent(10.0), // -> 80 (width, even on the top edge)
1238                PixelValue::px(4.0),
1239                PixelValue::px(4.0),
1240                PixelValue::px(4.0),
1241            ),
1242            border: UnresolvedEdge::new(
1243                PixelValue::percent(10.0), // -> 0 (percent is invalid on border-width)
1244                PixelValue::px(2.0),
1245                PixelValue::px(2.0),
1246                PixelValue::px(2.0),
1247            ),
1248        };
1249        let r = b.resolve(&p);
1250
1251        assert_eq!(r.margin.top, 0.0, "auto margin resolves to 0 px");
1252        assert_eq!(r.margin.right, 80.0);
1253        assert_eq!(r.padding.top, 80.0);
1254        assert_eq!(r.border.top, 0.0, "percent border-width must collapse to 0");
1255        assert_eq!(r.border.left, 2.0);
1256
1257        // The auto flags must survive resolution — they are what drives centering.
1258        assert!(r.margin_auto.top);
1259        assert!(r.margin_auto.left);
1260        assert!(!r.margin_auto.right);
1261        assert!(!r.margin_auto.bottom);
1262    }
1263
1264    // ================================================================
1265    // ResolvedBoxProps::inner_size
1266    // ================================================================
1267
1268    #[test]
1269    fn inner_size_of_a_zero_box_is_zero() {
1270        let bp = ResolvedBoxProps::default();
1271        for wm in ALL_WM {
1272            let s = bp.inner_size(LogicalSize::zero(), wm);
1273            assert_eq!(s.width, 0.0);
1274            assert_eq!(s.height, 0.0);
1275        }
1276    }
1277
1278    #[test]
1279    fn inner_size_subtracts_border_and_padding_but_not_margin() {
1280        let bp = props(
1281            edges(100.0, 100.0, 100.0, 100.0), // margin — must be ignored
1282            edges(1.0, 2.0, 4.0, 8.0),         // padding
1283            edges(10.0, 20.0, 30.0, 40.0),     // border
1284        );
1285        // width  loses left+right: (8+2) + (40+20) = 70
1286        // height loses top+bottom: (1+4) + (10+30) = 45
1287        let s = bp.inner_size(LogicalSize::new(200.0, 100.0), LayoutWritingMode::HorizontalTb);
1288        assert_eq!(s.width, 130.0);
1289        assert_eq!(s.height, 55.0);
1290    }
1291
1292    #[test]
1293    fn inner_size_is_identical_in_every_writing_mode() {
1294        // Physically, content-box = border-box minus the same four edges regardless
1295        // of writing mode. The main/cross indirection must cancel out exactly; a
1296        // transposed arm in one mode would show up right here.
1297        let bp = props(
1298            EdgeSizes::default(),
1299            edges(1.0, 2.0, 4.0, 8.0),
1300            edges(10.0, 20.0, 30.0, 40.0),
1301        );
1302        let outer = LogicalSize::new(200.0, 100.0);
1303        let base = bp.inner_size(outer, LayoutWritingMode::HorizontalTb);
1304        for wm in ALL_WM {
1305            let s = bp.inner_size(outer, wm);
1306            assert_eq!(s.width, base.width, "{wm:?} width diverged");
1307            assert_eq!(s.height, base.height, "{wm:?} height diverged");
1308        }
1309    }
1310
1311    #[test]
1312    fn inner_size_floors_at_zero_when_border_and_padding_exceed_the_box() {
1313        // CSS: if padding+border overflow the border-box, content is 0 — never negative.
1314        let bp = props(
1315            EdgeSizes::default(),
1316            edges(100.0, 100.0, 100.0, 100.0),
1317            edges(100.0, 100.0, 100.0, 100.0),
1318        );
1319        for wm in ALL_WM {
1320            let s = bp.inner_size(LogicalSize::new(10.0, 10.0), wm);
1321            assert_eq!(s.width, 0.0, "{wm:?}");
1322            assert_eq!(s.height, 0.0, "{wm:?}");
1323            assert!(s.width >= 0.0 && s.height >= 0.0);
1324        }
1325    }
1326
1327    #[test]
1328    fn inner_size_never_returns_nan() {
1329        // `.max(0.0)` discards a NaN operand, so NaN can only ever collapse to 0.
1330        let nan_bp = props(EdgeSizes::default(), edges(f32::NAN, 0.0, 0.0, 0.0), EdgeSizes::default());
1331        let cases = [
1332            (ResolvedBoxProps::default(), LogicalSize::new(f32::NAN, f32::NAN)),
1333            (nan_bp, LogicalSize::new(100.0, 100.0)),
1334            (
1335                // inf - inf = NaN, which must still floor to 0 rather than escape.
1336                props(EdgeSizes::default(), edges(f32::INFINITY, 0.0, f32::INFINITY, 0.0), EdgeSizes::default()),
1337                LogicalSize::new(f32::INFINITY, f32::INFINITY),
1338            ),
1339        ];
1340        for (bp, outer) in cases {
1341            for wm in ALL_WM {
1342                let s = bp.inner_size(outer, wm);
1343                assert!(!s.width.is_nan(), "{wm:?}: NaN width escaped inner_size");
1344                assert!(!s.height.is_nan(), "{wm:?}: NaN height escaped inner_size");
1345                assert!(s.width >= 0.0 && s.height >= 0.0);
1346            }
1347        }
1348    }
1349
1350    #[test]
1351    fn inner_size_at_f32_max_stays_finite_and_non_negative() {
1352        let bp = props(EdgeSizes::default(), edges(1.0, 2.0, 4.0, 8.0), edges(1.0, 1.0, 1.0, 1.0));
1353        for wm in ALL_WM {
1354            let s = bp.inner_size(LogicalSize::new(f32::MAX, f32::MAX), wm);
1355            assert!(s.width.is_finite() && s.height.is_finite());
1356            assert!(s.width > 0.0 && s.height > 0.0);
1357        }
1358    }
1359
1360    #[test]
1361    fn inner_size_with_negative_outer_size_floors_to_zero() {
1362        let bp = ResolvedBoxProps::default();
1363        for wm in ALL_WM {
1364            let s = bp.inner_size(LogicalSize::new(-100.0, -50.0), wm);
1365            assert_eq!(s.width, 0.0, "{wm:?}");
1366            assert_eq!(s.height, 0.0, "{wm:?}");
1367        }
1368    }
1369
1370    #[test]
1371    fn inner_size_with_negative_padding_grows_the_content_box() {
1372        // Negative border/padding is not reachable from CSS, but the struct permits
1373        // it — assert the arithmetic is plain subtraction rather than something that
1374        // silently clamps mid-way.
1375        let bp = props(EdgeSizes::default(), edges(-5.0, -5.0, -5.0, -5.0), EdgeSizes::default());
1376        let s = bp.inner_size(LogicalSize::new(100.0, 100.0), LayoutWritingMode::HorizontalTb);
1377        assert_eq!(s.width, 110.0);
1378        assert_eq!(s.height, 110.0);
1379    }
1380
1381    // ================================================================
1382    // ResolvedBoxProps: box rects
1383    // ================================================================
1384
1385    #[test]
1386    fn content_box_shrinks_by_border_plus_padding() {
1387        let bp = props(
1388            edges(100.0, 100.0, 100.0, 100.0), // margin is irrelevant here
1389            edges(1.0, 2.0, 4.0, 8.0),         // padding TRBL
1390            edges(10.0, 20.0, 30.0, 40.0),     // border TRBL
1391        );
1392        let got = bp.content_box(rect(1000.0, 2000.0, 200.0, 100.0));
1393        // origin moves in by border+padding on the start edges: left 40+8, top 10+1
1394        // size shrinks by both sides: width 200-(40+20)-(8+2)=130, height 100-(10+30)-(1+4)=55
1395        assert_eq!(got, rect(1048.0, 2011.0, 130.0, 55.0));
1396    }
1397
1398    #[test]
1399    fn padding_box_shrinks_by_border_only() {
1400        let bp = props(
1401            EdgeSizes::default(),
1402            edges(1.0, 2.0, 4.0, 8.0),
1403            edges(10.0, 20.0, 30.0, 40.0),
1404        );
1405        let got = bp.padding_box(rect(1000.0, 2000.0, 200.0, 100.0));
1406        assert_eq!(got, rect(1040.0, 2010.0, 140.0, 60.0));
1407    }
1408
1409    #[test]
1410    fn margin_box_expands_by_margin_only() {
1411        let bp = props(
1412            edges(1.0, 2.0, 4.0, 8.0), // margin TRBL
1413            edges(99.0, 99.0, 99.0, 99.0),
1414            edges(99.0, 99.0, 99.0, 99.0),
1415        );
1416        let got = bp.margin_box(rect(1000.0, 2000.0, 200.0, 100.0));
1417        // origin moves OUT by left/top margin; size grows by both sides.
1418        assert_eq!(got, rect(992.0, 1999.0, 210.0, 105.0));
1419    }
1420
1421    #[test]
1422    fn box_rects_nest_content_inside_padding_inside_border_inside_margin() {
1423        let bp = props(
1424            edges(5.0, 6.0, 7.0, 8.0),
1425            edges(1.0, 2.0, 3.0, 4.0),
1426            edges(9.0, 10.0, 11.0, 12.0),
1427        );
1428        let border_box = rect(50.0, 60.0, 400.0, 300.0);
1429        let content = bp.content_box(border_box);
1430        let padding = bp.padding_box(border_box);
1431        let margin = bp.margin_box(border_box);
1432
1433        // Each layer must sit strictly inside the next one out.
1434        assert!(margin.origin.x <= border_box.origin.x);
1435        assert!(border_box.origin.x <= padding.origin.x);
1436        assert!(padding.origin.x <= content.origin.x);
1437        assert!(margin.origin.y <= border_box.origin.y);
1438        assert!(border_box.origin.y <= padding.origin.y);
1439        assert!(padding.origin.y <= content.origin.y);
1440
1441        assert!(content.size.width <= padding.size.width);
1442        assert!(padding.size.width <= border_box.size.width);
1443        assert!(border_box.size.width <= margin.size.width);
1444        assert!(content.size.height <= padding.size.height);
1445        assert!(padding.size.height <= border_box.size.height);
1446        assert!(border_box.size.height <= margin.size.height);
1447    }
1448
1449    #[test]
1450    fn content_box_size_floors_at_zero_but_the_origin_still_moves_in() {
1451        let bp = props(
1452            EdgeSizes::default(),
1453            edges(500.0, 500.0, 500.0, 500.0),
1454            edges(500.0, 500.0, 500.0, 500.0),
1455        );
1456        let got = bp.content_box(rect(0.0, 0.0, 10.0, 10.0));
1457        assert_eq!(got.size.width, 0.0, "size must clamp, not go negative");
1458        assert_eq!(got.size.height, 0.0);
1459        assert_eq!(got.origin.x, 1000.0, "origin is not clamped");
1460        assert_eq!(got.origin.y, 1000.0);
1461    }
1462
1463    #[test]
1464    fn padding_box_size_floors_at_zero_for_an_oversized_border() {
1465        let bp = props(EdgeSizes::default(), EdgeSizes::default(), edges(99.0, 99.0, 99.0, 99.0));
1466        let got = bp.padding_box(rect(0.0, 0.0, 10.0, 10.0));
1467        assert_eq!(got.size.width, 0.0);
1468        assert_eq!(got.size.height, 0.0);
1469    }
1470
1471    #[test]
1472    fn margin_box_with_negative_margins_can_shrink_below_zero() {
1473        // Unlike content_box/padding_box, margin_box does NOT floor at 0 — negative
1474        // margins are legal CSS and the box genuinely inverts. Pinning the real
1475        // behaviour so a later "helpful" clamp cannot land unnoticed.
1476        let bp = props(edges(-10.0, -10.0, -10.0, -10.0), EdgeSizes::default(), EdgeSizes::default());
1477        let got = bp.margin_box(rect(0.0, 0.0, 10.0, 10.0));
1478        assert_eq!(got.size.width, -10.0);
1479        assert_eq!(got.size.height, -10.0);
1480        assert_eq!(got.origin.x, 10.0, "a negative left margin pushes the origin right");
1481        assert_eq!(got.origin.y, 10.0);
1482    }
1483
1484    #[test]
1485    fn box_rects_do_not_panic_on_nan_or_infinite_geometry() {
1486        let bp = props(
1487            edges(f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX),
1488            edges(f32::NAN, f32::MAX, 0.0, f32::MIN),
1489            edges(f32::INFINITY, 0.0, f32::NAN, 1.0),
1490        );
1491        let r = rect(f32::NAN, f32::INFINITY, f32::MAX, f32::MIN);
1492        let c = bp.content_box(r);
1493        let p = bp.padding_box(r);
1494        let m = bp.margin_box(r);
1495        // The clamped sizes are the only guaranteed invariant: never negative, never NaN.
1496        for s in [c.size, p.size] {
1497            assert!(!s.width.is_nan() && !s.height.is_nan());
1498            assert!(s.width >= 0.0 && s.height >= 0.0);
1499        }
1500        let _ = m;
1501    }
1502
1503    // ================================================================
1504    // ResolvedBoxProps: mbp / bp getters
1505    // ================================================================
1506
1507    #[test]
1508    fn mbp_and_bp_getters_sum_the_expected_layers() {
1509        let bp = props(
1510            edges(1.0, 2.0, 4.0, 8.0),      // margin: h=10, v=5
1511            edges(10.0, 20.0, 40.0, 80.0),  // padding: h=100, v=50
1512            edges(100.0, 200.0, 400.0, 800.0), // border: h=1000, v=500
1513        );
1514        assert_eq!(bp.horizontal_bp(), 1100.0);
1515        assert_eq!(bp.vertical_bp(), 550.0);
1516        assert_eq!(bp.horizontal_mbp(), 1110.0);
1517        assert_eq!(bp.vertical_mbp(), 555.0);
1518
1519        // mbp is exactly bp plus the margins — the two must never drift apart.
1520        assert_eq!(bp.horizontal_mbp(), bp.horizontal_bp() + bp.margin.horizontal_sum());
1521        assert_eq!(bp.vertical_mbp(), bp.vertical_bp() + bp.margin.vertical_sum());
1522    }
1523
1524    #[test]
1525    fn mbp_and_bp_getters_are_zero_on_a_default_box() {
1526        let bp = ResolvedBoxProps::default();
1527        assert_eq!(bp.horizontal_mbp(), 0.0);
1528        assert_eq!(bp.vertical_mbp(), 0.0);
1529        assert_eq!(bp.horizontal_bp(), 0.0);
1530        assert_eq!(bp.vertical_bp(), 0.0);
1531    }
1532
1533    #[test]
1534    fn mbp_getters_do_not_panic_on_extreme_boxes() {
1535        let bp = props(
1536            edges(f32::MAX, f32::MAX, f32::MAX, f32::MAX),
1537            edges(f32::NAN, 0.0, 0.0, 0.0),
1538            edges(f32::NEG_INFINITY, 0.0, 0.0, 0.0),
1539        );
1540        let _ = bp.horizontal_mbp();
1541        let _ = bp.vertical_mbp();
1542        let _ = bp.horizontal_bp();
1543        let _ = bp.vertical_bp();
1544    }
1545
1546    // ================================================================
1547    // PackedBoxProps: encoding
1548    // ================================================================
1549
1550    #[test]
1551    fn pack_edge_encodes_tenths_of_a_pixel() {
1552        let e = edges(0.0, 1.0, 2.5, 3276.7);
1553        let p = PackedBoxProps::pack_edge(&e);
1554        assert_eq!(p[0], 0);
1555        assert_eq!(p[1], 10);
1556        assert_eq!(p[2], 25);
1557        assert_eq!(p[3], 32767, "the documented +3276.7px maximum");
1558    }
1559
1560    #[test]
1561    fn pack_edge_rounds_to_the_nearest_tenth_rather_than_truncating() {
1562        let p = PackedBoxProps::pack_edge(&edges(1.04, 1.06, -1.04, -1.06));
1563        assert_eq!(p[0], 10, "1.04 rounds down");
1564        assert_eq!(p[1], 11, "1.06 rounds up — truncation would give 10");
1565        assert_eq!(p[2], -10);
1566        assert_eq!(p[3], -11);
1567    }
1568
1569    #[test]
1570    fn pack_edge_saturates_out_of_range_values_instead_of_wrapping() {
1571        // Wrapping would turn a huge positive margin into a huge NEGATIVE one —
1572        // the single nastiest failure mode of this encoding.
1573        let p = PackedBoxProps::pack_edge(&edges(10_000.0, -10_000.0, 1e30, -1e30));
1574        assert_eq!(p[0], i16::MAX);
1575        assert_eq!(p[1], i16::MIN);
1576        assert_eq!(p[2], i16::MAX);
1577        assert_eq!(p[3], i16::MIN);
1578        assert!(p.iter().all(|v| *v == i16::MAX || *v == i16::MIN));
1579    }
1580
1581    #[test]
1582    fn pack_edge_clamps_infinities_to_the_i16_bounds() {
1583        let p = PackedBoxProps::pack_edge(&edges(f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN));
1584        assert_eq!(p[0], i16::MAX);
1585        assert_eq!(p[1], i16::MIN);
1586        assert_eq!(p[2], i16::MAX);
1587        assert_eq!(p[3], i16::MIN);
1588    }
1589
1590    #[test]
1591    fn pack_edge_maps_nan_to_zero_without_panicking() {
1592        // `f32::clamp` passes NaN through (it only panics on a NaN *bound*), and the
1593        // subsequent `as i16` saturates NaN to 0. So a NaN edge encodes as 0px.
1594        let p = PackedBoxProps::pack_edge(&edges(f32::NAN, f32::NAN, f32::NAN, f32::NAN));
1595        assert_eq!(p, [0, 0, 0, 0]);
1596    }
1597
1598    #[test]
1599    fn pack_edge_maps_negative_zero_to_zero() {
1600        let p = PackedBoxProps::pack_edge(&edges(-0.0, -0.0, -0.0, -0.0));
1601        assert_eq!(p, [0, 0, 0, 0]);
1602    }
1603
1604    #[test]
1605    fn unpack_edge_decodes_tenths_and_preserves_the_trbl_order() {
1606        let e = PackedBoxProps::unpack_edge(&[10, 25, -10, 32767]);
1607        assert!(close(e.top, 1.0, 1e-4), "top was {}", e.top);
1608        assert!(close(e.right, 2.5, 1e-4), "right was {}", e.right);
1609        assert!(close(e.bottom, -1.0, 1e-4), "bottom was {}", e.bottom);
1610        assert!(close(e.left, 3276.7, 1e-2), "left was {}", e.left);
1611    }
1612
1613    #[test]
1614    fn unpack_edge_at_the_i16_extremes_stays_finite() {
1615        let e = PackedBoxProps::unpack_edge(&[i16::MAX, i16::MIN, 0, 1]);
1616        assert!(e.top.is_finite() && e.right.is_finite());
1617        assert!(close(e.top, 3276.7, 1e-2));
1618        assert!(close(e.right, -3276.8, 1e-2));
1619        assert_eq!(e.bottom, 0.0);
1620        assert!(close(e.left, 0.1, 1e-6));
1621    }
1622
1623    // ================================================================
1624    // PackedBoxProps: round-trip
1625    // ================================================================
1626
1627    #[test]
1628    fn every_i16_encoding_survives_unpack_then_pack_unchanged() {
1629        // Exhaustive decode->encode identity over the WHOLE i16 domain: if the
1630        // f32 round-off in `unpack_edge` ever drifted by more than half a tenth,
1631        // `pack_edge` would land on a neighbouring code and this would catch it.
1632        for n in i16::MIN..=i16::MAX {
1633            let encoded = [n; 4];
1634            let round_tripped = PackedBoxProps::pack_edge(&PackedBoxProps::unpack_edge(&encoded));
1635            assert_eq!(round_tripped, encoded, "i16 code {n} did not round-trip");
1636        }
1637    }
1638
1639    #[test]
1640    fn pack_then_unpack_preserves_values_to_a_tenth_of_a_pixel() {
1641        let bp = props(
1642            edges(1.0, 2.5, 0.1, 12.3),
1643            edges(0.0, 100.25, 3276.7, -3276.8),
1644            edges(0.5, 0.05, 7.0, 0.0),
1645        );
1646        let out = PackedBoxProps::pack(&bp).unpack();
1647        for (got, want) in [
1648            (out.margin.top, bp.margin.top),
1649            (out.margin.right, bp.margin.right),
1650            (out.margin.bottom, bp.margin.bottom),
1651            (out.margin.left, bp.margin.left),
1652            (out.padding.top, bp.padding.top),
1653            (out.padding.right, bp.padding.right),
1654            (out.padding.bottom, bp.padding.bottom),
1655            (out.padding.left, bp.padding.left),
1656            (out.border.top, bp.border.top),
1657            (out.border.right, bp.border.right),
1658            (out.border.bottom, bp.border.bottom),
1659            (out.border.left, bp.border.left),
1660        ] {
1661            // Half a quantum (0.05) plus a little float slack at the 3276.x extreme.
1662            assert!(close(got, want, 0.051), "{want} round-tripped to {got}");
1663        }
1664    }
1665
1666    #[test]
1667    fn pack_carries_the_margin_auto_flags_through_verbatim() {
1668        // margin_auto is NOT part of the lossy i16 encoding, so it must come back bit-exact.
1669        let bp = ResolvedBoxProps {
1670            margin_auto: MarginAuto { left: true, right: false, top: true, bottom: false },
1671            ..ResolvedBoxProps::default()
1672        };
1673        let out = PackedBoxProps::pack(&bp).unpack();
1674        assert!(out.margin_auto.left);
1675        assert!(!out.margin_auto.right);
1676        assert!(out.margin_auto.top);
1677        assert!(!out.margin_auto.bottom);
1678    }
1679
1680    #[test]
1681    fn pack_keeps_the_three_edge_groups_apart() {
1682        // A copy-paste slip in `pack` would splice margin into padding or border.
1683        let bp = props(
1684            edges(1.0, 1.0, 1.0, 1.0),
1685            edges(2.0, 2.0, 2.0, 2.0),
1686            edges(3.0, 3.0, 3.0, 3.0),
1687        );
1688        let p = PackedBoxProps::pack(&bp);
1689        assert_eq!(p.margin, [10; 4]);
1690        assert_eq!(p.padding, [20; 4]);
1691        assert_eq!(p.border, [30; 4]);
1692    }
1693
1694    #[test]
1695    fn pack_of_an_out_of_range_box_clamps_rather_than_flipping_sign() {
1696        let bp = props(
1697            edges(5000.0, 5000.0, 5000.0, 5000.0), // beyond +3276.7
1698            EdgeSizes::default(),
1699            EdgeSizes::default(),
1700        );
1701        let out = PackedBoxProps::pack(&bp).unpack();
1702        assert!(out.margin.top > 0.0, "a huge margin must not decode as negative");
1703        assert!(close(out.margin.top, 3276.7, 1e-2));
1704    }
1705
1706    #[test]
1707    fn packed_default_is_an_all_zero_box() {
1708        let p = PackedBoxProps::default();
1709        assert_eq!(p.margin, [0; 4]);
1710        assert_eq!(p.horizontal_mbp(), 0.0);
1711        assert_eq!(p.vertical_mbp(), 0.0);
1712        assert_eq!(p.horizontal_bp(), 0.0);
1713        assert_eq!(p.vertical_bp(), 0.0);
1714        let s = p.inner_size(LogicalSize::new(10.0, 10.0), LayoutWritingMode::HorizontalTb);
1715        assert_eq!(s.width, 10.0);
1716        assert_eq!(s.height, 10.0);
1717    }
1718
1719    // ================================================================
1720    // PackedBoxProps: convenience methods must agree with unpack()
1721    // ================================================================
1722
1723    #[test]
1724    fn packed_convenience_methods_match_the_unpacked_equivalents() {
1725        let bp = props(
1726            edges(1.0, 2.5, 4.0, 8.5),
1727            edges(0.5, 1.5, 2.5, 3.5),
1728            edges(2.0, 4.0, 6.0, 8.0),
1729        );
1730        let packed = PackedBoxProps::pack(&bp);
1731        let unpacked = packed.unpack();
1732        let r = rect(10.0, 20.0, 300.0, 200.0);
1733
1734        assert_eq!(packed.content_box(r), unpacked.content_box(r));
1735        assert_eq!(packed.padding_box(r), unpacked.padding_box(r));
1736        assert_eq!(packed.margin_box(r), unpacked.margin_box(r));
1737        assert_eq!(packed.horizontal_mbp(), unpacked.horizontal_mbp());
1738        assert_eq!(packed.vertical_mbp(), unpacked.vertical_mbp());
1739        assert_eq!(packed.horizontal_bp(), unpacked.horizontal_bp());
1740        assert_eq!(packed.vertical_bp(), unpacked.vertical_bp());
1741
1742        for wm in ALL_WM {
1743            let a = packed.inner_size(r.size, wm);
1744            let b = unpacked.inner_size(r.size, wm);
1745            assert_eq!(a.width, b.width, "{wm:?}");
1746            assert_eq!(a.height, b.height, "{wm:?}");
1747        }
1748    }
1749
1750    #[test]
1751    fn packed_inner_size_floors_at_zero_for_a_tiny_box() {
1752        let bp = props(
1753            EdgeSizes::default(),
1754            edges(50.0, 50.0, 50.0, 50.0),
1755            edges(50.0, 50.0, 50.0, 50.0),
1756        );
1757        let packed = PackedBoxProps::pack(&bp);
1758        for wm in ALL_WM {
1759            let s = packed.inner_size(LogicalSize::new(1.0, 1.0), wm);
1760            assert_eq!(s.width, 0.0, "{wm:?}");
1761            assert_eq!(s.height, 0.0, "{wm:?}");
1762        }
1763    }
1764
1765    #[test]
1766    fn packed_box_rects_do_not_panic_on_extreme_input_rects() {
1767        let packed = PackedBoxProps::pack(&props(
1768            edges(3276.7, 3276.7, 3276.7, 3276.7),
1769            edges(3276.7, 3276.7, 3276.7, 3276.7),
1770            edges(3276.7, 3276.7, 3276.7, 3276.7),
1771        ));
1772        for r in [
1773            rect(0.0, 0.0, 0.0, 0.0),
1774            rect(f32::MAX, f32::MIN, f32::MAX, f32::MAX),
1775            rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
1776            rect(-1e30, -1e30, -1e30, -1e30),
1777        ] {
1778            let c = packed.content_box(r);
1779            let p = packed.padding_box(r);
1780            let _ = packed.margin_box(r);
1781            assert!(c.size.width >= 0.0 && !c.size.width.is_nan());
1782            assert!(p.size.height >= 0.0 && !p.size.height.is_nan());
1783        }
1784    }
1785
1786    // ================================================================
1787    // WritingModeContext
1788    // ================================================================
1789
1790    #[test]
1791    fn writing_mode_context_new_stores_what_it_was_given() {
1792        let c = WritingModeContext::new(
1793            LayoutWritingMode::VerticalRl,
1794            StyleDirection::Rtl,
1795            StyleTextOrientation::Mixed,
1796        );
1797        assert_eq!(c.writing_mode, LayoutWritingMode::VerticalRl);
1798        assert_eq!(c.direction, StyleDirection::Rtl);
1799        assert_eq!(c.text_orientation, StyleTextOrientation::Mixed);
1800    }
1801
1802    #[test]
1803    fn text_orientation_upright_forces_the_used_direction_to_ltr() {
1804        // CSS Writing Modes 4 §5.1: `text-orientation: upright` makes the USED value
1805        // of `direction` ltr, even when the author wrote `direction: rtl`.
1806        let c = WritingModeContext::new(
1807            LayoutWritingMode::VerticalRl,
1808            StyleDirection::Rtl,
1809            StyleTextOrientation::Upright,
1810        );
1811        assert_eq!(c.used_direction(), StyleDirection::Ltr);
1812        assert!(!c.is_inline_reversed(), "upright must cancel the RTL reversal");
1813    }
1814
1815    #[test]
1816    fn only_upright_overrides_the_direction() {
1817        for orientation in [StyleTextOrientation::Mixed, StyleTextOrientation::Sideways] {
1818            let c = WritingModeContext::new(
1819                LayoutWritingMode::VerticalRl,
1820                StyleDirection::Rtl,
1821                orientation,
1822            );
1823            assert_eq!(
1824                c.used_direction(),
1825                StyleDirection::Rtl,
1826                "{orientation:?} must not touch the direction"
1827            );
1828            assert!(c.is_inline_reversed());
1829        }
1830    }
1831
1832    #[test]
1833    fn writing_mode_context_new_is_idempotent() {
1834        // Re-feeding a context's own fields back into `new()` must be a fixed point,
1835        // otherwise the upright override would compound across re-resolutions.
1836        for wm in ALL_WM {
1837            for dir in [StyleDirection::Ltr, StyleDirection::Rtl] {
1838                for or in [
1839                    StyleTextOrientation::Mixed,
1840                    StyleTextOrientation::Upright,
1841                    StyleTextOrientation::Sideways,
1842                ] {
1843                    let once = WritingModeContext::new(wm, dir, or);
1844                    let twice = WritingModeContext::new(
1845                        once.writing_mode,
1846                        once.direction,
1847                        once.text_orientation,
1848                    );
1849                    assert_eq!(once, twice, "{wm:?}/{dir:?}/{or:?} is not a fixed point");
1850                }
1851            }
1852        }
1853    }
1854
1855    #[test]
1856    fn is_horizontal_is_true_only_for_horizontal_tb() {
1857        let mk = |wm| WritingModeContext::new(wm, StyleDirection::Ltr, StyleTextOrientation::Mixed);
1858        assert!(mk(LayoutWritingMode::HorizontalTb).is_horizontal());
1859        assert!(!mk(LayoutWritingMode::VerticalRl).is_horizontal());
1860        assert!(!mk(LayoutWritingMode::VerticalLr).is_horizontal());
1861    }
1862
1863    #[test]
1864    fn inline_and_block_axis_predicates_track_is_horizontal() {
1865        // In horizontal-tb, inline size is the width and block size is the height;
1866        // both flip together in vertical modes. They may never disagree.
1867        for wm in ALL_WM {
1868            for dir in [StyleDirection::Ltr, StyleDirection::Rtl] {
1869                let c = WritingModeContext::new(wm, dir, StyleTextOrientation::Mixed);
1870                assert_eq!(c.inline_size_is_width(), c.is_horizontal(), "{wm:?}");
1871                assert_eq!(c.block_size_is_height(), c.is_horizontal(), "{wm:?}");
1872                assert_eq!(
1873                    c.inline_size_is_width(),
1874                    c.block_size_is_height(),
1875                    "{wm:?}: the two axes must flip together"
1876                );
1877            }
1878        }
1879    }
1880
1881    #[test]
1882    fn is_inline_reversed_follows_the_used_direction_not_the_writing_mode() {
1883        for wm in ALL_WM {
1884            let ltr = WritingModeContext::new(wm, StyleDirection::Ltr, StyleTextOrientation::Mixed);
1885            let rtl = WritingModeContext::new(wm, StyleDirection::Rtl, StyleTextOrientation::Mixed);
1886            assert!(!ltr.is_inline_reversed(), "{wm:?} ltr");
1887            assert!(rtl.is_inline_reversed(), "{wm:?} rtl");
1888        }
1889    }
1890
1891    #[test]
1892    fn default_writing_mode_context_is_horizontal_ltr_mixed() {
1893        let c = WritingModeContext::default();
1894        assert_eq!(c.writing_mode, LayoutWritingMode::HorizontalTb);
1895        assert_eq!(c.used_direction(), StyleDirection::Ltr);
1896        assert_eq!(c.text_orientation, StyleTextOrientation::Mixed);
1897        assert!(c.is_horizontal());
1898        assert!(c.inline_size_is_width());
1899        assert!(c.block_size_is_height());
1900        assert!(!c.is_inline_reversed());
1901    }
1902
1903    #[test]
1904    fn default_context_matches_new_with_the_same_arguments() {
1905        let built = WritingModeContext::new(
1906            LayoutWritingMode::HorizontalTb,
1907            StyleDirection::Ltr,
1908            StyleTextOrientation::Mixed,
1909        );
1910        assert_eq!(WritingModeContext::default(), built);
1911    }
1912
1913    #[test]
1914    fn fit_content_clamps_stretch_fit_between_min_and_max_content() {
1915        let is = IntrinsicSizes {
1916            min_content_width: 30.0,
1917            max_content_width: 100.0,
1918            min_content_height: 10.0,
1919            max_content_height: 40.0,
1920            ..Default::default()
1921        };
1922        // stretch-fit within [min, max] → returned as-is.
1923        assert!((is.fit_content_width(60.0) - 60.0).abs() < f32::EPSILON);
1924        assert!((is.fit_content_height(25.0) - 25.0).abs() < f32::EPSILON);
1925        // stretch-fit above max-content → clamped to max-content.
1926        assert!((is.fit_content_width(500.0) - 100.0).abs() < f32::EPSILON);
1927        assert!((is.fit_content_height(500.0) - 40.0).abs() < f32::EPSILON);
1928        // stretch-fit below min-content → clamped up to min-content (min wins over max).
1929        assert!((is.fit_content_width(0.0) - 30.0).abs() < f32::EPSILON);
1930        assert!((is.fit_content_height(0.0) - 10.0).abs() < f32::EPSILON);
1931    }
1932
1933    #[test]
1934    fn intrinsic_sizes_default_has_no_preferred_aspect_ratio() {
1935        assert_eq!(IntrinsicSizes::default().preferred_aspect_ratio, None);
1936    }
1937}