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;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub enum CanvasStyle {
8    Plain,
9    Dim,
10    Taken,
11    ActiveEdge,
12    Back,
13    NodeText,
14    NodeDim,
15    NodeFocusText,
16    NodeHeader,
17    NodeBorderDim,
18    NodeBorderActive,
19    NodeBorderReplay,
20    NodeBorderOk,
21    NodeBorderFail,
22    NodeBorderTimedOut,
23    NodeBorderWarn,
24    NodeBorderCancelled,
25    Active,
26    Replay,
27    Ok,
28    Fail,
29    TimedOut,
30    Warn,
31    Cancelled,
32    Branch,
33    BranchFocus,
34    Agent,
35    AgentFocus,
36    Compute,
37    ComputeFocus,
38    Action,
39    ActionFocus,
40    Checkpoint,
41    CheckpointFocus,
42}
43
44impl CanvasStyle {
45    /// Styles later in the priority order win when merged lines overlap.
46    fn priority(self) -> u8 {
47        match self {
48            CanvasStyle::Plain => 0,
49            CanvasStyle::Dim => 1,
50            CanvasStyle::Back => 2,
51            CanvasStyle::Taken => 3,
52            CanvasStyle::ActiveEdge => 4,
53            CanvasStyle::NodeText
54            | CanvasStyle::NodeDim
55            | CanvasStyle::NodeFocusText
56            | CanvasStyle::NodeHeader => 5,
57            CanvasStyle::NodeBorderDim
58            | CanvasStyle::NodeBorderActive
59            | CanvasStyle::NodeBorderReplay
60            | CanvasStyle::NodeBorderOk
61            | CanvasStyle::NodeBorderFail
62            | CanvasStyle::NodeBorderTimedOut
63            | CanvasStyle::NodeBorderWarn
64            | CanvasStyle::NodeBorderCancelled
65            | CanvasStyle::Warn
66            | CanvasStyle::Cancelled => 6,
67            CanvasStyle::Ok => 7,
68            CanvasStyle::Fail | CanvasStyle::TimedOut => 8,
69            CanvasStyle::Branch
70            | CanvasStyle::BranchFocus
71            | CanvasStyle::Agent
72            | CanvasStyle::AgentFocus
73            | CanvasStyle::Compute
74            | CanvasStyle::ComputeFocus
75            | CanvasStyle::Action
76            | CanvasStyle::ActionFocus
77            | CanvasStyle::Checkpoint
78            | CanvasStyle::CheckpointFocus => 9,
79            CanvasStyle::Replay => 10,
80            CanvasStyle::Active => 11,
81        }
82    }
83}
84
85fn merge_styles(a: CanvasStyle, b: CanvasStyle) -> CanvasStyle {
86    if a.priority() >= b.priority() {
87        a
88    } else {
89        b
90    }
91}
92
93pub const UP: u8 = 1;
94pub const DOWN: u8 = 2;
95pub const LEFT: u8 = 4;
96pub const RIGHT: u8 = 8;
97
98/// Which sides each box-drawing character connects to.
99pub fn char_to_mask(char: char) -> Option<u8> {
100    Some(match char {
101        '─' => LEFT | RIGHT,
102        '│' => UP | DOWN,
103        '┌' => DOWN | RIGHT,
104        '┐' => DOWN | LEFT,
105        '└' => UP | RIGHT,
106        '┘' => UP | LEFT,
107        '├' => UP | DOWN | RIGHT,
108        '┤' => UP | DOWN | LEFT,
109        '┬' => DOWN | LEFT | RIGHT,
110        '┴' => UP | LEFT | RIGHT,
111        '┼' => UP | DOWN | LEFT | RIGHT,
112        _ => return None,
113    })
114}
115
116fn mask_to_char(mask: u8) -> Option<char> {
117    Some(match mask {
118        m if m == (LEFT | RIGHT) => '─',
119        m if m == (UP | DOWN) => '│',
120        m if m == (DOWN | RIGHT) => '┌',
121        m if m == (DOWN | LEFT) => '┐',
122        m if m == (UP | RIGHT) => '└',
123        m if m == (UP | LEFT) => '┘',
124        m if m == (UP | DOWN | RIGHT) => '├',
125        m if m == (UP | DOWN | LEFT) => '┤',
126        m if m == (DOWN | LEFT | RIGHT) => '┬',
127        m if m == (UP | LEFT | RIGHT) => '┴',
128        m if m == (UP | DOWN | LEFT | RIGHT) => '┼',
129        _ => return None,
130    })
131}
132
133#[derive(Debug, Clone, Copy)]
134struct CanvasChar {
135    char: char,
136    style: CanvasStyle,
137}
138
139/// A styled run of consecutive characters on one canvas row.
140pub type StyledRun = (String, CanvasStyle);
141
142#[derive(Default)]
143pub struct CharCanvas {
144    cells: HashMap<i64, HashMap<i64, CanvasChar>>,
145    max_x: i64,
146    max_y: i64,
147}
148
149impl CharCanvas {
150    pub fn new() -> Self {
151        Self::default()
152    }
153
154    fn row(&mut self, y: i64) -> &mut HashMap<i64, CanvasChar> {
155        self.max_y = self.max_y.max(y);
156        self.cells.entry(y).or_default()
157    }
158
159    /// Place a single character, merging box-drawing connectivity.
160    pub fn put(&mut self, x: i64, y: i64, char: char, style: CanvasStyle) {
161        if x < 0 || y < 0 {
162            return;
163        }
164        self.max_y = self.max_y.max(y);
165        self.max_x = self.max_x.max(x);
166        let row = self.cells.entry(y).or_default();
167        // Spaces never occupy cells; text_over_run handles deliberate padding.
168        if char == ' ' {
169            return;
170        }
171        if let Some(existing) = row.get(&x).copied() {
172            if existing.char == ' ' {
173                row.insert(x, CanvasChar { char, style });
174                return;
175            }
176            let existing_mask = char_to_mask(existing.char);
177            let incoming_mask = char_to_mask(char);
178            if let (Some(existing_mask), Some(incoming_mask)) = (existing_mask, incoming_mask) {
179                row.insert(
180                    x,
181                    CanvasChar {
182                        char: mask_to_char(existing_mask | incoming_mask).unwrap_or(char),
183                        style: merge_styles(existing.style, style),
184                    },
185                );
186                return;
187            }
188            // Non-line characters (labels, glyphs, arrows) win over lines;
189            // between two non-line characters the newest wins.
190            if existing_mask.is_none() && incoming_mask.is_some() {
191                return;
192            }
193        }
194        row.insert(x, CanvasChar { char, style });
195    }
196
197    /// Write a text run left to right (labels, node lines).
198    pub fn text(&mut self, x: i64, y: i64, value: &str, style: CanvasStyle) {
199        for (index, char) in value.chars().enumerate() {
200            self.put(x + index as i64, y, char, style);
201        }
202    }
203
204    /// Write text only when every target cell is empty. Returns whether the
205    /// text was written.
206    pub fn text_if_empty(&mut self, x: i64, y: i64, value: &str, style: CanvasStyle) -> bool {
207        if x < 0 || y < 0 {
208            return false;
209        }
210        let width = value.chars().count() as i64;
211        if let Some(row) = self.cells.get(&y) {
212            for index in 0..width {
213                if row.contains_key(&(x + index)) {
214                    return false;
215                }
216            }
217        }
218        self.text(x, y, value, style);
219        true
220    }
221
222    /// Write text over a plain horizontal run, replacing `─` cells only.
223    /// Refuses unless every target cell and both flanking cells are exactly
224    /// `─`. Spaces in `value` become real blanks on purpose.
225    pub fn text_over_run(&mut self, x: i64, y: i64, value: &str, style: CanvasStyle) -> bool {
226        if x < 1 || y < 0 {
227            return false;
228        }
229        let chars: Vec<char> = value.chars().collect();
230        let row = self.cells.get(&y);
231        for index in -1..=(chars.len() as i64) {
232            let is_dash = row
233                .and_then(|row| row.get(&(x + index)))
234                .is_some_and(|cell| cell.char == '─');
235            if !is_dash {
236                return false;
237            }
238        }
239        let row = self.row(y);
240        for (index, &char) in chars.iter().enumerate() {
241            row.insert(x + index as i64, CanvasChar { char, style });
242        }
243        self.max_x = self.max_x.max(x + chars.len() as i64 - 1);
244        true
245    }
246
247    /// Fill a rectangle with intentional styled spaces. Unlike `put`, this
248    /// preserves spaces so node cards can carry a background color.
249    pub fn fill_rect(&mut self, x: i64, y: i64, width: i64, height: i64, style: CanvasStyle) {
250        if x < 0 || y < 0 || width <= 0 || height <= 0 {
251            return;
252        }
253        self.max_x = self.max_x.max(x + width - 1);
254        self.max_y = self.max_y.max(y + height - 1);
255        for row_y in y..y + height {
256            let row = self.cells.entry(row_y).or_default();
257            for column_x in x..x + width {
258                row.insert(column_x, CanvasChar { char: ' ', style });
259            }
260        }
261    }
262
263    pub fn hline(&mut self, y: i64, x1: i64, x2: i64, style: CanvasStyle) {
264        let (start, end) = if x1 <= x2 { (x1, x2) } else { (x2, x1) };
265        for x in start..=end {
266            self.put(x, y, '─', style);
267        }
268    }
269
270    pub fn vline(&mut self, x: i64, y1: i64, y2: i64, style: CanvasStyle) {
271        let (start, end) = if y1 <= y2 { (y1, y2) } else { (y2, y1) };
272        for y in start..=end {
273            self.put(x, y, '│', style);
274        }
275    }
276
277    /// Render to rows of styled runs, gaps filled with plain spaces.
278    /// Trailing whitespace is trimmed from every row.
279    pub fn render_runs(&self) -> Vec<Vec<StyledRun>> {
280        let mut lines = Vec::new();
281        for y in 0..=self.max_y {
282            let Some(row) = self.cells.get(&y).filter(|row| !row.is_empty()) else {
283                lines.push(Vec::new());
284                continue;
285            };
286            // Trim trailing whitespace: find the last occupied x.
287            let last_x = row.keys().copied().max().unwrap_or(-1);
288            let mut runs: Vec<StyledRun> = Vec::new();
289            let mut run_text = String::new();
290            let mut run_style = CanvasStyle::Plain;
291            for x in 0..=last_x.min(self.max_x) {
292                let (char, style) = match row.get(&x) {
293                    Some(cell) => (cell.char, cell.style),
294                    None => (' ', CanvasStyle::Plain),
295                };
296                if style != run_style {
297                    if !run_text.is_empty() {
298                        runs.push((std::mem::take(&mut run_text), run_style));
299                    }
300                    run_style = style;
301                }
302                run_text.push(char);
303            }
304            if !run_text.is_empty() {
305                runs.push((run_text, run_style));
306            }
307            lines.push(runs);
308        }
309        lines
310    }
311
312    /// Render to plain text lines (trailing whitespace trimmed), matching the
313    /// TypeScript renderer with colors disabled.
314    pub fn render_plain(&self) -> Vec<String> {
315        self.render_runs()
316            .into_iter()
317            .map(|runs| {
318                let line: String = runs.into_iter().map(|(text, _)| text).collect();
319                line.trim_end().to_string()
320            })
321            .collect()
322    }
323}