1use 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 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
98pub 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
139pub 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 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 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 if existing_mask.is_none() && incoming_mask.is_some() {
191 return;
192 }
193 }
194 row.insert(x, CanvasChar { char, style });
195 }
196
197 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 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 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 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 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 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 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}