Skip to main content

netget/display/
canvas.rs

1//! Canvas implementation using tiny-skia for 2D graphics rendering
2
3use crate::display::ascii::AsciiRenderer;
4use crate::display::text::TextRenderer;
5use crate::display::types::{Color, DisplayCommand};
6use image::{ImageBuffer, Rgb};
7use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform};
8
9/// Maximum retained pixel surface (RGBA scratch plus RGB output).
10pub const MAX_CANVAS_PIXELS: u64 = 16 * 1024 * 1024;
11pub const MAX_CANVAS_COMMANDS: usize = 10_000;
12pub const MAX_CANVAS_TEXT_BYTES: usize = 1024 * 1024;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct CanvasError(pub &'static str);
16impl std::fmt::Display for CanvasError {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        f.write_str(self.0)
19    }
20}
21impl std::error::Error for CanvasError {}
22
23/// Display canvas that accumulates drawing commands and renders to an image buffer
24pub struct DisplayCanvas {
25    width: u32,
26    height: u32,
27    commands: Vec<DisplayCommand>,
28}
29
30impl DisplayCanvas {
31    /// Create a new canvas with the specified dimensions
32    pub fn new(width: u32, height: u32) -> Self {
33        Self {
34            width,
35            height,
36            commands: Vec::new(),
37        }
38    }
39
40    /// Add a drawing command to the canvas
41    pub fn add_command(&mut self, cmd: DisplayCommand) {
42        self.commands.push(cmd);
43    }
44
45    /// Add multiple drawing commands to the canvas
46    pub fn add_commands(&mut self, cmds: Vec<DisplayCommand>) {
47        self.commands.extend(cmds);
48    }
49
50    /// Clear all drawing commands
51    pub fn clear_commands(&mut self) {
52        drop_commands(std::mem::take(&mut self.commands));
53    }
54
55    /// Compatibility entry point. An invalid/oversized canvas renders an empty
56    /// image; callers that need the reason should use `try_render`.
57    pub fn render(&self) -> ImageBuffer<Rgb<u8>, Vec<u8>> {
58        self.try_render().unwrap_or_else(|_| ImageBuffer::new(0, 0))
59    }
60
61    /// Bounded, fallible software rendering. Nested windows are visited without
62    /// recursion or cloning; one font/glyph cache serves the entire render.
63    pub fn try_render(&self) -> Result<ImageBuffer<Rgb<u8>, Vec<u8>>, CanvasError> {
64        if u64::from(self.width) * u64::from(self.height) > MAX_CANVAS_PIXELS {
65            return Err(CanvasError("canvas exceeds the 16-megapixel limit"));
66        }
67        if self.commands.len() > MAX_CANVAS_COMMANDS {
68            return Err(CanvasError("canvas command limit exceeded"));
69        }
70        let mut pending = self
71            .commands
72            .iter()
73            .rev()
74            .map(|command| (command, 0u32, 0u32))
75            .collect::<Vec<_>>();
76        if pending.len() > MAX_CANVAS_COMMANDS {
77            return Err(CanvasError("canvas command limit exceeded"));
78        }
79        let mut commands = Vec::new();
80        let mut text_bytes = 0usize;
81        while let Some((command, ox, oy)) = pending.pop() {
82            if matches!(command, DisplayCommand::DrawText { font_size, .. } | DisplayCommand::RenderAsciiArt { font_size, .. } if *font_size > super::text::MAX_FONT_SIZE)
83            {
84                return Err(CanvasError("canvas font size exceeds 512 pixels"));
85            }
86            let text = match command {
87                DisplayCommand::DrawText { text, .. }
88                | DisplayCommand::RenderAsciiArt { text, .. } => text.len(),
89                DisplayCommand::DrawButton { label, .. } => label.len(),
90                DisplayCommand::DrawTextBox {
91                    text, placeholder, ..
92                } => text
93                    .len()
94                    .saturating_add(placeholder.as_ref().map_or(0, String::len)),
95                DisplayCommand::DrawWindow {
96                    x,
97                    y,
98                    title,
99                    content,
100                    ..
101                } => {
102                    if content.len()
103                        > MAX_CANVAS_COMMANDS.saturating_sub(commands.len() + pending.len() + 1)
104                    {
105                        return Err(CanvasError("canvas command limit exceeded"));
106                    }
107                    pending.extend(content.iter().rev().map(|child| {
108                        (
109                            child,
110                            ox.saturating_add(*x),
111                            oy.saturating_add(*y).saturating_add(30),
112                        )
113                    }));
114                    title.len()
115                }
116                _ => 0,
117            };
118            text_bytes = text_bytes.saturating_add(text);
119            if text_bytes > MAX_CANVAS_TEXT_BYTES {
120                return Err(CanvasError("canvas text limit exceeded"));
121            }
122            commands.push((command, ox, oy));
123            if commands.len() > MAX_CANVAS_COMMANDS {
124                return Err(CanvasError("canvas command limit exceeded"));
125            }
126        }
127        if self.width == 0 || self.height == 0 {
128            return Ok(ImageBuffer::new(self.width, self.height));
129        }
130        let size = tiny_skia::IntSize::from_wh(self.width, self.height)
131            .ok_or(CanvasError("invalid canvas dimensions"))?;
132        let rgba_len = self.width as usize * self.height as usize * 4;
133        let mut rgba = Vec::new();
134        rgba.try_reserve_exact(rgba_len)
135            .map_err(|_| CanvasError("canvas allocation failed"))?;
136        rgba.resize(rgba_len, 0);
137        let mut pixmap =
138            Pixmap::from_vec(rgba, size).ok_or(CanvasError("invalid pixel surface"))?;
139        let mut renderer = None;
140        for (command, ox, oy) in commands {
141            self.execute_command(&mut pixmap, command, ox, oy, &mut renderer);
142        }
143        pixmap_to_image_buffer(&pixmap)
144    }
145
146    fn execute_command(
147        &self,
148        pixmap: &mut Pixmap,
149        cmd: &DisplayCommand,
150        ox: u32,
151        oy: u32,
152        renderer: &mut Option<TextRenderer>,
153    ) {
154        match cmd {
155            DisplayCommand::SetBackground { color } => self.set_background(pixmap, *color),
156            DisplayCommand::Clear => pixmap.fill(tiny_skia::Color::from_rgba8(0, 0, 0, 255)),
157            DisplayCommand::DrawRectangle {
158                x,
159                y,
160                width,
161                height,
162                color,
163                filled,
164            } => self.draw_rectangle(
165                pixmap,
166                x.saturating_add(ox),
167                y.saturating_add(oy),
168                *width,
169                *height,
170                *color,
171                *filled,
172            ),
173            DisplayCommand::DrawLine {
174                x1,
175                y1,
176                x2,
177                y2,
178                color,
179                width,
180            } => self.draw_line(
181                pixmap,
182                x1.saturating_add(ox),
183                y1.saturating_add(oy),
184                x2.saturating_add(ox),
185                y2.saturating_add(oy),
186                *color,
187                *width,
188            ),
189            DisplayCommand::DrawCircle {
190                x,
191                y,
192                radius,
193                color,
194                filled,
195            } => self.draw_circle(
196                pixmap,
197                x.saturating_add(ox),
198                y.saturating_add(oy),
199                *radius,
200                *color,
201                *filled,
202            ),
203            DisplayCommand::DrawText {
204                x,
205                y,
206                text,
207                font_size,
208                color,
209            } => renderer.get_or_insert_with(TextRenderer::new).draw_text(
210                pixmap,
211                x.saturating_add(ox),
212                y.saturating_add(oy),
213                text,
214                *font_size,
215                *color,
216            ),
217            DisplayCommand::RenderAsciiArt {
218                text,
219                font_size,
220                fg_color,
221                bg_color,
222            } => AsciiRenderer::render_with(
223                renderer.get_or_insert_with(TextRenderer::new),
224                pixmap,
225                text,
226                *font_size,
227                *fg_color,
228                *bg_color,
229            ),
230            DisplayCommand::DrawWindow {
231                x,
232                y,
233                width,
234                height,
235                title,
236                ..
237            } => self.draw_window(
238                pixmap,
239                renderer.get_or_insert_with(TextRenderer::new),
240                x.saturating_add(ox),
241                y.saturating_add(oy),
242                *width,
243                *height,
244                title,
245            ),
246            DisplayCommand::DrawButton {
247                x,
248                y,
249                width,
250                height,
251                label,
252            } => self.draw_button(
253                pixmap,
254                renderer.get_or_insert_with(TextRenderer::new),
255                x.saturating_add(ox),
256                y.saturating_add(oy),
257                *width,
258                *height,
259                label,
260            ),
261            DisplayCommand::DrawTextBox {
262                x,
263                y,
264                width,
265                height,
266                text,
267                placeholder,
268            } => self.draw_textbox(
269                pixmap,
270                renderer.get_or_insert_with(TextRenderer::new),
271                x.saturating_add(ox),
272                y.saturating_add(oy),
273                *width,
274                *height,
275                text,
276                placeholder,
277            ),
278        }
279    }
280
281    fn set_background(&self, pixmap: &mut Pixmap, color: Color) {
282        let sk_color = color_to_tiny_skia(color);
283        pixmap.fill(sk_color);
284    }
285
286    #[allow(clippy::too_many_arguments)]
287    fn draw_rectangle(
288        &self,
289        pixmap: &mut Pixmap,
290        x: u32,
291        y: u32,
292        width: u32,
293        height: u32,
294        color: Color,
295        filled: bool,
296    ) {
297        let Some(rect) =
298            tiny_skia::Rect::from_xywh(x as f32, y as f32, width as f32, height as f32)
299        else {
300            return;
301        };
302        let mut path_builder = PathBuilder::new();
303        path_builder.push_rect(rect);
304        let Some(path) = path_builder.finish() else {
305            return;
306        };
307
308        let mut paint = Paint::default();
309        paint.set_color(color_to_tiny_skia(color));
310        paint.anti_alias = true;
311
312        if filled {
313            pixmap.fill_path(
314                &path,
315                &paint,
316                FillRule::Winding,
317                Transform::identity(),
318                None,
319            );
320        } else {
321            let stroke = Stroke {
322                width: 1.0,
323                ..Default::default()
324            };
325            pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
326        }
327    }
328
329    #[allow(clippy::too_many_arguments)]
330    fn draw_line(
331        &self,
332        pixmap: &mut Pixmap,
333        x1: u32,
334        y1: u32,
335        x2: u32,
336        y2: u32,
337        color: Color,
338        width: u32,
339    ) {
340        let mut path_builder = PathBuilder::new();
341        path_builder.move_to(x1 as f32, y1 as f32);
342        path_builder.line_to(x2 as f32, y2 as f32);
343        let Some(path) = path_builder.finish() else {
344            return;
345        };
346
347        let mut paint = Paint::default();
348        paint.set_color(color_to_tiny_skia(color));
349        paint.anti_alias = true;
350
351        let stroke = Stroke {
352            width: width as f32,
353            ..Default::default()
354        };
355        pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
356    }
357
358    fn draw_circle(
359        &self,
360        pixmap: &mut Pixmap,
361        x: u32,
362        y: u32,
363        radius: u32,
364        color: Color,
365        filled: bool,
366    ) {
367        let mut path_builder = PathBuilder::new();
368        path_builder.push_circle(x as f32, y as f32, radius as f32);
369        let Some(path) = path_builder.finish() else {
370            return;
371        };
372
373        let mut paint = Paint::default();
374        paint.set_color(color_to_tiny_skia(color));
375        paint.anti_alias = true;
376
377        if filled {
378            pixmap.fill_path(
379                &path,
380                &paint,
381                FillRule::Winding,
382                Transform::identity(),
383                None,
384            );
385        } else {
386            let stroke = Stroke {
387                width: 1.0,
388                ..Default::default()
389            };
390            pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
391        }
392    }
393
394    #[allow(clippy::too_many_arguments)]
395    fn draw_window(
396        &self,
397        pixmap: &mut Pixmap,
398        text_renderer: &mut TextRenderer,
399        x: u32,
400        y: u32,
401        width: u32,
402        height: u32,
403        title: &str,
404    ) {
405        // Draw window background
406        self.draw_rectangle(pixmap, x, y, width, height, Color::LIGHT_GRAY, true);
407
408        // Draw window border
409        self.draw_rectangle(pixmap, x, y, width, height, Color::DARK_GRAY, false);
410
411        // Draw title bar
412        self.draw_rectangle(pixmap, x, y, width, 30, Color::BLUE, true);
413
414        // Draw title text
415        text_renderer.draw_text(
416            pixmap,
417            x.saturating_add(10),
418            y.saturating_add(20),
419            title,
420            14,
421            Color::WHITE,
422        );
423    }
424
425    fn draw_button(
426        &self,
427        pixmap: &mut Pixmap,
428        text_renderer: &mut TextRenderer,
429        x: u32,
430        y: u32,
431        width: u32,
432        height: u32,
433        label: &str,
434    ) {
435        // Draw button background
436        self.draw_rectangle(pixmap, x, y, width, height, Color::GRAY, true);
437
438        // Draw button border
439        self.draw_rectangle(pixmap, x, y, width, height, Color::BLACK, false);
440
441        // Draw button label (centered)
442        let label_width = u32::try_from(label.chars().count())
443            .unwrap_or(u32::MAX)
444            .saturating_mul(7);
445        let label_x = x.saturating_add((width / 2).saturating_sub(label_width / 2));
446        let label_y = y.saturating_add(height / 2).saturating_add(5);
447        text_renderer.draw_text(pixmap, label_x, label_y, label, 14, Color::BLACK);
448    }
449
450    #[allow(clippy::too_many_arguments)]
451    fn draw_textbox(
452        &self,
453        pixmap: &mut Pixmap,
454        text_renderer: &mut TextRenderer,
455        x: u32,
456        y: u32,
457        width: u32,
458        height: u32,
459        text: &str,
460        placeholder: &Option<String>,
461    ) {
462        // Draw textbox background
463        self.draw_rectangle(pixmap, x, y, width, height, Color::WHITE, true);
464
465        // Draw textbox border
466        self.draw_rectangle(pixmap, x, y, width, height, Color::GRAY, false);
467
468        // Draw text or placeholder
469        if text.is_empty() {
470            if let Some(ph) = placeholder {
471                text_renderer.draw_text(
472                    pixmap,
473                    x.saturating_add(5),
474                    y.saturating_add(height / 2).saturating_add(5),
475                    ph,
476                    14,
477                    Color::GRAY,
478                );
479            }
480        } else {
481            text_renderer.draw_text(
482                pixmap,
483                x.saturating_add(5),
484                y.saturating_add(height / 2).saturating_add(5),
485                text,
486                14,
487                Color::BLACK,
488            );
489        }
490    }
491}
492
493/// Convert a Color to tiny-skia Color
494fn color_to_tiny_skia(color: Color) -> tiny_skia::Color {
495    tiny_skia::Color::from_rgba8(color.r, color.g, color.b, color.a)
496}
497
498/// Convert tiny-skia Pixmap to image::ImageBuffer
499fn pixmap_to_image_buffer(pixmap: &Pixmap) -> Result<ImageBuffer<Rgb<u8>, Vec<u8>>, CanvasError> {
500    let mut rgb = Vec::new();
501    rgb.try_reserve_exact(pixmap.width() as usize * pixmap.height() as usize * 3)
502        .map_err(|_| CanvasError("canvas RGB allocation failed"))?;
503    for pixel in pixmap.pixels() {
504        rgb.extend_from_slice(&[pixel.red(), pixel.green(), pixel.blue()]);
505    }
506    ImageBuffer::from_raw(pixmap.width(), pixmap.height(), rgb)
507        .ok_or(CanvasError("invalid RGB surface"))
508}
509
510/// Deep command trees are also destroyed iteratively, including rejected input.
511fn drop_commands(mut commands: Vec<DisplayCommand>) {
512    while let Some(mut command) = commands.pop() {
513        if let DisplayCommand::DrawWindow { content, .. } = &mut command {
514            commands.append(content);
515        }
516    }
517}
518
519impl Drop for DisplayCanvas {
520    fn drop(&mut self) {
521        drop_commands(std::mem::take(&mut self.commands));
522    }
523}