Skip to main content

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}