1use 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 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
99pub 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
140pub 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 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 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 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 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 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 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 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 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 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 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 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}