kozan_core/html/replaced.rs
1//! Replaced element trait — elements with external/intrinsic content.
2//!
3//! Chrome equivalent: `LayoutReplaced` (layout side) +
4//! `HTMLImageElement::GetNaturalDimensions` (DOM side).
5//!
6//! A "replaced element" is one whose content comes from outside the CSS
7//! formatting model: images, video frames, canvas pixels, embedded documents.
8//! They have **intrinsic dimensions** (natural width, height, aspect ratio)
9//! that layout uses when no explicit CSS size is set.
10//!
11//! # Chrome hierarchy
12//!
13//! ```text
14//! LayoutObject → LayoutBox → LayoutReplaced
15//! ├── LayoutImage (img, CSS images)
16//! ├── LayoutVideo (video)
17//! ├── LayoutIFrame (iframe)
18//! └── LayoutSVGRoot (svg)
19//! ```
20//!
21//! # Kozan approach
22//!
23//! The `ReplacedElement` trait lives on the DOM side (Element level).
24//! During layout, `DocumentLayoutView` checks if a node is replaced
25//! and uses `intrinsic_sizing()` for leaf measurement.
26
27use super::html_element::HtmlElement;
28
29/// Intrinsic sizing information for a replaced element.
30///
31/// Chrome equivalent: `IntrinsicSizingInfo` / `PhysicalNaturalSizingInfo`.
32/// Returned by `ReplacedElement::intrinsic_sizing()`.
33#[derive(Debug, Clone, Copy, Default)]
34pub struct IntrinsicSizing {
35 /// Natural width in CSS pixels. `None` if unknown (e.g., image not loaded).
36 pub width: Option<f32>,
37 /// Natural height in CSS pixels. `None` if unknown.
38 pub height: Option<f32>,
39 /// Natural aspect ratio (width / height). Derived from width/height if both
40 /// are known, or set explicitly (e.g., CSS `aspect-ratio`).
41 pub aspect_ratio: Option<f32>,
42}
43
44impl IntrinsicSizing {
45 /// Create sizing with explicit width and height.
46 #[must_use]
47 pub fn from_size(width: f32, height: f32) -> Self {
48 Self {
49 width: Some(width),
50 height: Some(height),
51 aspect_ratio: if height > 0.0 {
52 Some(width / height)
53 } else {
54 None
55 },
56 }
57 }
58
59 /// Create sizing with only an aspect ratio (e.g., responsive video).
60 #[must_use]
61 pub fn from_ratio(ratio: f32) -> Self {
62 Self {
63 width: None,
64 height: None,
65 aspect_ratio: Some(ratio),
66 }
67 }
68}
69
70/// Shared behavior for replaced elements (img, video, canvas, iframe, svg).
71///
72/// Chrome equivalent: the virtual `GetNaturalDimensions()` on `LayoutReplaced`.
73///
74/// # Implementors
75///
76/// - `HtmlImageElement` — intrinsic size from decoded image
77/// - `HtmlVideoElement` — intrinsic size from video dimensions
78/// - `HtmlCanvasElement` — intrinsic size from canvas width/height attributes
79///
80/// # How it connects to layout
81///
82/// During layout, `DocumentLayoutView` checks `item_is_replaced` on the
83/// Taffy style and reads `intrinsic_sizing()` for natural dimensions.
84pub trait ReplacedElement: HtmlElement {
85 /// Get the element's intrinsic (natural) dimensions.
86 ///
87 /// Chrome equivalent: `LayoutReplaced::GetNaturalDimensions()`.
88 ///
89 /// Returns the current intrinsic size. May change when the external
90 /// resource loads (image decode complete, video metadata received).
91 fn intrinsic_sizing(&self) -> IntrinsicSizing;
92
93 /// Notify that intrinsic dimensions have changed.
94 ///
95 /// Chrome equivalent: `LayoutReplaced::NaturalSizeChanged()`.
96 /// Called when the underlying resource changes size (e.g., image loaded,
97 /// video resolution changed). Invalidates layout.
98 fn notify_intrinsic_size_changed(&self) {
99 // Default: mark layout dirty via handle.
100 // Future: self.handle().mark_layout_dirty();
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn intrinsic_sizing_from_size() {
110 let sizing = IntrinsicSizing::from_size(800.0, 600.0);
111 assert_eq!(sizing.width, Some(800.0));
112 assert_eq!(sizing.height, Some(600.0));
113 assert!((sizing.aspect_ratio.unwrap() - 1.333).abs() < 0.01);
114 }
115
116 #[test]
117 fn intrinsic_sizing_from_ratio() {
118 let sizing = IntrinsicSizing::from_ratio(16.0 / 9.0);
119 assert!(sizing.width.is_none());
120 assert!(sizing.height.is_none());
121 assert!((sizing.aspect_ratio.unwrap() - 1.777).abs() < 0.01);
122 }
123
124 #[test]
125 fn intrinsic_sizing_default() {
126 let sizing = IntrinsicSizing::default();
127 assert!(sizing.width.is_none());
128 assert!(sizing.height.is_none());
129 assert!(sizing.aspect_ratio.is_none());
130 }
131
132 #[test]
133 fn zero_height_no_ratio() {
134 let sizing = IntrinsicSizing::from_size(100.0, 0.0);
135 assert!(sizing.aspect_ratio.is_none());
136 }
137}