返回 CodeWhale
tree.rs
1 //! WorkflowTree: nested work (a run, its phases, their agents) as one tree.
2 //!
3 //! ```text
4 //! ● Release notes working · 4m 06s · $0.38
5 //! ├ ✓ Gather done · 3 agents · 1m 12s
6 //! ├ ● Draft working · 2 of 4 agents
7 //! │ ├ ✕ writer-2 failed · tests did not pass →
8 //! │ └ ● writer-1 working · Editing CHANGELOG.md · 38 s
9 //! └ ○ Review ready · +3 hidden · 2 ready · 1 working
10 //! 1 failed · 2 working · 3 ready
11 //! ```
12 //!
13 //! Every row carries its state's mark and word. A collapsed parent reports
14 //! the real number of nodes it hides and how they stand, so folding a phase
15 //! never hides a failure. Guides are `├ └ │`, or `+ \ |` in ASCII-safe
16 //! terminals. The summary line counts the whole tree, not what fits.
17 //!
18 //! [`TreeState`] holds the selection, the scroll offset and which nodes the
19 //! person flipped from their default (`TreeNode::collapsed`), and
20 //! [`TreeState::handle_key`] turns keys into a [`TreeOutcome`] the host acts
21 //! on. Modelled on the engine's `widgets/workflow_panel.rs` and
22 //! `history/checklist.rs` (`Hmbown/CodeWhale` `58b1dd3dd`).
23
24 use std::{borrow::Cow, collections::BTreeSet};
25
26 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
27 use ratatui::{
28 buffer::Buffer,
29 layout::Rect,
30 style::Modifier,
31 text::{Line, Span},
32 widgets::Widget,
33 };
34
35 use crate::{Paint, Role, State, StateWords, StatusMark, Theme, glyphs, text};
36
37 /// One node: a state, a label, and optionally words about it and children.
38 #[derive(Clone, Debug, PartialEq, Eq)]
39 pub struct TreeNode {
40 pub label: Cow<'static, str>,
41 pub state: State,
42 /// What it is doing, or why it failed.
43 pub detail: Option<Cow<'static, str>>,
44 /// Measured facts: `1m 12s · $0.38`.
45 pub receipt: Option<Cow<'static, str>>,
46 pub children: Vec<TreeNode>,
47 /// Whether its children start folded. [`TreeState`] flips this per node.
48 pub collapsed: bool,
49 /// Draws `→`: there is a receipt or detail to open.
50 pub opens: bool,
51 }
52
53 impl TreeNode {
54 #[must_use]
55 pub fn new(state: State, label: impl Into<Cow<'static, str>>) -> Self {
56 Self {
57 label: label.into(),
58 state,
59 detail: None,
60 receipt: None,
61 children: Vec::new(),
62 collapsed: false,
63 opens: false,
64 }
65 }
66
67 #[must_use]
68 pub fn detail(mut self, detail: impl Into<Cow<'static, str>>) -> Self {
69 self.detail = Some(detail.into());
70 self
71 }
72
73 #[must_use]
74 pub fn receipt(mut self, receipt: impl Into<Cow<'static, str>>) -> Self {
75 self.receipt = Some(receipt.into());
76 self
77 }
78
79 #[must_use]
80 pub fn child(mut self, child: TreeNode) -> Self {
81 self.children.push(child);
82 self
83 }
84
85 #[must_use]
86 pub fn children(mut self, children: impl IntoIterator<Item = TreeNode>) -> Self {
87 self.children.extend(children);
88 self
89 }
90
91 #[must_use]
92 pub fn collapsed(mut self, collapsed: bool) -> Self {
93 self.collapsed = collapsed;
94 self
95 }
96
97 #[must_use]
98 pub fn opens(mut self) -> Self {
99 self.opens = true;
100 self
101 }
102
103 /// How many nodes sit below this one, at every depth.
104 #[must_use]
105 pub fn descendants(&self) -> usize {
106 self.children.iter().map(|c| 1 + c.descendants()).sum()
107 }
108 }
109
110 /// Counts by state, in [`State::ALL`] order.
111 type Counts = [usize; 7];
112
113 fn state_index(state: State) -> usize {
114 State::ALL.iter().position(|s| *s == state).unwrap_or(0)
115 }
116
117 /// `node`'s descendants (not `node`) by state.
118 fn count_below(node: &TreeNode, counts: &mut Counts) {
119 for child in &node.children {
120 counts[state_index(child.state)] += 1;
121 count_below(child, counts);
122 }
123 }
124
125 /// Every node of `nodes` by state, folded or not.
126 #[must_use]
127 pub fn tree_counts(nodes: &[TreeNode]) -> Vec<(State, usize)> {
128 let mut counts: Counts = [0; 7];
129 for node in nodes {
130 counts[state_index(node.state)] += 1;
131 count_below(node, &mut counts);
132 }
133 ordered(&counts)
134 }
135
136 /// What a person scans for first: failures, then what needs them, then what
137 /// is moving.
138 const SUMMARY_ORDER: [State; 7] = [
139 State::Failed,
140 State::NeedsYou,
141 State::Working,
142 State::Ready,
143 State::Stopped,
144 State::Unknown,
145 State::Done,
146 ];
147
148 fn ordered(counts: &Counts) -> Vec<(State, usize)> {
149 SUMMARY_ORDER
150 .iter()
151 .map(|s| (*s, counts[state_index(*s)]))
152 .filter(|(_, n)| *n > 0)
153 .collect()
154 }
155
156 /// Words the tree prints. `Default` is English, in lowercase because they
157 /// sit mid-sentence (`1 failed · 2 working`).
158 #[derive(Clone, Debug, PartialEq, Eq)]
159 pub struct TreeWords {
160 /// The word beside each state mark, and in counts.
161 pub states: StateWords,
162 /// `+3 hidden`: nodes a folded parent covers.
163 pub hidden: Cow<'static, str>,
164 /// `+4 more`: rows below the window.
165 pub more: Cow<'static, str>,
166 }
167
168 impl Default for TreeWords {
169 fn default() -> Self {
170 Self {
171 states: StateWords {
172 working: Cow::Borrowed("working"),
173 done: Cow::Borrowed("done"),
174 needs_you: Cow::Borrowed("needs you"),
175 failed: Cow::Borrowed("failed"),
176 stopped: Cow::Borrowed("stopped"),
177 ready: Cow::Borrowed("ready"),
178 unknown: Cow::Borrowed("unknown"),
179 },
180 hidden: Cow::Borrowed("hidden"),
181 more: Cow::Borrowed("more"),
182 }
183 }
184 }
185
186 // ---------------------------------------------------------------------------
187 // State
188 // ---------------------------------------------------------------------------
189
190 /// A row on screen, with where it sits in the tree.
191 struct Visible<'a> {
192 path: Vec<usize>,
193 node: &'a TreeNode,
194 /// For each ancestor below the roots: whether it was the last sibling.
195 trail: Vec<bool>,
196 is_last: bool,
197 collapsed: bool,
198 }
199
200 /// Which row is selected, how far the tree has scrolled, and which nodes the
201 /// person folded or unfolded.
202 #[derive(Clone, Debug, Default, PartialEq, Eq)]
203 pub struct TreeState {
204 /// Index into the visible rows.
205 pub selected: usize,
206 pub offset: usize,
207 /// Paths whose folded state is the opposite of their node's default.
208 flipped: BTreeSet<Vec<usize>>,
209 }
210
211 /// What a key did, for the host to act on.
212 #[derive(Clone, Debug, PartialEq, Eq)]
213 pub enum TreeOutcome {
214 Ignored,
215 /// The selection moved.
216 Moved,
217 Expanded,
218 Collapsed,
219 /// Enter on this node (its path of child indices): open its receipt.
220 Opened(Vec<usize>),
221 Cancelled,
222 }
223
224 impl TreeState {
225 #[must_use]
226 pub fn new() -> Self {
227 Self::default()
228 }
229
230 /// Whether `node` at `path` is folded now.
231 #[must_use]
232 pub fn is_collapsed(&self, path: &[usize], node: &TreeNode) -> bool {
233 !node.children.is_empty() && (node.collapsed != self.flipped.contains(path))
234 }
235
236 fn flip(&mut self, path: &[usize]) {
237 if !self.flipped.remove(path) {
238 self.flipped.insert(path.to_vec());
239 }
240 }
241
242 fn visible<'a>(&self, nodes: &'a [TreeNode]) -> Vec<Visible<'a>> {
243 fn walk<'a>(
244 nodes: &'a [TreeNode],
245 state: &TreeState,
246 path: &mut Vec<usize>,
247 trail: &mut Vec<bool>,
248 out: &mut Vec<Visible<'a>>,
249 ) {
250 for (i, node) in nodes.iter().enumerate() {
251 path.push(i);
252 let collapsed = state.is_collapsed(path, node);
253 out.push(Visible {
254 path: path.clone(),
255 node,
256 trail: trail.clone(),
257 is_last: i + 1 == nodes.len(),
258 collapsed,
259 });
260 if !collapsed {
261 // Roots draw no guide, so they add nothing to the trail.
262 let guided = path.len() > 1;
263 if guided {
264 trail.push(i + 1 == nodes.len());
265 }
266 walk(&node.children, state, path, trail, out);
267 if guided {
268 trail.pop();
269 }
270 }
271 path.pop();
272 }
273 }
274 let mut out = Vec::new();
275 walk(nodes, self, &mut Vec::new(), &mut Vec::new(), &mut out);
276 out
277 }
278
279 /// How many rows are on screen with the current folds.
280 #[must_use]
281 pub fn visible_len(&self, nodes: &[TreeNode]) -> usize {
282 self.visible(nodes).len()
283 }
284
285 /// The selected node's path of child indices, if there is one.
286 #[must_use]
287 pub fn selected_path(&self, nodes: &[TreeNode]) -> Option<Vec<usize>> {
288 let rows = self.visible(nodes);
289 rows.get(self.selected.min(rows.len().saturating_sub(1)))
290 .map(|r| r.path.clone())
291 }
292
293 /// The rows of a `len`-row tree to show in `rows` rows: `(offset,
294 /// shown)`. When some rows are left below the window the last row is
295 /// spent on `+n more` (`shown` is one less than `rows`), and the
296 /// selection is always inside the shown rows.
297 #[must_use]
298 pub fn window(&self, len: usize, rows: usize) -> (usize, usize) {
299 if rows == 0 || len == 0 {
300 return (0, 0);
301 }
302 let selected = self.selected.min(len - 1);
303 if len <= rows {
304 return (0, len);
305 }
306 if rows == 1 {
307 return (selected, 1);
308 }
309 let usable = rows - 1;
310 let mut offset = self.offset.min(len - usable);
311 if selected < offset {
312 offset = selected;
313 } else if selected >= offset + usable {
314 offset = selected + 1 - usable;
315 }
316 if offset + rows >= len {
317 (len - rows, rows)
318 } else {
319 (offset, usable)
320 }
321 }
322
323 /// Store the offset the tree paints with, so scrolling is stable.
324 pub fn scroll_into_view(&mut self, len: usize, rows: u16) {
325 self.selected = self.selected.min(len.saturating_sub(1));
326 self.offset = self.window(len, usize::from(rows)).0;
327 }
328
329 /// Apply one key. Up and Down, PageUp and PageDown, Home and End move;
330 /// Right unfolds (or steps into the first child), Left folds (or steps
331 /// out to the parent), Space toggles, Enter asks the host to open the
332 /// node, Esc cancels. Releases are ignored. `rows` is the height the tree
333 /// paints in.
334 pub fn handle_key(&mut self, key: KeyEvent, nodes: &[TreeNode], rows: u16) -> TreeOutcome {
335 if key.kind == KeyEventKind::Release {
336 return TreeOutcome::Ignored;
337 }
338 let rows_now = self.visible(nodes);
339 let len = rows_now.len();
340 if len == 0 {
341 return TreeOutcome::Ignored;
342 }
343 let at = self.selected.min(len - 1);
344 let page = usize::from(rows.max(2)) - 1;
345 let current = &rows_now[at];
346 let has_children = !current.node.children.is_empty();
347 let mut moved_to = None;
348 let outcome = match key.code {
349 KeyCode::Up => {
350 moved_to = Some(at.saturating_sub(1));
351 None
352 }
353 KeyCode::Down => {
354 moved_to = Some((at + 1).min(len - 1));
355 None
356 }
357 KeyCode::PageUp => {
358 moved_to = Some(at.saturating_sub(page));
359 None
360 }
361 KeyCode::PageDown => {
362 moved_to = Some((at + page).min(len - 1));
363 None
364 }
365 KeyCode::Home => {
366 moved_to = Some(0);
367 None
368 }
369 KeyCode::End => {
370 moved_to = Some(len - 1);
371 None
372 }
373 KeyCode::Right if has_children && current.collapsed => {
374 let path = current.path.clone();
375 self.flip(&path);
376 Some(TreeOutcome::Expanded)
377 }
378 KeyCode::Right if has_children => {
379 moved_to = Some(at + 1);
380 None
381 }
382 KeyCode::Left if has_children && !current.collapsed => {
383 let path = current.path.clone();
384 self.flip(&path);
385 Some(TreeOutcome::Collapsed)
386 }
387 KeyCode::Left if current.path.len() > 1 => {
388 let parent = &current.path[..current.path.len() - 1];
389 moved_to = rows_now.iter().position(|r| r.path == parent);
390 None
391 }
392 KeyCode::Char(' ') if has_children => {
393 let path = current.path.clone();
394 let was = current.collapsed;
395 self.flip(&path);
396 Some(if was {
397 TreeOutcome::Expanded
398 } else {
399 TreeOutcome::Collapsed
400 })
401 }
402 KeyCode::Enter => Some(TreeOutcome::Opened(current.path.clone())),
403 KeyCode::Esc => Some(TreeOutcome::Cancelled),
404 _ => Some(TreeOutcome::Ignored),
405 };
406 let outcome = match (outcome, moved_to) {
407 (Some(outcome), _) => outcome,
408 (None, Some(to)) if to != at => {
409 self.selected = to;
410 TreeOutcome::Moved
411 }
412 _ => TreeOutcome::Ignored,
413 };
414 let len_after = self.visible_len(nodes);
415 self.scroll_into_view(len_after, rows);
416 outcome
417 }
418 }
419
420 // ---------------------------------------------------------------------------
421 // The tree
422 // ---------------------------------------------------------------------------
423
424 /// The nodes to paint, with optional selection state.
425 #[derive(Clone, Debug)]
426 pub struct WorkflowTree<'a> {
427 pub nodes: &'a [TreeNode],
428 pub state: Option<&'a TreeState>,
429 /// A last line counting every node by state.
430 pub summary: bool,
431 pub words: TreeWords,
432 }
433
434 impl<'a> WorkflowTree<'a> {
435 #[must_use]
436 pub fn new(nodes: &'a [TreeNode]) -> Self {
437 Self {
438 nodes,
439 state: None,
440 summary: false,
441 words: TreeWords::default(),
442 }
443 }
444
445 /// Paint with this selection and these folds.
446 #[must_use]
447 pub fn state(mut self, state: &'a TreeState) -> Self {
448 self.state = Some(state);
449 self
450 }
451
452 /// Add the summary line.
453 #[must_use]
454 pub fn summary(mut self) -> Self {
455 self.summary = true;
456 self
457 }
458
459 #[must_use]
460 pub fn words(mut self, words: TreeWords) -> Self {
461 self.words = words;
462 self
463 }
464
465 fn summary_text(&self, ascii: bool) -> String {
466 let sep = if ascii { " - " } else { " \u{b7} " };
467 self.counts_text(&tree_counts(self.nodes), sep)
468 }
469
470 fn counts_text(&self, counts: &[(State, usize)], sep: &str) -> String {
471 counts
472 .iter()
473 .map(|(s, n)| format!("{n} {}", text::display_safe(self.words.states.get(*s))))
474 .collect::<Vec<_>>()
475 .join(sep)
476 }
477
478 /// The rows the tree shows for a state: the folds it holds, or its
479 /// nodes' defaults.
480 fn rows(&self) -> Vec<Visible<'a>> {
481 match self.state {
482 Some(state) => state.visible(self.nodes),
483 None => TreeState::default().visible(self.nodes),
484 }
485 }
486 }
487
488 /// `├ `, `└ `, `│ ` and the blanks between.
489 fn guide_cells(ascii: bool, row: &Visible<'_>, skip: usize) -> String {
490 let (tee, ell, bar) = if ascii {
491 ("+ ", "\\ ", "| ")
492 } else {
493 ("\u{251c} ", "\u{2514} ", "\u{2502} ")
494 };
495 let mut out = String::new();
496 for last in row.trail.iter().skip(skip) {
497 out.push_str(if *last { " " } else { bar });
498 }
499 if row.path.len() > 1 {
500 out.push_str(if row.is_last { ell } else { tee });
501 }
502 out
503 }
504
505 impl Paint for WorkflowTree<'_> {
506 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
507 let area = area.intersection(buf.area);
508 if area.is_empty() {
509 return;
510 }
511 let ascii = theme.ascii();
512 let rows = self.rows();
513 let summary = self.summary && area.height >= 2 && !rows.is_empty();
514 let body_rows = usize::from(area.height) - usize::from(summary);
515 let default = TreeState::default();
516 let state = self.state.unwrap_or(&default);
517 let (offset, shown) = state.window(rows.len(), body_rows);
518 let selected = self
519 .state
520 .map(|s| s.selected.min(rows.len().saturating_sub(1)));
521 let width = usize::from(area.width);
522 let select_w = if self.state.is_some() { 2 } else { 0 };
523 let window = &rows[offset..offset + shown];
524
525 // Guides deeper than a third of the width would crowd the words out:
526 // drop the outermost levels, never the row.
527 let skip_of = |row: &Visible<'_>| -> usize {
528 let max_levels = (width / 3).saturating_sub(select_w) / 2;
529 (row.trail.len() + 1).saturating_sub(max_levels.max(1))
530 };
531 let lefts: Vec<usize> = window
532 .iter()
533 .map(|r| select_w + text::width(&guide_cells(ascii, r, skip_of(r))) + 2)
534 .collect();
535 let want = window
536 .iter()
537 .zip(&lefts)
538 .map(|(r, left)| left + text::width(&text::display_safe(&r.node.label)))
539 .max()
540 .unwrap_or(0)
541 + 2;
542 let tail_col = want.min(width * 3 / 5).max(1);
543
544 let sep = if ascii { " - " } else { " \u{b7} " };
545 for (n, (row, left)) in window.iter().zip(&lefts).enumerate() {
546 let y = area.y + n as u16;
547 let rect = Rect::new(area.x, y, area.width, 1);
548 let is_selected = selected == Some(offset + n);
549 let node = row.node;
550 let mut spans: Vec<Span<'static>> = Vec::new();
551 if self.state.is_some() {
552 let marker = glyphs::pick(glyphs::selection_marker(is_selected), ascii);
553 spans.push(Span::styled(format!("{marker} "), theme.fg(Role::Primary)));
554 }
555 let guides = guide_cells(ascii, row, skip_of(row));
556 if !guides.is_empty() {
557 spans.push(Span::styled(guides, theme.fg(Role::Dim)));
558 }
559 let mark = StatusMark::new(node.state);
560 spans.push(Span::styled(mark.glyph(theme), theme.fg(node.state.role())));
561 spans.push(Span::raw(" "));
562 let label_room = tail_col.saturating_sub(*left + 2).max(1);
563 let label_room = label_room.min(width.saturating_sub(*left).max(1));
564 let label = text::display_safe(&node.label);
565 let label_style = if is_selected {
566 theme.fg(Role::Foreground).add_modifier(Modifier::BOLD)
567 } else {
568 theme.fg(Role::Foreground)
569 };
570 spans.push(Span::styled(
571 text::pad(&label, label_room, ascii),
572 label_style,
573 ));
574
575 // The tail: word, detail, receipt, then what a fold hides.
576 let mut room = width.saturating_sub(left + label_room);
577 let loud = matches!(node.state, State::Failed | State::NeedsYou);
578 let mut parts: Vec<(String, Role)> = vec![(
579 text::display_safe(self.words.states.get(node.state)).into_owned(),
580 Role::Muted,
581 )];
582 if let Some(detail) = &node.detail {
583 let role = if loud { Role::Foreground } else { Role::Muted };
584 parts.push((text::display_safe(detail).into_owned(), role));
585 }
586 if let Some(receipt) = &node.receipt {
587 parts.push((text::display_safe(receipt).into_owned(), Role::Muted));
588 }
589 if row.collapsed {
590 let hidden = node.descendants();
591 let mut counts: Counts = [0; 7];
592 count_below(node, &mut counts);
593 let mut summary = format!("+{hidden} {}", text::display_safe(&self.words.hidden));
594 let by_state = self.counts_text(&ordered(&counts), sep);
595 if !by_state.is_empty() {
596 summary.push_str(sep);
597 summary.push_str(&by_state);
598 }
599 parts.push((summary, Role::Muted));
600 }
601 let arrow = if ascii { " >" } else { " \u{2192}" };
602 let arrow_w = if node.opens { text::width(arrow) } else { 0 };
603 room = room.saturating_sub(2 + arrow_w);
604 let mut first = true;
605 for (part, role) in parts {
606 let lead = if first { " " } else { sep };
607 let lead_w = text::width(lead);
608 if room <= lead_w {
609 break;
610 }
611 let fitted = text::truncate(&part, room - lead_w, ascii).into_owned();
612 let cut = text::width(&fitted) < text::width(&part);
613 room -= lead_w + text::width(&fitted);
614 spans.push(Span::styled(
615 lead.to_string(),
616 theme.fg(if first { Role::Foreground } else { Role::Dim }),
617 ));
618 spans.push(Span::styled(fitted, theme.fg(role)));
619 first = false;
620 if cut {
621 break;
622 }
623 }
624 if node.opens {
625 spans.push(Span::styled(arrow, theme.fg(Role::Muted)));
626 }
627 if is_selected {
628 buf.set_style(rect, theme.bg(Role::Selected));
629 }
630 Line::from(spans).render(rect, buf);
631 }
632
633 let mut y = area.y + shown as u16;
634 if shown < rows.len() && shown == body_rows.saturating_sub(1) && offset + shown < rows.len()
635 {
636 let below = rows.len() - offset - shown;
637 let more = format!("+{below} {}", text::display_safe(&self.words.more));
638 let more = text::truncate(&more, width.saturating_sub(select_w), ascii);
639 let rect = Rect::new(area.x, y, area.width, 1);
640 Line::from(vec![
641 Span::raw(" ".repeat(select_w)),
642 Span::styled(more.into_owned(), theme.fg(Role::Muted)),
643 ])
644 .render(rect, buf);
645 y += 1;
646 }
647 if summary && y < area.bottom() {
648 let counts = self.summary_text(ascii);
649 let line = text::truncate(&counts, width.saturating_sub(select_w), ascii);
650 let rect = Rect::new(area.x, area.bottom() - 1, area.width, 1);
651 Line::from(vec![
652 Span::raw(" ".repeat(select_w)),
653 Span::styled(line.into_owned(), theme.fg(Role::Muted)),
654 ])
655 .render(rect, buf);
656 }
657 }
658
659 fn height(&self, _width: u16, _theme: &Theme) -> u16 {
660 let rows = self.rows().len() + usize::from(self.summary && !self.nodes.is_empty());
661 u16::try_from(rows).unwrap_or(u16::MAX)
662 }
663 }
664
664 lines RUST