orinium_browser/engine/ui/components/
image.rs1use 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#[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 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 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 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 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 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}