Skip to main content

orinium_browser/engine/ui/components/
canvas.rs

1//! Canvas replaced-element rendering backed by recorded 2D commands.
2
3use ui_layout::Style;
4
5use crate::engine::layouter::types::{Color, TextFlowStyle, TextStyle};
6use crate::engine::renderer_model::{Brush, DrawCommand, FillRule, Paint, rect_path};
7use crate::engine::ui::custom_node::{ContentSize, CustomNode};
8
9#[derive(Debug, Clone, PartialEq)]
10pub enum CanvasCommand {
11    FillRect {
12        color: Color,
13        x: f32,
14        y: f32,
15        width: f32,
16        height: f32,
17    },
18    ClearRect {
19        x: f32,
20        y: f32,
21        width: f32,
22        height: f32,
23    },
24    StrokeRect {
25        color: Color,
26        x: f32,
27        y: f32,
28        width: f32,
29        height: f32,
30    },
31}
32
33#[derive(Debug)]
34pub struct CanvasComponent {
35    width: f32,
36    height: f32,
37    commands: Vec<CanvasCommand>,
38}
39
40impl CanvasComponent {
41    pub fn new(width: f32, height: f32, source: &str) -> Self {
42        Self {
43            width,
44            height,
45            commands: parse_commands(source),
46        }
47    }
48}
49
50impl CustomNode for CanvasComponent {
51    fn draw_sized(
52        &self,
53        cmd_buf: &mut Vec<DrawCommand>,
54        _text_style: &TextStyle,
55        _text_flow_style: &TextFlowStyle,
56        _style: &Style,
57        size: ContentSize,
58    ) {
59        let scale_x = if self.width > 0.0 {
60            size.width / self.width
61        } else {
62            1.0
63        };
64        let scale_y = if self.height > 0.0 {
65            size.height / self.height
66        } else {
67            1.0
68        };
69        for command in &self.commands {
70            match *command {
71                CanvasCommand::FillRect {
72                    color,
73                    x,
74                    y,
75                    width,
76                    height,
77                } => draw_rect(cmd_buf, color, x, y, width, height, scale_x, scale_y),
78                CanvasCommand::ClearRect { .. } => {
79                    // TODO: Add a retained transparent pixel surface so clearRect can erase
80                    // previously drawn canvas content.
81                }
82                CanvasCommand::StrokeRect {
83                    color,
84                    x,
85                    y,
86                    width,
87                    height,
88                } => {
89                    let line = 1.0;
90                    draw_rect(cmd_buf, color, x, y, width, line, scale_x, scale_y);
91                    draw_rect(
92                        cmd_buf,
93                        color,
94                        x,
95                        y + height - line,
96                        width,
97                        line,
98                        scale_x,
99                        scale_y,
100                    );
101                    draw_rect(cmd_buf, color, x, y, line, height, scale_x, scale_y);
102                    draw_rect(
103                        cmd_buf,
104                        color,
105                        x + width - line,
106                        y,
107                        line,
108                        height,
109                        scale_x,
110                        scale_y,
111                    );
112                }
113            }
114        }
115    }
116
117    fn intrinsic_size(&self) -> ContentSize {
118        ContentSize {
119            width: self.width,
120            height: self.height,
121        }
122    }
123
124    fn role(&self) -> Option<&'static str> {
125        Some("img")
126    }
127}
128
129#[allow(clippy::too_many_arguments)]
130fn draw_rect(
131    cmd_buf: &mut Vec<DrawCommand>,
132    color: Color,
133    x: f32,
134    y: f32,
135    width: f32,
136    height: f32,
137    scale_x: f32,
138    scale_y: f32,
139) {
140    if width <= 0.0 || height <= 0.0 {
141        return;
142    }
143    cmd_buf.push(DrawCommand::Fill {
144        path: rect_path(x * scale_x, y * scale_y, width * scale_x, height * scale_y),
145        paint: Paint {
146            brush: Brush::Solid(color),
147            opacity: 1.0,
148        },
149        rule: FillRule::NonZero,
150    });
151}
152
153fn parse_commands(source: &str) -> Vec<CanvasCommand> {
154    source.lines().filter_map(parse_command).collect()
155}
156
157fn parse_command(source: &str) -> Option<CanvasCommand> {
158    let mut parts = source.split('|');
159    let name = parts.next()?;
160    let color = parse_color(parts.next().unwrap_or(""));
161    let x = parts.next()?.parse().ok()?;
162    let y = parts.next()?.parse().ok()?;
163    let width = parts.next()?.parse().ok()?;
164    let height = parts.next()?.parse().ok()?;
165    match name {
166        "fillRect" => Some(CanvasCommand::FillRect {
167            color: color?,
168            x,
169            y,
170            width,
171            height,
172        }),
173        "clearRect" => Some(CanvasCommand::ClearRect {
174            x,
175            y,
176            width,
177            height,
178        }),
179        "strokeRect" => Some(CanvasCommand::StrokeRect {
180            color: color?,
181            x,
182            y,
183            width,
184            height,
185        }),
186        _ => None,
187    }
188}
189
190fn parse_color(source: &str) -> Option<Color> {
191    let source = source.trim().to_ascii_lowercase();
192    match source.as_str() {
193        "black" => Some(Color(0, 0, 0, 255)),
194        "white" => Some(Color(255, 255, 255, 255)),
195        "red" => Some(Color(255, 0, 0, 255)),
196        "green" => Some(Color(0, 128, 0, 255)),
197        "blue" => Some(Color(0, 0, 255, 255)),
198        "orange" => Some(Color(255, 165, 0, 255)),
199        "transparent" => Some(Color(0, 0, 0, 0)),
200        _ if source.len() == 7 && source.starts_with('#') => Some(Color(
201            u8::from_str_radix(&source[1..3], 16).ok()?,
202            u8::from_str_radix(&source[3..5], 16).ok()?,
203            u8::from_str_radix(&source[5..7], 16).ok()?,
204            255,
205        )),
206        _ if source.len() == 4 && source.starts_with('#') => {
207            let mut digits = source[1..].chars();
208            let expand = |digit: char| u8::from_str_radix(&format!("{digit}{digit}"), 16).ok();
209            Some(Color(
210                expand(digits.next()?)?,
211                expand(digits.next()?)?,
212                expand(digits.next()?)?,
213                255,
214            ))
215        }
216        _ => None,
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn recorded_rectangles_parse_into_canvas_commands() {
226        let canvas = CanvasComponent::new(
227            150.0,
228            100.0,
229            "fillRect|orange|10|10|130|80\nstrokeRect|#0000ff|0|0|150|100",
230        );
231        assert_eq!(canvas.commands.len(), 2);
232        assert!(matches!(
233            canvas.commands[0],
234            CanvasCommand::FillRect {
235                color: Color(255, 165, 0, 255),
236                ..
237            }
238        ));
239    }
240}