Skip to main content

piw/
canvas.rs

1//! Port of `src/render/canvas.ts`: a sparse character grid where box-drawing
2//! characters merge by connectivity (│ crossing ─ becomes ┼).
3
4use std::collections::HashMap;
5use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum CanvasStyle {
9    Plain,
10    Dim,
11    Taken,
12    ActiveEdge,
13    Back,
14    NodeText,
15    NodeDim,
16    NodeFocusText,
17    NodeHeader,
18    NodeBorderDim,
19    NodeBorderActive,
20    NodeBorderReplay,
21    NodeBorderOk,
22    NodeBorderFail,
23    NodeBorderTimedOut,
24    NodeBorderWarn,
25    NodeBorderCancelled,
26    Active,
27    Replay,
28    Ok,
29    Fail,
30    TimedOut,
31    Warn,
32    Cancelled,
33    Branch,
34    BranchFocus,
35    Agent,
36    AgentFocus,
37    Compute,
38    ComputeFocus,
39    Action,
40    ActionFocus,
41    Checkpoint,
42    CheckpointFocus,
43}
44
45impl CanvasStyle {
46    /// Styles later in the priority order win when merged lines overlap.
47    fn priority(self) -> u8 {
48        match self {
49            CanvasStyle::Plain => 0,
50            CanvasStyle::Dim => 1,
51            CanvasStyle::Back => 2,
52            CanvasStyle::Taken => 3,
53            CanvasStyle::ActiveEdge => 4,
54            CanvasStyle::NodeText
55            | CanvasStyle::NodeDim
56            | CanvasStyle::NodeFocusText
57            | CanvasStyle::NodeHeader => 5,
58            CanvasStyle::NodeBorderDim
59            | CanvasStyle::NodeBorderActive
60            | CanvasStyle::NodeBorderReplay
61            | CanvasStyle::NodeBorderOk
62            | CanvasStyle::NodeBorderFail
63            | CanvasStyle::NodeBorderTimedOut
64            | CanvasStyle::NodeBorderWarn
65            | CanvasStyle::NodeBorderCancelled
66            | CanvasStyle::Warn
67            | CanvasStyle::Cancelled => 6,
68            CanvasStyle::Ok => 7,
69            CanvasStyle::Fail | CanvasStyle::TimedOut => 8,
70            CanvasStyle::Branch
71            | CanvasStyle::BranchFocus
72            | CanvasStyle::Agent
73            | CanvasStyle::AgentFocus
74            | CanvasStyle::Compute
75            | CanvasStyle::ComputeFocus
76            | CanvasStyle::Action
77            | CanvasStyle::ActionFocus
78            | CanvasStyle::Checkpoint
79            | CanvasStyle::CheckpointFocus => 9,
80            CanvasStyle::Replay => 10,
81            CanvasStyle::Active => 11,
82        }
83    }
84}
85
86fn merge_styles(a: CanvasStyle, b: CanvasStyle) -> CanvasStyle {
87    if a.priority() >= b.priority() {
88        a
89    } else {
90        b
91    }
92}
93
94pub const UP: u8 = 1;
95pub const DOWN: u8 = 2;
96pub const LEFT: u8 = 4;
97pub const RIGHT: u8 = 8;
98
99/// Which sides each box-drawing character connects to.
100pub fn char_to_mask(char: char) -> Option<u8> {
101    Some(match char {
102        '─' => LEFT | RIGHT,
103        '│' => UP | DOWN,
104        '┌' => DOWN | RIGHT,
105        '┐' => DOWN | LEFT,
106        '└' => UP | RIGHT,
107        '┘' => UP | LEFT,
108        '├' => UP | DOWN | RIGHT,
109        '┤' => UP | DOWN | LEFT,
110        '┬' => DOWN | LEFT | RIGHT,
111        '┴' => UP | LEFT | RIGHT,
112        '┼' => UP | DOWN | LEFT | RIGHT,
113        _ => return None,
114    })
115}
116
117fn mask_to_char(mask: u8) -> Option<char> {
118    Some(match mask {
119        m if m == (LEFT | RIGHT) => '─',
120        m if m == (UP | DOWN) => '│',
121        m if m == (DOWN | RIGHT) => '┌',
122        m if m == (DOWN | LEFT) => '┐',
123        m if m == (UP | RIGHT) => '└',
124        m if m == (UP | LEFT) => '┘',
125        m if m == (UP | DOWN | RIGHT) => '├',
126        m if m == (UP | DOWN | LEFT) => '┤',
127        m if m == (DOWN | LEFT | RIGHT) => '┬',
128        m if m == (UP | LEFT | RIGHT) => '┴',
129        m if m == (UP | DOWN | LEFT | RIGHT) => '┼',
130        _ => return None,
131    })
132}
133
134#[derive(Debug, Clone, Copy)]
135struct CanvasChar {
136    char: Option<char>,
137    style: CanvasStyle,
138}
139
140/// A styled run of consecutive characters on one canvas row.
141pub type StyledRun = (String, CanvasStyle);
142
143#[derive(Clone, Default)]
144pub struct CharCanvas {
145    cells: HashMap<i64, HashMap<i64, CanvasChar>>,
146    combining: HashMap<(i64, i64), String>,
147    max_x: i64,
148    max_y: i64,
149}
150
151impl CharCanvas {
152    pub fn new() -> Self {
153        Self::default()
154    }
155
156    fn row(&mut self, y: i64) -> &mut HashMap<i64, CanvasChar> {
157        self.max_y = self.max_y.max(y);
158        self.cells.entry(y).or_default()
159    }
160
161    /// Place a single character, merging box-drawing connectivity.
162    pub fn put(&mut self, x: i64, y: i64, char: char, style: CanvasStyle) {
163        if x < 0 || y < 0 {
164            return;
165        }
166        self.max_y = self.max_y.max(y);
167        self.max_x = self.max_x.max(x);
168        self.combining.remove(&(x, y));
169        let row = self.cells.entry(y).or_default();
170        // Spaces never occupy cells; text_over_run handles deliberate padding.
171        if char == ' ' {
172            return;
173        }
174        if let Some(existing) = row.get(&x).copied() {
175            if existing.char.is_none() {
176                return;
177            }
178            if existing.char == Some(' ') {
179                row.insert(
180                    x,
181                    CanvasChar {
182                        char: Some(char),
183                        style,
184                    },
185                );
186                return;
187            }
188            let existing_mask = existing.char.and_then(char_to_mask);
189            let incoming_mask = char_to_mask(char);
190            if let (Some(existing_mask), Some(incoming_mask)) = (existing_mask, incoming_mask) {
191                row.insert(
192                    x,
193                    CanvasChar {
194                        char: Some(mask_to_char(existing_mask | incoming_mask).unwrap_or(char)),
195                        style: merge_styles(existing.style, style),
196                    },
197                );
198                return;
199            }
200            // Non-line characters (labels, glyphs, arrows) win over lines;
201            // between two non-line characters the newest wins.
202            if existing_mask.is_none() && incoming_mask.is_some() {
203                return;
204            }
205        }
206        row.insert(
207            x,
208            CanvasChar {
209                char: Some(char),
210                style,
211            },
212        );
213    }
214
215    fn write_text(
216        &mut self,
217        x: i64,
218        y: i64,
219        value: &str,
220        style: CanvasStyle,
221        preserve_spaces: bool,
222    ) {
223        let mut cursor = x;
224        for char in value.chars() {
225            let width = UnicodeWidthChar::width(char).unwrap_or(0) as i64;
226            if width == 0 {
227                let mut anchor = cursor - 1;
228                while self
229                    .cells
230                    .get(&y)
231                    .and_then(|row| row.get(&anchor))
232                    .is_some_and(|cell| cell.char.is_none())
233                {
234                    anchor -= 1;
235                }
236                let attach = self
237                    .cells
238                    .get(&y)
239                    .and_then(|row| row.get(&anchor))
240                    .is_some_and(|cell| cell.char.is_some_and(|value| value != ' '));
241                if attach {
242                    self.combining.entry((anchor, y)).or_default().push(char);
243                }
244                continue;
245            }
246            if char == ' ' {
247                if preserve_spaces {
248                    self.row(y).insert(
249                        cursor,
250                        CanvasChar {
251                            char: Some(char),
252                            style,
253                        },
254                    );
255                }
256            } else {
257                self.put(cursor, y, char, style);
258            }
259            for offset in 1..width {
260                self.row(y)
261                    .insert(cursor + offset, CanvasChar { char: None, style });
262            }
263            cursor += width;
264        }
265        self.max_x = self.max_x.max(cursor - 1);
266    }
267
268    /// Write a text run left to right in terminal display cells.
269    pub fn text(&mut self, x: i64, y: i64, value: &str, style: CanvasStyle) {
270        self.write_text(x, y, value, style, false);
271    }
272
273    /// Write text only when every target cell is empty. Returns whether the
274    /// text was written.
275    pub fn text_if_empty(&mut self, x: i64, y: i64, value: &str, style: CanvasStyle) -> bool {
276        if x < 0 || y < 0 {
277            return false;
278        }
279        let width = UnicodeWidthStr::width(value) as i64;
280        if let Some(row) = self.cells.get(&y) {
281            for index in 0..width {
282                if row.contains_key(&(x + index)) {
283                    return false;
284                }
285            }
286        }
287        self.text(x, y, value, style);
288        true
289    }
290
291    /// Write text over a plain horizontal run, replacing `─` cells only.
292    /// Refuses unless every target cell and both flanking cells are exactly
293    /// `─`. Spaces in `value` become real blanks on purpose.
294    pub fn text_over_run(&mut self, x: i64, y: i64, value: &str, style: CanvasStyle) -> bool {
295        if x < 1 || y < 0 {
296            return false;
297        }
298        let width = UnicodeWidthStr::width(value) as i64;
299        let row = self.cells.get(&y);
300        for index in -1..=width {
301            let is_dash = row
302                .and_then(|row| row.get(&(x + index)))
303                .is_some_and(|cell| cell.char == Some('─'));
304            if !is_dash {
305                return false;
306            }
307        }
308        for index in 0..width {
309            self.row(y).remove(&(x + index));
310            self.combining.remove(&(x + index, y));
311        }
312        self.write_text(x, y, value, style, true);
313        true
314    }
315
316    /// Fill a rectangle with intentional styled spaces. Unlike `put`, this
317    /// preserves spaces so node cards can carry a background color.
318    pub fn fill_rect(&mut self, x: i64, y: i64, width: i64, height: i64, style: CanvasStyle) {
319        if x < 0 || y < 0 || width <= 0 || height <= 0 {
320            return;
321        }
322        self.max_x = self.max_x.max(x + width - 1);
323        self.max_y = self.max_y.max(y + height - 1);
324        for row_y in y..y + height {
325            let row = self.cells.entry(row_y).or_default();
326            for column_x in x..x + width {
327                self.combining.remove(&(column_x, row_y));
328                row.insert(
329                    column_x,
330                    CanvasChar {
331                        char: Some(' '),
332                        style,
333                    },
334                );
335            }
336        }
337    }
338
339    pub fn hline(&mut self, y: i64, x1: i64, x2: i64, style: CanvasStyle) {
340        let (start, end) = if x1 <= x2 { (x1, x2) } else { (x2, x1) };
341        for x in start..=end {
342            self.put(x, y, '─', style);
343        }
344    }
345
346    pub fn vline(&mut self, x: i64, y1: i64, y2: i64, style: CanvasStyle) {
347        let (start, end) = if y1 <= y2 { (y1, y2) } else { (y2, y1) };
348        for y in start..=end {
349            self.put(x, y, '│', style);
350        }
351    }
352
353    pub fn size(&self) -> (usize, usize) {
354        (
355            usize::try_from(self.max_x.saturating_add(1)).unwrap_or(0),
356            usize::try_from(self.max_y.saturating_add(1)).unwrap_or(0),
357        )
358    }
359
360    /// Materialize only the requested viewport. The complete logical scene
361    /// remains in sparse cells, but large off-screen rows and columns never
362    /// become strings or Ratatui spans.
363    pub fn render_runs_window(
364        &self,
365        origin_x: i64,
366        origin_y: i64,
367        width: usize,
368        height: usize,
369    ) -> Vec<Vec<StyledRun>> {
370        let mut lines = Vec::with_capacity(height);
371        for viewport_y in 0..height {
372            let y = origin_y + viewport_y as i64;
373            let row = (y >= 0).then(|| self.cells.get(&y)).flatten();
374            let mut runs: Vec<StyledRun> = Vec::new();
375            let mut run_text = String::new();
376            let mut run_style = CanvasStyle::Plain;
377            for viewport_x in 0..width {
378                let x = origin_x + viewport_x as i64;
379                let (char, style) = if x < 0 {
380                    (Some(' '), CanvasStyle::Plain)
381                } else {
382                    row.and_then(|row| row.get(&x)).map_or(
383                        (Some(' '), CanvasStyle::Plain),
384                        |cell| {
385                            let char = match cell.char {
386                                Some(char)
387                                    if UnicodeWidthChar::width(char).unwrap_or(0) > 1
388                                        && viewport_x
389                                            + UnicodeWidthChar::width(char).unwrap_or(0)
390                                            > width =>
391                                {
392                                    Some(' ')
393                                }
394                                None if viewport_x == 0 => Some(' '),
395                                value => value,
396                            };
397                            (char, cell.style)
398                        },
399                    )
400                };
401                if style != run_style {
402                    if !run_text.is_empty() {
403                        runs.push((std::mem::take(&mut run_text), run_style));
404                    }
405                    run_style = style;
406                }
407                if let Some(char) = char {
408                    run_text.push(char);
409                    if let Some(combining) = self.combining.get(&(x, y)) {
410                        run_text.push_str(combining);
411                    }
412                }
413            }
414            if !run_text.is_empty() {
415                runs.push((run_text, run_style));
416            }
417            lines.push(runs);
418        }
419        lines
420    }
421
422    /// Render to rows of styled runs, gaps filled with plain spaces.
423    /// Trailing whitespace is trimmed from every row.
424    pub fn render_runs(&self) -> Vec<Vec<StyledRun>> {
425        let mut lines = Vec::new();
426        for y in 0..=self.max_y {
427            let Some(row) = self.cells.get(&y).filter(|row| !row.is_empty()) else {
428                lines.push(Vec::new());
429                continue;
430            };
431            // Trim trailing whitespace: find the last occupied x.
432            let last_x = row.keys().copied().max().unwrap_or(-1);
433            let mut runs: Vec<StyledRun> = Vec::new();
434            let mut run_text = String::new();
435            let mut run_style = CanvasStyle::Plain;
436            for x in 0..=last_x.min(self.max_x) {
437                let (char, style) = match row.get(&x) {
438                    Some(cell) => (cell.char, cell.style),
439                    None => (Some(' '), CanvasStyle::Plain),
440                };
441                if style != run_style {
442                    if !run_text.is_empty() {
443                        runs.push((std::mem::take(&mut run_text), run_style));
444                    }
445                    run_style = style;
446                }
447                if let Some(char) = char {
448                    run_text.push(char);
449                    if let Some(combining) = self.combining.get(&(x, y)) {
450                        run_text.push_str(combining);
451                    }
452                }
453            }
454            if !run_text.is_empty() {
455                runs.push((run_text, run_style));
456            }
457            lines.push(runs);
458        }
459        lines
460    }
461
462    /// Render to plain text lines (trailing whitespace trimmed), matching the
463    /// TypeScript renderer with colors disabled.
464    pub fn render_plain(&self) -> Vec<String> {
465        self.render_runs()
466            .into_iter()
467            .map(|runs| {
468                let line: String = runs.into_iter().map(|(text, _)| text).collect();
469                line.trim_end().to_string()
470            })
471            .collect()
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn viewport_materialization_is_bounded_by_visible_cells() {
481        let mut canvas = CharCanvas::new();
482        canvas.text(100_000, 100_000, "far", CanvasStyle::Plain);
483        canvas.text(5, 5, "near", CanvasStyle::Taken);
484        assert_eq!(canvas.size(), (100_003, 100_001));
485        let window = canvas.render_runs_window(4, 4, 8, 3);
486        assert_eq!(window.len(), 3);
487        assert!(window
488            .iter()
489            .flat_map(|row| row.iter())
490            .all(|(text, _)| UnicodeWidthStr::width(text.as_str()) <= 8));
491        assert_eq!(
492            window[1]
493                .iter()
494                .map(|(text, _)| text.as_str())
495                .collect::<String>(),
496            " near   "
497        );
498    }
499
500    #[test]
501    fn wide_text_uses_terminal_display_cells() {
502        let mut canvas = CharCanvas::new();
503        canvas.text(0, 0, "界a", CanvasStyle::Plain);
504        assert_eq!(canvas.size(), (3, 1));
505        assert_eq!(canvas.render_plain(), vec!["界a"]);
506        let window = canvas.render_runs_window(0, 0, 3, 1);
507        let rendered = window[0]
508            .iter()
509            .map(|(text, _)| text.as_str())
510            .collect::<String>();
511        assert_eq!(UnicodeWidthStr::width(rendered.as_str()), 3);
512
513        let mut combining = CharCanvas::new();
514        combining.text(0, 0, "e\u{301}", CanvasStyle::Plain);
515        assert_eq!(combining.size(), (1, 1));
516        assert_eq!(combining.render_plain(), vec!["e\u{301}"]);
517    }
518}