Skip to main content

orinium_browser/engine/ui/components/
image.rs

1//! Replaced-element component for decoded HTML images.
2
3use ui_layout::Style;
4
5use crate::engine::layouter::types::{Color, TextFlowStyle, TextStyle};
6use crate::engine::renderer_model::{Brush, DrawCommand, FillRule, Image, Paint, rect_path};
7use crate::engine::ui::custom_node::{ContentSize, CustomNode};
8
9/// A decoded image participating in layout as a replaced element.
10///
11/// When the image failed to decode (`image == None`), the component renders
12/// a placeholder box with the `alt` text (if any).
13#[derive(Debug)]
14pub struct ImageComponent {
15    pub image: Option<Image>,
16    pub alt: String,
17}
18
19impl ImageComponent {
20    pub fn new(image: Option<Image>, alt: String) -> Self {
21        Self { image, alt }
22    }
23}
24
25impl CustomNode for ImageComponent {
26    fn draw_sized(
27        &self,
28        cmd_buf: &mut Vec<DrawCommand>,
29        text_style: &TextStyle,
30        text_flow_style: &TextFlowStyle,
31        _style: &Style,
32        size: ContentSize,
33    ) {
34        match &self.image {
35            Some(image) => cmd_buf.push(DrawCommand::Fill {
36                path: rect_path(0.0, 0.0, size.width, size.height),
37                paint: Paint {
38                    brush: Brush::Image(image.clone()),
39                    opacity: 1.0,
40                },
41                rule: FillRule::NonZero,
42            }),
43            None => self.draw_placeholder(cmd_buf, text_style, text_flow_style, size),
44        }
45    }
46
47    fn intrinsic_size(&self) -> ContentSize {
48        match &self.image {
49            Some(image) => ContentSize {
50                width: image.width() as f32,
51                height: image.height() as f32,
52            },
53            None => {
54                // Placeholder sized to the alt text (or a fixed box when empty).
55                let line_count = self.alt.lines().count().max(1);
56                ContentSize {
57                    width: 160.0,
58                    height: (line_count * 16) as f32,
59                }
60            }
61        }
62    }
63
64    fn preserves_intrinsic_aspect_ratio(&self) -> bool {
65        true
66    }
67
68    fn role(&self) -> Option<&'static str> {
69        self.image.is_some().then_some("img")
70    }
71
72    fn label(&self) -> Option<String> {
73        (!self.alt.is_empty()).then(|| self.alt.clone())
74    }
75}
76
77impl ImageComponent {
78    /// Renders a placeholder box with a broken-image mark and the `alt` text.
79    fn draw_placeholder(
80        &self,
81        cmd_buf: &mut Vec<DrawCommand>,
82        text_style: &TextStyle,
83        text_flow_style: &TextFlowStyle,
84        size: ContentSize,
85    ) {
86        // Broken-image box (light fill with a thin border drawn as fills).
87        let border = 1.0;
88        cmd_buf.push(DrawCommand::Fill {
89            path: rect_path(0.0, 0.0, size.width, size.height),
90            paint: Paint {
91                brush: Brush::Solid(Color(240, 240, 240, 255)),
92                opacity: 1.0,
93            },
94            rule: FillRule::NonZero,
95        });
96        let border_rects = [
97            [0.0, 0.0, size.width, border],
98            [0.0, size.height - border, size.width, border],
99            [0.0, 0.0, border, size.height],
100            [size.width - border, 0.0, border, size.height],
101        ];
102        for [x, y, w, h] in border_rects {
103            cmd_buf.push(DrawCommand::Fill {
104                path: rect_path(x, y, w, h),
105                paint: Paint {
106                    brush: Brush::Solid(Color(180, 180, 180, 255)),
107                    opacity: 1.0,
108                },
109                rule: FillRule::NonZero,
110            });
111        }
112
113        if self.alt.is_empty() {
114            return;
115        }
116
117        // Render the alt text, wrapping within the placeholder width.
118        let mut style = text_style.clone();
119        style.color = Color(90, 90, 90, 255);
120        let max_width = size.width - 8.0;
121        let x = 4.0;
122        let mut y = 4.0 + text_flow_style.font_size;
123        let mut line = String::new();
124        for ch in self.alt.chars() {
125            if ch == '\n' || line.chars().count() * 8 >= max_width as usize {
126                cmd_buf.push(DrawCommand::DrawText {
127                    text: line.clone().into(),
128                    x,
129                    y,
130                    style: style.clone(),
131                    flow_style: *text_flow_style,
132                });
133                line = String::new();
134                y += text_flow_style.font_size + 2.0;
135            }
136            if ch != '\n' {
137                line.push(ch);
138            }
139        }
140        if !line.is_empty() {
141            cmd_buf.push(DrawCommand::DrawText {
142                text: line.into(),
143                x,
144                y,
145                style,
146                flow_style: *text_flow_style,
147            });
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn image_component_uses_intrinsic_size_and_draw_target() {
158        let image = Image::from_rgba(2, 3, vec![255; 24]).unwrap();
159        let component = ImageComponent::new(Some(image), String::new());
160        assert_eq!(
161            component.intrinsic_size(),
162            ContentSize {
163                width: 2.0,
164                height: 3.0
165            }
166        );
167
168        let mut commands = Vec::new();
169        component.draw_sized(
170            &mut commands,
171            &TextStyle::default(),
172            &TextFlowStyle::default(),
173            &Style::default(),
174            ContentSize {
175                width: 20.0,
176                height: 30.0,
177            },
178        );
179        let DrawCommand::Fill { path, paint, .. } = &commands[0] else {
180            panic!("expected image fill");
181        };
182        assert!(matches!(&paint.brush, Brush::Image(_)));
183        let points = path.subpaths().remove(0);
184        assert!(points.contains(&(20.0, 30.0)));
185    }
186
187    #[test]
188    fn broken_image_renders_placeholder() {
189        let component = ImageComponent::new(None, "example".to_string());
190        let mut commands = Vec::new();
191        component.draw_sized(
192            &mut commands,
193            &TextStyle::default(),
194            &TextFlowStyle::default(),
195            &Style::default(),
196            ContentSize {
197                width: 100.0,
198                height: 50.0,
199            },
200        );
201        // Fill + 4 border rects + alt text.
202        assert!(commands.len() >= 6);
203        assert!(
204            commands
205                .iter()
206                .any(|cmd| matches!(cmd, DrawCommand::DrawText { .. }))
207        );
208    }
209
210    #[test]
211    fn broken_image_intrinsic_sized_to_alt() {
212        let component = ImageComponent::new(None, "hello".to_string());
213        let size = component.intrinsic_size();
214        assert_eq!(size.width, 160.0);
215        assert_eq!(size.height, 16.0);
216    }
217
218    #[test]
219    fn broken_image_exposes_alt_as_label() {
220        let broken = ImageComponent::new(None, "alt text".to_string());
221        assert_eq!(broken.role(), None);
222        assert_eq!(broken.label(), Some("alt text".to_string()));
223
224        let ok = ImageComponent::new(
225            Some(Image::from_rgba(1, 1, vec![255; 4]).unwrap()),
226            "alt text".to_string(),
227        );
228        assert_eq!(ok.role(), Some("img"));
229    }
230}