1use 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
9pub 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
23pub struct DisplayCanvas {
25 width: u32,
26 height: u32,
27 commands: Vec<DisplayCommand>,
28}
29
30impl DisplayCanvas {
31 pub fn new(width: u32, height: u32) -> Self {
33 Self {
34 width,
35 height,
36 commands: Vec::new(),
37 }
38 }
39
40 pub fn add_command(&mut self, cmd: DisplayCommand) {
42 self.commands.push(cmd);
43 }
44
45 pub fn add_commands(&mut self, cmds: Vec<DisplayCommand>) {
47 self.commands.extend(cmds);
48 }
49
50 pub fn clear_commands(&mut self) {
52 drop_commands(std::mem::take(&mut self.commands));
53 }
54
55 pub fn render(&self) -> ImageBuffer<Rgb<u8>, Vec<u8>> {
58 self.try_render().unwrap_or_else(|_| ImageBuffer::new(0, 0))
59 }
60
61 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 self.draw_rectangle(pixmap, x, y, width, height, Color::LIGHT_GRAY, true);
407
408 self.draw_rectangle(pixmap, x, y, width, height, Color::DARK_GRAY, false);
410
411 self.draw_rectangle(pixmap, x, y, width, 30, Color::BLUE, true);
413
414 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 self.draw_rectangle(pixmap, x, y, width, height, Color::GRAY, true);
437
438 self.draw_rectangle(pixmap, x, y, width, height, Color::BLACK, false);
440
441 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 self.draw_rectangle(pixmap, x, y, width, height, Color::WHITE, true);
464
465 self.draw_rectangle(pixmap, x, y, width, height, Color::GRAY, false);
467
468 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
493fn 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
498fn 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
510fn 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}