| 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 = ¤t.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 |