Skip to main content

unicode_plot/canvas/
mod.rs

1mod ascii;
2mod block;
3mod braille;
4mod core;
5mod density;
6mod dot;
7mod transform;
8
9use crate::color::CanvasColor;
10
11pub use ascii::AsciiCanvas;
12pub use block::BlockCanvas;
13pub use braille::BrailleCanvas;
14pub(crate) use core::CanvasCore;
15pub use density::DensityCanvas;
16pub use dot::DotCanvas;
17pub use transform::{AxisTransform, Scale, Transform2D};
18
19/// Runtime selector for the canvas rendering backend.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21#[non_exhaustive]
22pub enum CanvasType {
23    /// 3x3 pixels per character using ASCII approximation glyphs.
24    Ascii,
25    /// 2x2 pixels per character using Unicode block elements.
26    Block,
27    /// 2x4 pixels per character using Unicode braille patterns (default).
28    Braille,
29    /// Hit-counter canvas that maps density to shade characters.
30    Density,
31    /// 1x2 pixels per character using dot/colon glyphs.
32    Dot,
33}
34
35/// Returns the available canvas types in alphabetical order.
36#[must_use]
37pub const fn canvas_types() -> &'static [CanvasType] {
38    &[
39        CanvasType::Ascii,
40        CanvasType::Block,
41        CanvasType::Braille,
42        CanvasType::Density,
43        CanvasType::Dot,
44    ]
45}
46
47/// A drawable pixel grid that maps data-space coordinates to Unicode characters.
48///
49/// Each canvas type maps characters to a grid of sub-character pixels (e.g.,
50/// braille uses 2x4 pixels per character) and composites colors additively via
51/// [`CanvasColor`].
52pub trait Canvas {
53    /// Sets a single pixel at the given pixel coordinates.
54    fn pixel(&mut self, x: usize, y: usize, color: CanvasColor);
55
56    /// Returns the Unicode glyph for the character cell at `(col, row)`.
57    fn glyph_at(&self, col: usize, row: usize) -> char;
58
59    /// Returns the composited color for the character cell at `(col, row)`.
60    fn color_at(&self, col: usize, row: usize) -> CanvasColor;
61
62    /// The number of character columns in this canvas.
63    fn char_width(&self) -> usize;
64
65    /// The number of character rows in this canvas.
66    fn char_height(&self) -> usize;
67
68    /// The total pixel width (character columns times pixels per character).
69    fn pixel_width(&self) -> usize;
70
71    /// The total pixel height (character rows times pixels per character).
72    fn pixel_height(&self) -> usize;
73
74    /// Returns a reference to this canvas's coordinate transform.
75    fn transform(&self) -> &Transform2D;
76
77    /// Returns a mutable reference to this canvas's coordinate transform.
78    fn transform_mut(&mut self) -> &mut Transform2D;
79
80    /// Plots a single data-space point, transforming it to pixel coordinates.
81    fn point(&mut self, x: f64, y: f64, color: CanvasColor) {
82        let Some(pixel_x) = self.transform().data_to_pixel_x(x) else {
83            return;
84        };
85        let Some(pixel_y) = self.transform().data_to_pixel_y(y) else {
86            return;
87        };
88        let Some(clamped_x) = clamp_pixel_coord(pixel_x, self.pixel_width()) else {
89            return;
90        };
91        let Some(clamped_y) = clamp_pixel_coord(pixel_y, self.pixel_height()) else {
92            return;
93        };
94
95        self.pixel(clamped_x, clamped_y, color);
96    }
97
98    /// Plots multiple data-space points. Silently ignores mismatched lengths.
99    fn points(&mut self, xs: &[f64], ys: &[f64], color: CanvasColor) {
100        if xs.len() != ys.len() {
101            return;
102        }
103
104        for (&x, &y) in xs.iter().zip(ys) {
105            self.point(x, y, color);
106        }
107    }
108
109    /// Draws a line between two data-space points using DDA rasterization.
110    fn line(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, color: CanvasColor) {
111        let Some(start_x) = self.transform().data_to_pixel_x(x1) else {
112            return;
113        };
114        let Some(start_y) = self.transform().data_to_pixel_y(y1) else {
115            return;
116        };
117        let Some(end_x) = self.transform().data_to_pixel_x(x2) else {
118            return;
119        };
120        let Some(end_y) = self.transform().data_to_pixel_y(y2) else {
121            return;
122        };
123
124        if segment_is_outside_bounds(start_x, end_x, self.pixel_width())
125            || segment_is_outside_bounds(start_y, end_y, self.pixel_height())
126        {
127            return;
128        }
129
130        let dx = end_x - start_x;
131        let dy = end_y - start_y;
132        let steps_u32 = dx.unsigned_abs().max(dy.unsigned_abs());
133        let Ok(steps) = usize::try_from(steps_u32) else {
134            return;
135        };
136
137        if steps == 0 {
138            if let (Some(in_bounds_x), Some(in_bounds_y)) = (
139                in_bounds_pixel_coord(start_x, self.pixel_width()),
140                in_bounds_pixel_coord(start_y, self.pixel_height()),
141            ) {
142                self.pixel(in_bounds_x, in_bounds_y, color);
143            }
144            return;
145        }
146
147        let step_denominator = i64::from(steps_u32);
148        for step in 0..=steps {
149            let Ok(step_i64) = i64::try_from(step) else {
150                continue;
151            };
152
153            let step_dx = floor_div_i64(i64::from(dx) * step_i64, step_denominator);
154            let step_dy = floor_div_i64(i64::from(dy) * step_i64, step_denominator);
155            let Ok(pixel_x) = i32::try_from(i64::from(start_x) + step_dx) else {
156                continue;
157            };
158            let Ok(pixel_y) = i32::try_from(i64::from(start_y) + step_dy) else {
159                continue;
160            };
161
162            if let (Some(in_bounds_x), Some(in_bounds_y)) = (
163                in_bounds_pixel_coord(pixel_x, self.pixel_width()),
164                in_bounds_pixel_coord(pixel_y, self.pixel_height()),
165            ) {
166                self.pixel(in_bounds_x, in_bounds_y, color);
167            }
168        }
169    }
170
171    /// Draws connected line segments through consecutive point pairs.
172    fn lines(&mut self, xs: &[f64], ys: &[f64], color: CanvasColor) {
173        if xs.len() != ys.len() {
174            return;
175        }
176
177        for (x_pair, y_pair) in xs.windows(2).zip(ys.windows(2)) {
178            let &[x1, x2] = x_pair else {
179                continue;
180            };
181            let &[y1, y2] = y_pair else {
182                continue;
183            };
184            self.line(x1, y1, x2, y2, color);
185        }
186    }
187
188    /// Iterates over `(glyph, color)` pairs for all cells in a character row.
189    fn row_cells(&self, row: usize) -> impl Iterator<Item = (char, CanvasColor)> + '_ {
190        (0..self.char_width()).map(move |col| (self.glyph_at(col, row), self.color_at(col, row)))
191    }
192}
193
194fn clamp_pixel_coord(coord: i32, upper: usize) -> Option<usize> {
195    if upper == 0 {
196        return None;
197    }
198
199    let upper_i64 = i64::try_from(upper).ok()?;
200    let clamped = i64::from(coord).clamp(0, upper_i64);
201    let adjusted = if clamped == upper_i64 {
202        clamped - 1
203    } else {
204        clamped
205    };
206
207    usize::try_from(adjusted).ok()
208}
209
210fn in_bounds_pixel_coord(coord: i32, upper: usize) -> Option<usize> {
211    if upper == 0 {
212        return None;
213    }
214
215    if coord < 0 {
216        return None;
217    }
218
219    let upper_i64 = i64::try_from(upper).ok()?;
220    let coord_i64 = i64::from(coord);
221    if coord_i64 > upper_i64 {
222        return None;
223    }
224
225    let adjusted = if coord_i64 == upper_i64 {
226        coord_i64 - 1
227    } else {
228        coord_i64
229    };
230
231    usize::try_from(adjusted).ok()
232}
233
234fn segment_is_outside_bounds(start: i32, end: i32, upper: usize) -> bool {
235    let Ok(upper_bound) = i32::try_from(upper) else {
236        return false;
237    };
238
239    (start < 0 && end < 0) || (start > upper_bound && end > upper_bound)
240}
241
242fn floor_div_i64(numerator: i64, denominator: i64) -> i64 {
243    let quotient = numerator / denominator;
244    let remainder = numerator % denominator;
245    if remainder < 0 {
246        quotient - 1
247    } else {
248        quotient
249    }
250}
251
252#[cfg(test)]
253pub(crate) fn write_colored_cell(
254    out: &mut String,
255    glyph: char,
256    color: CanvasColor,
257    enable_color: bool,
258) {
259    use std::fmt::Write as _;
260
261    if !enable_color {
262        out.push(glyph);
263        return;
264    }
265
266    let _ = if color == CanvasColor::NORMAL {
267        write!(out, "\x1b[0m{glyph}")
268    } else {
269        write!(out, "\x1b[{}m{glyph}\x1b[39m", canvas_color_fg_code(color))
270    };
271}
272
273#[cfg(test)]
274fn canvas_color_fg_code(color: CanvasColor) -> u8 {
275    match color.as_u8() {
276        1 => 34,
277        2 => 31,
278        3 => 35,
279        4 => 32,
280        5 => 36,
281        6 => 33,
282        7 => 37,
283        _ => 39,
284    }
285}
286
287#[cfg(test)]
288const X1_DATA: &str = r"
289    0.226582 0.504629 0.933372 0.522172 0.505208
290    0.0997825 0.0443222 0.722906 0.812814 0.245457
291    0.11202 0.000341996 0.380001 0.505277 0.841177
292    0.326561 0.810857 0.850456 0.478053 0.179066
293";
294
295#[cfg(test)]
296const Y1_DATA: &str = r"
297    0.44701 0.219519 0.677372 0.746407 0.735727
298    0.574789 0.538086 0.848053 0.110351 0.796793
299    0.987618 0.801862 0.365172 0.469959 0.306373
300    0.704691 0.540434 0.405842 0.805117 0.014829
301";
302
303#[cfg(test)]
304const X2_DATA: &str = r"
305    0.486366 0.911547 0.900818 0.641951 0.546221
306    0.036135 0.931522 0.196704 0.710775 0.969291
307    0.32546 0.632833 0.815576 0.85278 0.577286
308    0.887004 0.231596 0.288337 0.881386 0.0952668
309    0.609881 0.393795 0.84808 0.453653 0.746048
310    0.924725 0.100012 0.754283 0.769802 0.997368
311    0.0791693 0.234334 0.361207 0.1037 0.713739
312    0.510725 0.649145 0.233949 0.812092 0.914384
313    0.106925 0.570467 0.594956 0.118498 0.699827
314    0.380363 0.843282 0.28761 0.541469 0.568466
315";
316
317#[cfg(test)]
318const Y2_DATA: &str = r"
319    0.417777 0.774845 0.00230619 0.907031 0.971138
320    0.0524795 0.957415 0.328894 0.530493 0.193359
321    0.768422 0.783238 0.607772 0.0261113 0.0849032
322    0.461164 0.613067 0.785021 0.988875 0.131524
323    0.0657328 0.466453 0.560878 0.925428 0.238691
324    0.692385 0.203687 0.441146 0.229352 0.332706
325    0.113543 0.537354 0.965718 0.437026 0.960983
326    0.372294 0.0226533 0.593514 0.657878 0.450696
327    0.436169 0.445539 0.0534673 0.0882236 0.361795
328    0.182991 0.156862 0.734805 0.166076 0.1172
329";
330
331#[cfg(test)]
332fn parse_points(data: &str) -> Vec<f64> {
333    data.split_ascii_whitespace()
334        .map(|value| {
335            value
336                .parse::<f64>()
337                .unwrap_or_else(|_| panic!("invalid fixture point value: {value}"))
338        })
339        .collect()
340}
341
342#[cfg(test)]
343pub(crate) fn draw_reference_canvas_scene<C: Canvas>(canvas: &mut C) {
344    let x1 = parse_points(X1_DATA);
345    let y1 = parse_points(Y1_DATA);
346    let x2 = parse_points(X2_DATA);
347    let y2 = parse_points(Y2_DATA);
348
349    canvas.line(0.0, 0.0, 1.0, 1.0, CanvasColor::BLUE);
350    canvas.points(&x1, &y1, CanvasColor::WHITE);
351    canvas.pixel(2, 4, CanvasColor::CYAN);
352    canvas.points(&x2, &y2, CanvasColor::RED);
353    canvas.line(0.0, 1.0, 0.5, 0.0, CanvasColor::GREEN);
354    canvas.point(0.05, 0.3, CanvasColor::CYAN);
355    canvas.lines(&[1.0, 2.0], &[2.0, 1.0], CanvasColor::NORMAL);
356    canvas.line(0.0, 0.0, 9.0, 9999.0, CanvasColor::YELLOW);
357    canvas.line(0.0, 0.0, 1.0, 1.0, CanvasColor::BLUE);
358    canvas.line(0.1, 0.7, 0.9, 0.6, CanvasColor::RED);
359}
360
361#[cfg(test)]
362pub(crate) fn render_canvas_show<C: Canvas>(canvas: C, color: bool) -> String {
363    use std::fmt::Write as _;
364
365    use crate::color::{NamedColor, TermColor};
366    use crate::plot::Plot;
367    use crate::render::build_rendered_plot;
368
369    let mut plot = Plot::new(canvas);
370    plot.margin = 0;
371    plot.padding = 0;
372
373    let rendered = build_rendered_plot(&plot);
374    let mut out = String::new();
375    for (row_index, row) in rendered.rows().iter().enumerate() {
376        let is_top_or_bottom = row_index == 0 || row_index + 1 == rendered.rows().len();
377        if color && is_top_or_bottom {
378            let border = row.iter().map(|cell| cell.glyph).collect::<String>();
379            let _ = write!(out, "\x1b[90m{border}\x1b[39m");
380            if row_index + 1 < rendered.rows().len() {
381                out.push('\n');
382            }
383            continue;
384        }
385
386        for (col_index, cell) in row.iter().enumerate() {
387            if !color {
388                out.push(cell.glyph);
389                continue;
390            }
391
392            let is_side_border = col_index == 0 || col_index + 1 == row.len();
393            if is_top_or_bottom || is_side_border {
394                let _ = write!(out, "\x1b[90m{}\x1b[39m", cell.glyph);
395                continue;
396            }
397
398            let code = match cell.color {
399                Some(TermColor::Named(NamedColor::Black)) => Some(30),
400                Some(TermColor::Named(NamedColor::Red)) => Some(31),
401                Some(TermColor::Named(NamedColor::Green)) => Some(32),
402                Some(TermColor::Named(NamedColor::Yellow)) => Some(33),
403                Some(TermColor::Named(NamedColor::Blue)) => Some(34),
404                Some(TermColor::Named(NamedColor::Magenta)) => Some(35),
405                Some(TermColor::Named(NamedColor::Cyan)) => Some(36),
406                Some(TermColor::Named(NamedColor::White)) => Some(37),
407                _ => None,
408            };
409
410            if let Some(code) = code {
411                let _ = write!(out, "\x1b[{code}m{}\x1b[39m", cell.glyph);
412            } else {
413                let _ = write!(out, "\x1b[0m{}", cell.glyph);
414            }
415        }
416
417        if row_index + 1 < rendered.rows().len() {
418            out.push('\n');
419        }
420    }
421
422    out
423}
424
425#[cfg(test)]
426mod tests {
427    use super::{AxisTransform, Canvas, CanvasColor, Transform2D};
428    use crate::canvas::Scale;
429
430    #[derive(Debug)]
431    struct RecordingCanvas {
432        char_width: usize,
433        char_height: usize,
434        pixel_width: usize,
435        pixel_height: usize,
436        transform: Transform2D,
437        hits: Vec<(usize, usize, CanvasColor)>,
438    }
439
440    #[derive(Debug)]
441    struct RowCanvas {
442        transform: Transform2D,
443        glyphs: [char; 6],
444        colors: [CanvasColor; 6],
445    }
446
447    impl RowCanvas {
448        fn new(transform: Transform2D) -> Self {
449            Self {
450                transform,
451                glyphs: ['a', 'b', 'c', 'd', 'e', 'f'],
452                colors: [
453                    CanvasColor::BLUE,
454                    CanvasColor::GREEN,
455                    CanvasColor::RED,
456                    CanvasColor::CYAN,
457                    CanvasColor::MAGENTA,
458                    CanvasColor::YELLOW,
459                ],
460            }
461        }
462
463        fn index(col: usize, row: usize) -> usize {
464            row * 3 + col
465        }
466    }
467
468    impl Canvas for RowCanvas {
469        fn pixel(&mut self, _x: usize, _y: usize, _color: CanvasColor) {}
470
471        fn glyph_at(&self, col: usize, row: usize) -> char {
472            self.glyphs[Self::index(col, row)]
473        }
474
475        fn color_at(&self, col: usize, row: usize) -> CanvasColor {
476            self.colors[Self::index(col, row)]
477        }
478
479        fn char_width(&self) -> usize {
480            3
481        }
482
483        fn char_height(&self) -> usize {
484            2
485        }
486
487        fn pixel_width(&self) -> usize {
488            3
489        }
490
491        fn pixel_height(&self) -> usize {
492            2
493        }
494
495        fn transform(&self) -> &Transform2D {
496            &self.transform
497        }
498
499        fn transform_mut(&mut self) -> &mut Transform2D {
500            &mut self.transform
501        }
502    }
503
504    impl RecordingCanvas {
505        fn new(transform: Transform2D) -> Self {
506            Self {
507                char_width: 10,
508                char_height: 10,
509                pixel_width: 10,
510                pixel_height: 10,
511                transform,
512                hits: Vec::new(),
513            }
514        }
515    }
516
517    impl Canvas for RecordingCanvas {
518        fn pixel(&mut self, x: usize, y: usize, color: CanvasColor) {
519            self.hits.push((x, y, color));
520        }
521
522        fn glyph_at(&self, _col: usize, _row: usize) -> char {
523            ' '
524        }
525
526        fn color_at(&self, _col: usize, _row: usize) -> CanvasColor {
527            CanvasColor::NORMAL
528        }
529
530        fn char_width(&self) -> usize {
531            self.char_width
532        }
533
534        fn char_height(&self) -> usize {
535            self.char_height
536        }
537
538        fn pixel_width(&self) -> usize {
539            self.pixel_width
540        }
541
542        fn pixel_height(&self) -> usize {
543            self.pixel_height
544        }
545
546        fn transform(&self) -> &Transform2D {
547            &self.transform
548        }
549
550        fn transform_mut(&mut self) -> &mut Transform2D {
551            &mut self.transform
552        }
553    }
554
555    fn identity_transform() -> Transform2D {
556        let x = AxisTransform::new(0.0, 10.0, 10, Scale::Identity, false)
557            .unwrap_or_else(|| unreachable!("valid transform"));
558        let y = AxisTransform::new(0.0, 10.0, 10, Scale::Identity, false)
559            .unwrap_or_else(|| unreachable!("valid transform"));
560        Transform2D::new(x, y)
561    }
562
563    #[test]
564    fn dda_line_draws_expected_horizontal_vertical_and_diagonal_points() {
565        let mut horizontal = RecordingCanvas::new(identity_transform());
566        horizontal.line(1.0, 1.0, 9.0, 1.0, CanvasColor::BLUE);
567        let expected_horizontal: Vec<_> = (1..=9).map(|x| (x, 1, CanvasColor::BLUE)).collect();
568        assert_eq!(horizontal.hits, expected_horizontal);
569
570        let mut vertical = RecordingCanvas::new(identity_transform());
571        vertical.line(2.0, 1.0, 2.0, 9.0, CanvasColor::GREEN);
572        let expected_vertical: Vec<_> = (1..=9).map(|y| (2, y, CanvasColor::GREEN)).collect();
573        assert_eq!(vertical.hits, expected_vertical);
574
575        let mut diagonal = RecordingCanvas::new(identity_transform());
576        diagonal.line(1.0, 1.0, 9.0, 9.0, CanvasColor::RED);
577        let expected_diagonal: Vec<_> = (1..=9)
578            .map(|value| (value, value, CanvasColor::RED))
579            .collect();
580        assert_eq!(diagonal.hits, expected_diagonal);
581    }
582
583    #[test]
584    fn point_clamps_coordinates_to_canvas_edges() {
585        let mut canvas = RecordingCanvas::new(identity_transform());
586        canvas.point(-5.0, -5.0, CanvasColor::CYAN);
587        canvas.point(10.0, 10.0, CanvasColor::CYAN);
588        canvas.point(15.0, 5.0, CanvasColor::CYAN);
589
590        assert_eq!(
591            canvas.hits,
592            vec![
593                (0, 0, CanvasColor::CYAN),
594                (9, 9, CanvasColor::CYAN),
595                (9, 5, CanvasColor::CYAN)
596            ]
597        );
598    }
599
600    #[test]
601    fn points_and_lines_ignore_mismatched_input_lengths() {
602        let mut points_canvas = RecordingCanvas::new(identity_transform());
603        points_canvas.points(&[0.0, 1.0], &[0.0], CanvasColor::WHITE);
604        assert!(points_canvas.hits.is_empty());
605
606        let mut lines_canvas = RecordingCanvas::new(identity_transform());
607        lines_canvas.lines(&[0.0, 1.0, 2.0], &[0.0, 1.0], CanvasColor::WHITE);
608        assert!(lines_canvas.hits.is_empty());
609    }
610
611    #[test]
612    fn line_handles_reverse_direction_and_zero_length_segments() {
613        let mut reverse = RecordingCanvas::new(identity_transform());
614        reverse.line(9.0, 1.0, 1.0, 1.0, CanvasColor::MAGENTA);
615        let expected_reverse: Vec<_> = (1..=9)
616            .rev()
617            .map(|x| (x, 1, CanvasColor::MAGENTA))
618            .collect();
619        assert_eq!(reverse.hits, expected_reverse);
620
621        let mut point = RecordingCanvas::new(identity_transform());
622        point.line(4.0, 7.0, 4.0, 7.0, CanvasColor::YELLOW);
623        assert_eq!(point.hits, vec![(4, 7, CanvasColor::YELLOW)]);
624    }
625
626    #[test]
627    fn line_rejects_segments_fully_outside_bounds() {
628        let mut canvas = RecordingCanvas::new(identity_transform());
629        canvas.line(-10.0, 5.0, -1.0, 5.0, CanvasColor::RED);
630        canvas.line(20.0, 5.0, 30.0, 5.0, CanvasColor::RED);
631        canvas.line(5.0, -10.0, 5.0, -1.0, CanvasColor::RED);
632        canvas.line(5.0, 20.0, 5.0, 30.0, CanvasColor::RED);
633
634        assert!(canvas.hits.is_empty());
635    }
636
637    #[test]
638    fn row_cells_iterates_in_column_order_with_matching_colors() {
639        let canvas = RowCanvas::new(identity_transform());
640        let row = canvas.row_cells(1).collect::<Vec<_>>();
641
642        assert_eq!(
643            row,
644            vec![
645                ('d', CanvasColor::CYAN),
646                ('e', CanvasColor::MAGENTA),
647                ('f', CanvasColor::YELLOW)
648            ]
649        );
650    }
651}