Skip to main content

benday_core/raster/
mod.rs

1//! Sub-cell pixel canvas: 2×4 pseudo-pixels per terminal cell, rendered as
2//! braille dots (U+2800 block) or octants (Unicode 16 Symbols for Legacy
3//! Computing Supplement).
4//!
5//! Pixels are stored as a row-major bit pattern per cell: bit `row*2 + col`,
6//! row 0 at the top. One foreground color per cell, last write wins.
7//!
8//! This module also owns `rasterize()`: a compiled `Scene` in, glyphs out. It
9//! never sees a `Theme` — every color it stamps comes from the Scene.
10
11use crate::ansi::Buffer;
12use crate::render::{BarStyle, Rendered};
13use crate::scene::{BarDirection, Scene, SceneMark};
14
15/// Bar-fill glyph ramp for vertical block bars, indexed 0..8 by eighths of a
16/// cell filled from the BOTTOM up.
17const EIGHTHS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
18
19/// Bar-fill glyph ramp for horizontal block bars, indexed 0..8 by eighths of a
20/// cell filled from the LEFT (U+258F down to U+2589); index 8 is `█`, handled
21/// separately like `EIGHTHS`.
22const LEFT_EIGHTHS: [char; 8] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
23
24/// Everything the rasterizer needs beyond the Scene itself. The theme is
25/// deliberately absent: colors are compile-time facts baked into the Scene.
26pub struct RasterOptions {
27    pub marker: Marker,
28    pub bar_style: BarStyle,
29    pub color: bool,
30}
31
32/// Turn a compiled Scene into a `Rendered` (ANSI text + --meta payload).
33/// Reproduces the pre-refactor cell buffer exactly: title, legend, y-axis
34/// chrome + labels, marks, x-axis chrome + labels, all placed from Scene data.
35pub fn rasterize(scene: &Scene, opts: &RasterOptions) -> Rendered {
36    let mut buf = Buffer::new(scene.size.columns, scene.size.rows);
37    // plot.x is `gutter + 1`; the gutter column sits one to its left.
38    let gutter = scene.plot.x.saturating_sub(1);
39    let top = scene.plot.y;
40    let plot_w = scene.plot.w;
41    let plot_h = scene.plot.h;
42    let axis = Some(scene.chrome.axis);
43
44    if let Some(t) = &scene.title {
45        buf.text(t.col, t.row, &t.text, Some(scene.chrome.title));
46    }
47    for entry in &scene.legend {
48        buf.text(entry.col, entry.row, "──", Some(entry.color));
49        buf.text(entry.col + 3, entry.row, &entry.name, axis);
50    }
51
52    // Y axis: the full vertical rule first, then tick marks + labels on top.
53    for r in 0..plot_h {
54        buf.set(gutter, top + r, '│', axis);
55    }
56    for tick in &scene.y_axis.ticks {
57        buf.set(gutter, tick.row, '┤', axis);
58        let len = tick.label.chars().count();
59        buf.text(gutter.saturating_sub(len), tick.row, &tick.label, axis);
60    }
61
62    // Bars draw straight to the buffer. XY marks (line/point/area) all share a
63    // single pixel canvas so overlapping sub-pixels in the same cell merge into
64    // one glyph — matching the pre-refactor single-canvas draw — then blit once.
65    match scene.marks.first() {
66        Some(SceneMark::Bars { .. }) => {
67            for mark in &scene.marks {
68                if let SceneMark::Bars { bars, direction } = mark {
69                    rasterize_bars(
70                        &mut buf, bars, *direction, opts, gutter, top, plot_w, plot_h,
71                    );
72                }
73            }
74        }
75        _ => rasterize_xy(&mut buf, &scene.marks, opts, gutter, top, plot_w, plot_h),
76    }
77
78    // X axis: baseline, category/quantitative tick glyphs, then labels.
79    let axis_row = top + plot_h;
80    buf.set(gutter, axis_row, '└', axis);
81    for c in 0..plot_w {
82        buf.set(gutter + 1 + c, axis_row, '─', axis);
83    }
84    for &c in &scene.x_axis.tick_cols {
85        if c < plot_w {
86            buf.set(gutter + 1 + c, axis_row, '┴', axis);
87        }
88    }
89    for label in &scene.x_axis.labels {
90        buf.text(label.col, label.row, &label.text, axis);
91    }
92
93    Rendered {
94        text: buf.to_ansi(opts.color),
95        meta: scene.meta(),
96    }
97}
98
99#[allow(clippy::too_many_arguments)]
100fn rasterize_bars(
101    buf: &mut Buffer,
102    bars: &[crate::scene::Bar],
103    direction: BarDirection,
104    opts: &RasterOptions,
105    gutter: usize,
106    top: usize,
107    plot_w: usize,
108    plot_h: usize,
109) {
110    match opts.bar_style {
111        BarStyle::Dots => {
112            let mut canvas = PixelCanvas::new(plot_w, plot_h, opts.marker);
113            match direction {
114                BarDirection::Vertical => {
115                    // Verbatim from the pre-generalization single-direction path:
116                    // its exact rounding order (round(h*ph) then fill ph-level..ph)
117                    // must be preserved subpixel-for-subpixel.
118                    let ph = (plot_h * 4) as i64;
119                    for bar in bars {
120                        let x0 = (bar.x0 * plot_w as f64).round() as usize;
121                        let bar_w = (bar.w * plot_w as f64).round() as usize;
122                        let level = (bar.h * ph as f64).round() as i64;
123                        for px in (x0 * 2) as i64..((x0 + bar_w) * 2) as i64 {
124                            for py in (ph - level)..ph {
125                                canvas.set(px, py, bar.color);
126                            }
127                        }
128                    }
129                }
130                BarDirection::Horizontal => {
131                    // Length anchored at the rounded value extent; bar rows are
132                    // exact cell multiples by construction, so the y span is exact.
133                    for bar in bars {
134                        let px_lo = (bar.x0 * plot_w as f64).round() as i64 * 2;
135                        let px_hi = ((bar.x0 + bar.w) * plot_w as f64).round() as i64 * 2;
136                        let py_lo = (bar.y0 * plot_h as f64).round() as i64 * 4;
137                        let py_hi = ((bar.y0 + bar.h) * plot_h as f64).round() as i64 * 4;
138                        for px in px_lo..px_hi {
139                            for py in py_lo..py_hi {
140                                canvas.set(px, py, bar.color);
141                            }
142                        }
143                    }
144                }
145            }
146            for cy in 0..plot_h {
147                for cx in 0..plot_w {
148                    if let Some((ch, color)) = canvas.cell(cx, cy) {
149                        buf.set(gutter + 1 + cx, top + cy, ch, Some(color));
150                    }
151                }
152            }
153        }
154        BarStyle::Blocks => match direction {
155            BarDirection::Vertical => {
156                // Verbatim bottom-up eighths fill from the pre-generalization path.
157                for bar in bars {
158                    let x0 = (bar.x0 * plot_w as f64).round() as usize;
159                    let bar_w = (bar.w * plot_w as f64).round() as usize;
160                    let level = (bar.h * (plot_h * 8) as f64).round() as i64;
161                    for r in 0..plot_h {
162                        let fill = level - ((plot_h - 1 - r) * 8) as i64;
163                        if fill <= 0 {
164                            continue;
165                        }
166                        let ch = if fill >= 8 {
167                            '█'
168                        } else {
169                            EIGHTHS[fill as usize]
170                        };
171                        for c in 0..bar_w {
172                            buf.set(gutter + 1 + x0 + c, top + r, ch, Some(bar.color));
173                        }
174                    }
175                }
176            }
177            BarDirection::Horizontal => {
178                // Left-anchored eighths fill: full columns get `█`, the fractional
179                // end column gets the left-eighth glyph for the remainder. Bar rows
180                // are exact cell multiples, so the y span rounds cleanly.
181                for bar in bars {
182                    let x0 = (bar.x0 * plot_w as f64).round() as usize;
183                    let r0 = (bar.y0 * plot_h as f64).round() as usize;
184                    let r1 = ((bar.y0 + bar.h) * plot_h as f64).round() as usize;
185                    let level = (bar.w * (plot_w * 8) as f64).round() as i64;
186                    for c in 0..plot_w {
187                        let fill = level - (c * 8) as i64;
188                        if fill <= 0 {
189                            continue;
190                        }
191                        let ch = if fill >= 8 {
192                            '█'
193                        } else {
194                            LEFT_EIGHTHS[fill as usize]
195                        };
196                        for r in r0..r1 {
197                            buf.set(gutter + 1 + x0 + c, top + r, ch, Some(bar.color));
198                        }
199                    }
200                }
201            }
202        },
203    }
204}
205
206/// Rasterize line/point/area marks into one shared pixel canvas, then blit it
207/// into the plot area. A mark point is `[frac_x, frac_y]`; frac_y was already
208/// flipped by the compiler (0 = top), so both axes map the same way:
209/// `px = round(frac_x * (pixel_w - 1))`, `py = round(frac_y * (pixel_h - 1))`.
210/// The grid is 2×4 pixels per cell for braille AND octant markers alike.
211fn rasterize_xy(
212    buf: &mut Buffer,
213    marks: &[SceneMark],
214    opts: &RasterOptions,
215    gutter: usize,
216    top: usize,
217    plot_w: usize,
218    plot_h: usize,
219) {
220    let mut canvas = PixelCanvas::new(plot_w, plot_h, opts.marker);
221    let (pw, ph) = (canvas.pixel_width() as i64, canvas.pixel_height() as i64);
222    let px = |fx: f64| (fx * (pw - 1) as f64).round() as i64;
223    let py = |fy: f64| (fy * (ph - 1) as f64).round() as i64;
224
225    for mark in marks {
226        let (series, points, fill, points_mark) = match mark {
227            SceneMark::Points { series, points } => (series, points, false, true),
228            SceneMark::Path { series, points } => (series, points, false, false),
229            SceneMark::Fill { series, points } => (series, points, true, false),
230            SceneMark::Bars { .. } => continue,
231        };
232        let color = series.color;
233
234        if points_mark {
235            // 2×2 dot square centred on each pixel.
236            for p in points {
237                let (cx, cy) = (px(p[0]), py(p[1]));
238                for dx in 0..2 {
239                    for dy in 0..2 {
240                        canvas.set(cx + dx, cy + dy, color);
241                    }
242                }
243            }
244            continue;
245        }
246
247        // Area fill first: per column, interpolate the top edge and fill down
248        // to the pixel-grid bottom, so the line lands on top of the fill.
249        if fill {
250            for w in points.windows(2) {
251                let (x0, y0, x1, y1) = (px(w[0][0]), py(w[0][1]), px(w[1][0]), py(w[1][1]));
252                for x in x0..=x1 {
253                    let t = if x1 == x0 {
254                        0.0
255                    } else {
256                        (x - x0) as f64 / (x1 - x0) as f64
257                    };
258                    let ytop = (y0 as f64 + t * (y1 - y0) as f64).round() as i64;
259                    for yy in ytop..ph {
260                        canvas.set(x, yy, color);
261                    }
262                }
263            }
264        }
265        // A lone point has no window to draw; stamp two horizontal dots.
266        if points.len() == 1 {
267            let (cx, cy) = (px(points[0][0]), py(points[0][1]));
268            canvas.set(cx, cy, color);
269            canvas.set(cx + 1, cy, color);
270        }
271        for w in points.windows(2) {
272            canvas.line(px(w[0][0]), py(w[0][1]), px(w[1][0]), py(w[1][1]), color);
273        }
274    }
275
276    for cy in 0..plot_h {
277        for cx in 0..plot_w {
278            if let Some((ch, color)) = canvas.cell(cx, cy) {
279                buf.set(gutter + 1 + cx, top + cy, ch, Some(color));
280            }
281        }
282    }
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286pub struct Rgb(pub u8, pub u8, pub u8);
287
288impl Rgb {
289    pub fn hex(&self) -> String {
290        format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2)
291    }
292}
293
294impl serde::Serialize for Rgb {
295    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
296        s.serialize_str(&self.hex())
297    }
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub enum Marker {
302    Braille,
303    Octant,
304}
305
306pub struct PixelCanvas {
307    width_cells: usize,
308    height_cells: usize,
309    bits: Vec<u8>,
310    colors: Vec<Option<Rgb>>,
311    marker: Marker,
312}
313
314impl PixelCanvas {
315    pub fn new(width_cells: usize, height_cells: usize, marker: Marker) -> Self {
316        PixelCanvas {
317            width_cells,
318            height_cells,
319            bits: vec![0; width_cells * height_cells],
320            colors: vec![None; width_cells * height_cells],
321            marker,
322        }
323    }
324
325    pub fn pixel_width(&self) -> usize {
326        self.width_cells * 2
327    }
328
329    pub fn pixel_height(&self) -> usize {
330        self.height_cells * 4
331    }
332
333    pub fn set(&mut self, x: i64, y: i64, color: Rgb) {
334        if x < 0 || y < 0 {
335            return;
336        }
337        let (x, y) = (x as usize, y as usize);
338        if x >= self.pixel_width() || y >= self.pixel_height() {
339            return;
340        }
341        let idx = (y / 4) * self.width_cells + x / 2;
342        self.bits[idx] |= 1 << ((y % 4) * 2 + (x % 2));
343        self.colors[idx] = Some(color);
344    }
345
346    /// Bresenham line in pixel coordinates.
347    pub fn line(&mut self, x0: i64, y0: i64, x1: i64, y1: i64, color: Rgb) {
348        let dx = (x1 - x0).abs();
349        let dy = -(y1 - y0).abs();
350        let sx = if x0 < x1 { 1 } else { -1 };
351        let sy = if y0 < y1 { 1 } else { -1 };
352        let mut err = dx + dy;
353        let (mut x, mut y) = (x0, y0);
354        loop {
355            self.set(x, y, color);
356            if x == x1 && y == y1 {
357                break;
358            }
359            let e2 = 2 * err;
360            if e2 >= dy {
361                err += dy;
362                x += sx;
363            }
364            if e2 <= dx {
365                err += dx;
366                y += sy;
367            }
368        }
369    }
370
371    /// The rendered glyph and color for a cell, or None if it's empty.
372    pub fn cell(&self, cx: usize, cy: usize) -> Option<(char, Rgb)> {
373        let idx = cy * self.width_cells + cx;
374        let bits = self.bits[idx];
375        if bits == 0 {
376            return None;
377        }
378        let ch = match self.marker {
379            Marker::Braille => braille_char(bits),
380            Marker::Octant => OCTANTS[bits as usize],
381        };
382        Some((ch, self.colors[idx].unwrap_or(Rgb(255, 255, 255))))
383    }
384}
385
386/// Braille dot values by (row, col), per the Unicode braille encoding
387/// (dots 1-2-3-7 in the left column, 4-5-6-8 in the right).
388const BRAILLE_DOT: [[u16; 2]; 4] = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
389
390fn braille_char(bits: u8) -> char {
391    let mut v: u16 = 0;
392    for (row, cols) in BRAILLE_DOT.iter().enumerate() {
393        for (col, dot) in cols.iter().enumerate() {
394            if bits & (1 << (row * 2 + col)) != 0 {
395                v |= dot;
396            }
397        }
398    }
399    char::from_u32(0x2800 + u32::from(v)).expect("U+2800..=U+28FF are valid chars")
400}
401
402/// Octant glyphs indexed by row-major bit pattern.
403/// Table from ratatui (`ratatui-core/src/symbols/pixel.rs`), MIT license.
404#[rustfmt::skip]
405pub const OCTANTS: [char; 256] = [
406    ' ', '𜺨', '𜺫', '🮂', '𜴀', '▘', '𜴁', '𜴂', '𜴃', '𜴄', '▝', '𜴅', '𜴆', '𜴇', '𜴈', '▀', '𜴉', '𜴊', '𜴋',
407    '𜴌', '🯦', '𜴍', '𜴎', '𜴏', '𜴐', '𜴑', '𜴒', '𜴓', '𜴔', '𜴕', '𜴖', '𜴗', '𜴘', '𜴙', '𜴚', '𜴛', '𜴜', '𜴝',
408    '𜴞', '𜴟', '🯧', '𜴠', '𜴡', '𜴢', '𜴣', '𜴤', '𜴥', '𜴦', '𜴧', '𜴨', '𜴩', '𜴪', '𜴫', '𜴬', '𜴭', '𜴮', '𜴯',
409    '𜴰', '𜴱', '𜴲', '𜴳', '𜴴', '𜴵', '🮅', '𜺣', '𜴶', '𜴷', '𜴸', '𜴹', '𜴺', '𜴻', '𜴼', '𜴽', '𜴾', '𜴿', '𜵀',
410    '𜵁', '𜵂', '𜵃', '𜵄', '▖', '𜵅', '𜵆', '𜵇', '𜵈', '▌', '𜵉', '𜵊', '𜵋', '𜵌', '▞', '𜵍', '𜵎', '𜵏', '𜵐',
411    '▛', '𜵑', '𜵒', '𜵓', '𜵔', '𜵕', '𜵖', '𜵗', '𜵘', '𜵙', '𜵚', '𜵛', '𜵜', '𜵝', '𜵞', '𜵟', '𜵠', '𜵡', '𜵢',
412    '𜵣', '𜵤', '𜵥', '𜵦', '𜵧', '𜵨', '𜵩', '𜵪', '𜵫', '𜵬', '𜵭', '𜵮', '𜵯', '𜵰', '𜺠', '𜵱', '𜵲', '𜵳', '𜵴',
413    '𜵵', '𜵶', '𜵷', '𜵸', '𜵹', '𜵺', '𜵻', '𜵼', '𜵽', '𜵾', '𜵿', '𜶀', '𜶁', '𜶂', '𜶃', '𜶄', '𜶅', '𜶆', '𜶇',
414    '𜶈', '𜶉', '𜶊', '𜶋', '𜶌', '𜶍', '𜶎', '𜶏', '▗', '𜶐', '𜶑', '𜶒', '𜶓', '▚', '𜶔', '𜶕', '𜶖', '𜶗', '▐',
415    '𜶘', '𜶙', '𜶚', '𜶛', '▜', '𜶜', '𜶝', '𜶞', '𜶟', '𜶠', '𜶡', '𜶢', '𜶣', '𜶤', '𜶥', '𜶦', '𜶧', '𜶨', '𜶩',
416    '𜶪', '𜶫', '▂', '𜶬', '𜶭', '𜶮', '𜶯', '𜶰', '𜶱', '𜶲', '𜶳', '𜶴', '𜶵', '𜶶', '𜶷', '𜶸', '𜶹', '𜶺', '𜶻',
417    '𜶼', '𜶽', '𜶾', '𜶿', '𜷀', '𜷁', '𜷂', '𜷃', '𜷄', '𜷅', '𜷆', '𜷇', '𜷈', '𜷉', '𜷊', '𜷋', '𜷌', '𜷍', '𜷎',
418    '𜷏', '𜷐', '𜷑', '𜷒', '𜷓', '𜷔', '𜷕', '𜷖', '𜷗', '𜷘', '𜷙', '𜷚', '▄', '𜷛', '𜷜', '𜷝', '𜷞', '▙', '𜷟',
419    '𜷠', '𜷡', '𜷢', '▟', '𜷣', '▆', '𜷤', '𜷥', '█',
420];
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn braille_bit_mapping() {
428        assert_eq!(braille_char(0b0000_0001), '⠁'); // top-left dot
429        assert_eq!(braille_char(0b1111_1111), '⣿'); // all eight dots
430    }
431
432    #[test]
433    fn octant_table_landmarks() {
434        assert_eq!(OCTANTS[0b0000_1111], '▀'); // top four pixels = upper half
435        assert_eq!(OCTANTS[255], '█');
436    }
437
438    #[test]
439    fn canvas_maps_pixels_to_cells() {
440        let mut c = PixelCanvas::new(2, 1, Marker::Braille);
441        c.set(0, 0, Rgb(255, 0, 0));
442        assert_eq!(c.cell(0, 0), Some(('⠁', Rgb(255, 0, 0))));
443        assert_eq!(c.cell(1, 0), None);
444    }
445
446    /// Rasterize a single bar into a fresh buffer and return the plot rows as
447    /// strings. Column 0 is the (empty) gutter — `rasterize_bars` draws marks at
448    /// `gutter + 1 + cx`, so with `gutter = 0` bars start at column 1 and every
449    /// row carries one leading gutter space.
450    fn bar_rows(
451        direction: BarDirection,
452        bar_style: BarStyle,
453        plot_w: usize,
454        plot_h: usize,
455        bar: crate::scene::Bar,
456    ) -> Vec<String> {
457        let mut buf = Buffer::new(plot_w + 1, plot_h);
458        let opts = RasterOptions {
459            marker: Marker::Octant,
460            bar_style,
461            color: false,
462        };
463        rasterize_bars(&mut buf, &[bar], direction, &opts, 0, 0, plot_w, plot_h);
464        let out = buf.to_ansi(false);
465        let mut rows: Vec<String> = out.split('\n').map(str::to_string).collect();
466        rows.pop(); // trailing newline after the last row
467        rows
468    }
469
470    #[test]
471    fn rasterize_bars_glyph_rows() {
472        use crate::scene::Bar;
473        use BarDirection::{Horizontal, Vertical};
474
475        struct Case {
476            name: &'static str,
477            direction: BarDirection,
478            style: BarStyle,
479            plot_w: usize,
480            plot_h: usize,
481            bar: Bar,
482            expected: &'static [&'static str],
483        }
484
485        let color = Rgb(1, 2, 3);
486        let cases = [
487            // Vertical blocks: h = 0.75 over 2 rows → 12 eighths. Bottom row full
488            // (`█`), top row 4 eighths from the bottom (`▄`).
489            Case {
490                name: "vertical blocks",
491                direction: Vertical,
492                style: BarStyle::Blocks,
493                plot_w: 1,
494                plot_h: 2,
495                bar: Bar {
496                    x0: 0.0,
497                    y0: 0.25,
498                    w: 1.0,
499                    h: 0.75,
500                    color,
501                },
502                expected: &[" ▄", " █"],
503            },
504            // Horizontal blocks: w = 0.75 over 2 cols → 12 eighths. Left col full
505            // (`█`), end col 4 eighths from the left (`▌`).
506            Case {
507                name: "horizontal blocks",
508                direction: Horizontal,
509                style: BarStyle::Blocks,
510                plot_w: 2,
511                plot_h: 1,
512                bar: Bar {
513                    x0: 0.0,
514                    y0: 0.0,
515                    w: 0.75,
516                    h: 1.0,
517                    color,
518                },
519                expected: &[" █▌"],
520            },
521            // Vertical dots: h = 0.5 over 2 cells fills the bottom cell fully (`█`),
522            // top cell empty.
523            Case {
524                name: "vertical dots",
525                direction: Vertical,
526                style: BarStyle::Dots,
527                plot_w: 1,
528                plot_h: 2,
529                bar: Bar {
530                    x0: 0.0,
531                    y0: 0.5,
532                    w: 1.0,
533                    h: 0.5,
534                    color,
535                },
536                expected: &["", " █"],
537            },
538            // Horizontal dots: w = 1.0 over 2 cells fills both cells fully (`██`).
539            Case {
540                name: "horizontal dots",
541                direction: Horizontal,
542                style: BarStyle::Dots,
543                plot_w: 2,
544                plot_h: 1,
545                bar: Bar {
546                    x0: 0.0,
547                    y0: 0.0,
548                    w: 1.0,
549                    h: 1.0,
550                    color,
551                },
552                expected: &[" ██"],
553            },
554        ];
555
556        for case in cases {
557            let rows = bar_rows(
558                case.direction,
559                case.style,
560                case.plot_w,
561                case.plot_h,
562                case.bar,
563            );
564            assert_eq!(rows, case.expected, "case: {}", case.name);
565        }
566    }
567}