From 9c10646f7b0889082fbef619f068bca88a1fe431 Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:08:29 +0100 Subject: [PATCH 1/6] feat(view): root the forest at one bead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Shift+F` draws the selected bead where a root is drawn, what the forest draws beneath that bead anywhere else, and nothing else. It is for finishing one bead, so what it takes away is everything a reader does not have to do in order to close the one they are on. Everything beneath the focused bead is already in the tree it is drawn in, because a tree's children are the nesting restricted to what its root reached. So the mode is a change to what the layout walks rather than to what was collected, and the bead keeps the place it carries everywhere else — which is what lets a fold set on it survive the key both ways. The mode stands on the bead named at the keystroke rather than on the selection, so walking about under it moves nothing. Pressing again puts the forest back with the selection on the bead it was rooted at. The bead leaving the collection is the one thing that ends the mode on its own. --- docs/design.md | 20 +++ src/tui/drive.rs | 1 + src/tui/keys.rs | 8 ++ src/tui/screen.rs | 19 ++- src/view/forest/layout.rs | 77 +++++++++--- src/view/forest/mod.rs | 257 +++++++++++++++++++++++++++++++++++++- src/view/mod.rs | 3 + 7 files changed, 366 insertions(+), 19 deletions(-) diff --git a/docs/design.md b/docs/design.md index 4d25e5d..9fb2984 100644 --- a/docs/design.md +++ b/docs/design.md @@ -934,6 +934,25 @@ which stops being true the moment something new is under it. Only hand-folds are stored; the default is derived, which is what makes "back to the default" a single key. +**`Shift+F` roots the forest at one bead, and that is the other place a reader +asks by name.** The key draws the selected bead where a root is drawn, what the +forest draws beneath that bead anywhere else, and nothing else: no other root +and no other project's tree. It is for finishing one bead, so what it takes +away is everything a reader does not have to do in order to close the one they +are on. Everything beneath that bead is already in the tree it is drawn in, so +this is a change to what the layout walks rather than to what was collected, +and the bead keeps the place it has everywhere else — which is what lets a fold +set on it survive the key both ways. The default above rejects +collapsed-except-selected and the rejection stands: the selection has no +bearing on what is open, and it has none here either, because the mode stands +on the bead named at the keystroke and moving about under it moves nothing. +Pressing the key again puts the forest back, with the selection on the bead it +was rooted at. The bead leaving the collection is the one thing that ends the +mode on its own; a bead that closes is still in the collection, so closing the +focused bead does not. Every root and project the mode holds back goes behind +one collapsed line per project rather than off the screen, which is *degrade, +never disappear* binding here as everywhere. + **A finished branch draws as one line and rests shut.** Its glyph, its fraction and its fold marker already say *finished, and holds more*; opening it is the ordinary fold. A closed bead standing over unfinished work is the @@ -2364,6 +2383,7 @@ The bindings are vim-like, with the arrows as aliases: | `Space` | fold or unfold the selected node | | `a` | show every tree, not only those with a live agent | | `?` | show these key bindings | +| `F` | draw the selected bead as the only root, or put the forest back | | `/` | find part of a bead's id or title, wherever the forest draws it | | `n` | go to the next bead matching the search | | `N` | go to the one before it | diff --git a/src/tui/drive.rs b/src/tui/drive.rs index 2055663..5a077e2 100644 --- a/src/tui/drive.rs +++ b/src/tui/drive.rs @@ -629,6 +629,7 @@ fn answered( | Action::CollapseSubtree | Action::RestoreDefault | Action::ToggleFilter + | Action::FocusForest | Action::Search | Action::NextMatch | Action::PreviousMatch, diff --git a/src/tui/keys.rs b/src/tui/keys.rs index 661853d..6afa8ec 100644 --- a/src/tui/keys.rs +++ b/src/tui/keys.rs @@ -90,6 +90,12 @@ pub(super) const BINDINGS: &[Binding] = &[ does: "show these key bindings", hint: Some("keys"), }, + Binding { + keys: &[alone(KeyCode::Char('F'), "F")], + action: Action::FocusForest, + does: "draw the selected bead as the only root, or put the forest back", + hint: None, + }, Binding { keys: &[alone(KeyCode::Char('/'), "/")], action: Action::Search, @@ -338,6 +344,7 @@ pub(super) mod tests { Action::RestoreDefault, Action::ToggleFilter, Action::Focus, + Action::FocusForest, Action::ShowBead, Action::NextRelated, Action::Back, @@ -368,6 +375,7 @@ pub(super) mod tests { | Action::RestoreDefault | Action::ToggleFilter | Action::Focus + | Action::FocusForest | Action::ShowBead | Action::NextRelated | Action::Back diff --git a/src/tui/screen.rs b/src/tui/screen.rs index 9814de1..d62d7ee 100644 --- a/src/tui/screen.rs +++ b/src/tui/screen.rs @@ -1195,7 +1195,7 @@ mod tests { " Space fold or unfold the selected node", " a show every tree, not only those with a live agent", " ? show these key bindings", - " … 19 more bindings · no room on a screen this short", + " … 20 more bindings · no room on a screen this short", ] ); } @@ -1302,6 +1302,7 @@ mod tests { " Space fold or unfold the selected node", " a show every tree, not only those with a live agent", " ? show these key bindings", + " F draw the selected bead as the only root, or put the forest back", " / find part of a bead's id or title, wherever the forest draws it", " n go to the next bead matching the search", " N go to the one before it", @@ -1344,15 +1345,27 @@ mod tests { let drawn = window_inner(40, tall); - assert_eq!(drawn[8], " q, ^C quit"); + assert_eq!(row_naming(&drawn, "q, ^C"), " q, ^C quit"); assert_eq!( drawn[0], " Enter show the selected bead, o…", "a line too long for forty columns, cut with the cut marked" ); - assert_eq!(drawn[18], " Right, l expand, or move to the fi…"); + assert_eq!( + row_naming(&drawn, "Right, l"), + " Right, l expand, or move to the fi…" + ); assert_eq!(drawn.len(), BINDINGS.len(), "a narrow screen loses no rows"); } + /// The one drawn row whose keys are the ones named, whichever row that + /// turns out to be. + fn row_naming<'a>(drawn: &'a [String], keys: &str) -> &'a str { + drawn + .iter() + .find(|row| row.trim_start().starts_with(keys)) + .unwrap_or_else(|| panic!("no row names {keys}: {drawn:#?}")) + } + /// The row is cut from its own end, so one that outgrew the narrowest /// screen anyone uses would lose `q quit` — which is what a reader who /// cannot get out is looking for. `bdi-2bb.12` cut `^R` from it to make diff --git a/src/view/forest/layout.rs b/src/view/forest/layout.rs index fa2de76..b14c209 100644 --- a/src/view/forest/layout.rs +++ b/src/view/forest/layout.rs @@ -49,15 +49,32 @@ fn shut_over(beneath: Counts, first: bool, folded: Option) -> Option Vec { +pub(super) fn draw( + snapshot: &Snapshot, + facts: &Facts, + folds: &Folds, + rooted: Option<&Rooted>, +) -> Vec { Layout { snapshot, facts, folds, + rooted, } .draw() } +/// The one bead the forest is rooted at, where the reader has asked for that: +/// the line they asked on, and the nodes the way down to it steps through. +/// +/// Both, because the place says which line and the way says which nodes, and +/// resolving one into the other needs the snapshot the line was drawn from. +pub(super) struct Rooted { + pub(super) place: Place, + /// The way down from the tree's root to the focused bead, that bead last. + pub(super) way: Vec, +} + /// Whether a group is drawn at all, which is whether the snapshot has put /// anything in it. pub(super) fn group_drawn(snapshot: &Snapshot, kind: GroupKind, project: Option<&str>) -> bool { @@ -234,6 +251,9 @@ struct Layout<'a> { snapshot: &'a Snapshot, facts: &'a Facts, folds: &'a Folds, + /// The bead the forest is rooted at, where the reader has rooted it at + /// one. Nothing is drawn outside what hangs beneath it. + rooted: Option<&'a Rooted>, } impl Layout<'_> { @@ -297,15 +317,30 @@ impl Layout<'_> { if !open { return; } - let trees: Vec<&Tree> = self - .snapshot - .trees - .iter() - .filter(|tree| tree.project == project) - .map(Arc::as_ref) - .collect(); + let trees: Vec<&Tree> = match self.rooted { + // Rooted at one bead, the forest draws the one tree holding it, + // and draws that tree from the bead rather than from its root. + // Taken from what was collected rather than from what the filter + // shows, so a reader who rooted the forest at a bead in a tree the + // filter is holding back keeps the tree they asked for. + Some(rooted) if rooted.place.tree.project == project => { + self.snapshot.tree(&rooted.place.tree).into_iter().collect() + } + Some(_) => Vec::new(), + None => self + .snapshot + .trees + .iter() + .filter(|tree| tree.project == project) + .map(Arc::as_ref) + .collect(), + }; let groups: Vec = GroupKind::UNDER_A_PROJECT .into_iter() + // The group of trees the filter is holding back draws whole trees, + // and one bead is the only root there is while the forest is + // rooted at one. + .filter(|kind| self.rooted.is_none() || *kind != GroupKind::HiddenTrees) .filter_map(|kind| group_of(self.snapshot, kind, Some(&project))) .collect(); let mut entries = trees.len() + groups.len(); @@ -317,6 +352,7 @@ impl Layout<'_> { tree, facts: self.facts.tree(&root_key(tree)), rests_shut: false, + rooted: self.rooted, } .draw(&mut trunk, entries == 0, lines); } @@ -370,6 +406,7 @@ impl Layout<'_> { tree, facts: self.facts.tree(&root_key(tree)), rests_shut: true, + rooted: None, } .draw(trunk, n + 1 == count, lines); } @@ -425,15 +462,27 @@ struct TreeLayout<'a> { tree: &'a Tree, facts: &'a TreeFacts, rests_shut: bool, + /// The bead to draw this tree from, where the reader has rooted the forest + /// at one. Its own root otherwise. + rooted: Option<&'a Rooted>, } impl TreeLayout<'_> { /// `trunk` is the way down to whatever this tree hangs under, as the /// box-drawing says it: empty for a root directly under its project. fn draw(&self, trunk: &mut Vec, last: bool, lines: &mut Vec) { - let root = Place::root(root_key(self.tree)); + // Where the walk starts. The bead the reader rooted the forest at is + // drawn where its tree's root would be, and keeps the place it has + // everywhere else, so a fold set on it survives the key that rooted + // the forest there and the key that puts the forest back. + let (root, way) = match self.rooted { + Some(rooted) => (rooted.place.clone(), rooted.way.clone()), + None => (Place::root(root_key(self.tree)), vec![0]), + }; + let (at, above) = way.split_last().expect("a way down ends somewhere"); + let at = *at; let depth = trunk.len() as u16 + 1; - let Some(node) = self.tree.beads.first() else { + let Some(node) = self.tree.beads.get(at) else { // No nodes, so no row: the root is named on a line of its own // rather than left out, because a root that would not read is the // one a reader most needs to see is there. @@ -452,8 +501,8 @@ impl TreeLayout<'_> { // A tree opens because of what is in it, not because the selection // is in it: the first screen is meant to be the answer to what is // being worked and what could be started. - let kids = self.children_entries(0, &[]); - let bead = self.facts.bead(self.tree, 0, &[]); + let kids = self.children_entries(at, above); + let bead = self.facts.bead(self.tree, at, above); let open = !kids.is_empty() && self.folds.expanded( &Handle::Bead(root.clone()), @@ -470,7 +519,7 @@ impl TreeLayout<'_> { node, &self.tree.root, bead.progress, - shut_over(bead.beneath, first_copy(self.tree, 0, &[]), folded), + shut_over(bead.beneath, first_copy(self.tree, at, above), folded), )), }); @@ -479,7 +528,7 @@ impl TreeLayout<'_> { entries.extend(kids); } trunk.push(!last); - self.draw_children(entries, &root, &[0], trunk, lines); + self.draw_children(entries, &root, &way, trunk, lines); trunk.pop(); } diff --git a/src/view/forest/mod.rs b/src/view/forest/mod.rs index 5f8aae3..2ad2914 100644 --- a/src/view/forest/mod.rs +++ b/src/view/forest/mod.rs @@ -18,6 +18,7 @@ use crate::view::{Action, Motion, Notch}; use facts::{Facts, TreeFacts}; use handle::{handle_of, selectable, Folds, Handle}; +use layout::Rooted; /// Where a search came to. #[derive(Debug, Clone, PartialEq, Eq)] @@ -116,6 +117,13 @@ pub struct Forest { room: usize, lines: Vec, selected: usize, + /// The bead the forest is rooted at, where the reader has asked for one. + /// + /// The line they asked on rather than the bead standing on it, so a bead + /// drawn more than once roots the forest at the copy they were on. Held + /// rather than derived, because the selection moves afterwards and the + /// mode does not move with it. + focused: Option, /// What was last searched for, which `n` and `N` step through. /// /// The text and not the matches it found. A match set held between @@ -137,6 +145,7 @@ pub fn flatten(snapshot: Snapshot) -> Forest { room: 0, lines: Vec::new(), selected: 0, + focused: None, searched: None, }; forest.lay_out(); @@ -406,6 +415,7 @@ impl Forest { Action::CollapseSubtree => self.fold_subtree(false), Action::RestoreDefault => self.folds.clear(), Action::ToggleFilter => self.toggle_filter(), + Action::FocusForest => self.focus_forest(), Action::Focus | Action::ShowBead | Action::NextRelated @@ -423,6 +433,44 @@ impl Forest { self.selected != selected || self.lines != was || revealed } + /// Root the forest at the selected bead, or put it back where it is + /// already rooted at one. + /// + /// Putting it back leaves the selection on the bead the reader rooted it + /// at, wherever they had walked to under the mode: they asked to finish + /// that bead, and the forest they came back to is the one they left. + /// + /// A line carrying no bead roots the forest at nothing. A root whose + /// tracker refused holds a place but no node, and there is no tree to draw + /// from a bead that is not there. + fn focus_forest(&mut self) { + if self.focused.is_some() { + self.cursor = self.focused.take().map(Handle::Bead); + return; + } + let Some(Handle::Bead(place)) = self.cursor.clone() else { + return; + }; + if self.locate(&place).is_some() { + self.focused = Some(place); + } + } + + /// Where the forest is rooted, where the reader has rooted it at a bead + /// the snapshot in hand still draws. + /// + /// Resolved against that snapshot on every layout rather than kept, so a + /// collection that moved the bead is followed and one that dropped it + /// ends the mode. + fn rooted(&self) -> Option { + let place = self.focused.as_ref()?; + let (_, way) = self.locate(place)?; + Some(Rooted { + place: place.clone(), + way, + }) + } + fn toggle_filter(&mut self) { let next = match self.snapshot.filter { Filter::LiveAgents => Filter::All, @@ -529,7 +577,8 @@ impl Forest { /// Point every fold drawn in `scope`'s subtree at `open`, reporting /// whether any of them was pointing the other way. fn point_every_drawn_fold(&mut self, scope: &Handle, open: bool) -> bool { - let drawn = layout::draw(&self.snapshot, &self.facts, &self.folds); + let rooted = self.rooted(); + let drawn = layout::draw(&self.snapshot, &self.facts, &self.folds, rooted.as_ref()); let pointed: Vec = subtree_of(&drawn, scope) .iter() .filter(|line| line.folded == Some(!open)) @@ -960,7 +1009,8 @@ impl Forest { /// without duplicating either. fn lay_out(&mut self) -> Vec { self.settle_cursor(); - let drawn = layout::draw(&self.snapshot, &self.facts, &self.folds); + let rooted = self.rooted(); + let drawn = layout::draw(&self.snapshot, &self.facts, &self.folds, rooted.as_ref()); let was = std::mem::replace(&mut self.lines, drawn); if self.find_cursor().is_none() { // The line the cursor named is not drawn — an ancestor is folded @@ -6862,4 +6912,207 @@ credential_command = "secret harbour" fn drawn_here(forest: &Forest, said: &str) -> bool { sketch(forest).iter().any(|row| row.contains(said)) } + + /// Put the selection on a bead and focus the forest there, the way a + /// reader would with the keys they have. + fn focus_on(forest: &mut Forest, id: &str) { + select_bead(forest, id); + assert!( + forest.apply(Action::FocusForest), + "focusing {id} changed nothing: {:#?}", + sketch(forest) + ); + } + + /// Every root but the one focused goes, and so does every other project's + /// tree. The project lines stay, because what is holding their roots back + /// hangs under them. + #[test] + fn focusing_a_root_leaves_it_the_only_one_drawn() { + let mut forest = flatten(snapshot()); + focus_on(&mut forest, "orb-7"); + + assert_eq!( + // Without the things in the groups, which say nothing about where + // the trees went and are long enough to bury the rows that do. + sketch(&forest) + .into_iter() + .filter(|row| !row.contains("── - ")) + .collect::>(), + vec![ + "▾ orbital", + " ├── ◐ orb-7 lift the ground station", + " │ ├── ! Dangling(1)", + " │ ├─▸ ○ .1 re-point the dish", + " │ ├── ○ .7 log the survey marks", + " │ ├── ✓ .4 clear the access road", + " │ └─▸ … 3 more", + " └── [Unattributed orbital] 2", + "▾ ferry", + " └── [Unattributed ferry] 1", + "▾ harbour", + "▸ [FailedProjects] 1", + "▾ [Unconfigured] 1", + "▾ [Conflicts] 1", + ] + ); + } + + /// The reader asked to finish the bead they pressed the key on, so that is + /// the bead the forest they come back to is standing on, wherever they had + /// walked to underneath it. + #[test] + fn putting_the_forest_back_leaves_the_selection_on_the_bead_it_was_rooted_at() { + let mut forest = flatten(snapshot()); + let was = sketch(&forest); + focus_on(&mut forest, "orb-7.1"); + forest.apply(Action::Move(Motion::LastRow)); + + assert!(forest.apply(Action::FocusForest)); + + assert_eq!(sketch(&forest), was); + assert_eq!(cursor(&forest), Some(&key("orbital", "orb-7.1"))); + } + + /// `bdi` runs on the live-agent filter unless told otherwise, and the + /// group of trees it is holding back is where a reader meets a quiet one. + /// Rooting the forest at a bead in one of those draws it like any other, + /// rather than finding no tree to root at. + #[test] + fn rooting_the_forest_at_a_bead_the_filter_holds_back_draws_its_tree() { + let mut forest = flatten(snapshot()); + select_hidden_tree(&mut forest); + assert_eq!(cursor(&forest), Some(&key("harbour", "hbr-3"))); + + assert!(forest.apply(Action::FocusForest)); + + let hbr = forest + .lines() + .iter() + .find(|line| line.bead().is_some_and(|bead| bead.id == "hbr-3")) + .unwrap_or_else(|| panic!("hbr-3 is not drawn: {:#?}", sketch(&forest))); + assert_eq!(hbr.depth, 1, "drawn where a root is drawn"); + assert!( + !drawn_here(&forest, "HiddenTrees"), + "the group draws whole trees: {:#?}", + sketch(&forest) + ); + } + + /// The mode stands on the bead named at the keystroke, so walking about + /// under it does not move it. + #[test] + fn moving_the_selection_leaves_the_forest_rooted_where_it_was() { + let mut forest = flatten(snapshot()); + toggle_fold_of(&mut forest, "orb-7.1"); + focus_on(&mut forest, "orb-7.1"); + let rooted = sketch(&forest); + + forest.apply(Action::Move(Motion::LastRow)); + forest.apply(Action::Move(Motion::FirstRow)); + + assert_eq!(sketch(&forest), rooted); + } + + /// The bead going out of the collection is the one thing that ends the + /// mode on its own: there is nothing left to root the forest at. + #[test] + fn a_collection_that_has_lost_the_focused_bead_puts_the_forest_back() { + let mut forest = flatten(snapshot()); + toggle_fold_of(&mut forest, "orb-7.1"); + focus_on(&mut forest, "orb-7.1.1"); + assert!(!drawn_here(&forest, "fer-2"), "rooted at one bead"); + + let renamed = edited(ORBITAL, r#""id":"orb-7.1.1""#, r#""id":"orb-7.1.9""#); + forest.refresh(gather( + vec![ + tree_of("orbital", &renamed), + Tree::tracker_unreachable("ferry", "fer-2", TrackerFailure::Auth), + tree_of("harbour", HARBOUR), + ], + Vec::new(), + Filter::LiveAgents, + )); + + assert!( + drawn_here(&forest, "fer-2"), + "every root is back: {:#?}", + sketch(&forest) + ); + } + + /// A bead closing is not the bead leaving. `bdi` reads every bead a + /// tracker holds, so a closed one stays in the collection and the forest + /// stays rooted at it. + #[test] + fn the_focused_bead_closing_leaves_the_forest_rooted_at_it() { + let mut forest = flatten(snapshot()); + focus_on(&mut forest, "orb-7.1"); + + let closed = edited( + ORBITAL, + r#"{"id":"orb-7.1","title":"re-point the dish","status":"open"#, + r#"{"id":"orb-7.1","title":"re-point the dish","status":"closed"#, + ); + forest.refresh(gather( + vec![ + tree_of("orbital", &closed), + Tree::tracker_unreachable("ferry", "fer-2", TrackerFailure::Auth), + tree_of("harbour", HARBOUR), + ], + Vec::new(), + Filter::LiveAgents, + )); + + assert!( + !drawn_here(&forest, "fer-2"), + "still rooted at one bead: {:#?}", + sketch(&forest) + ); + assert!(drawn_here(&forest, "re-point the dish")); + } + + /// A project, a line standing over a stretch of finished beads, and a root + /// whose tracker refused are all lines a reader can sit on and none of + /// them is a bead with a tree under it. + #[test] + fn asking_to_root_the_forest_at_a_line_that_is_not_a_bead_does_nothing() { + let mut forest = flatten(snapshot()); + let was = sketch(&forest); + + select_project(&mut forest, "orbital"); + assert!(!forest.apply(Action::FocusForest)); + assert_eq!(sketch(&forest), was); + + select_run(&mut forest); + assert!(!forest.apply(Action::FocusForest)); + assert_eq!(sketch(&forest), was); + + select_bead(&mut forest, "fer-2"); + assert!(!forest.apply(Action::FocusForest)); + assert_eq!(sketch(&forest), was); + } + + /// A bead deep in a tree is drawn where a root is drawn, and what hangs + /// under it is what hangs under it anywhere else. + #[test] + fn focusing_a_bead_under_a_root_draws_it_where_that_root_was() { + let mut forest = flatten(snapshot()); + toggle_fold_of(&mut forest, "orb-7.1"); + focus_on(&mut forest, "orb-7.1"); + + assert_eq!( + sketch(&forest) + .into_iter() + .take_while(|row| !row.contains("Unattributed")) + .collect::>(), + vec![ + "▾ orbital", + " ├── ○ .1 re-point the dish", + " │ ├── ! Dangling(1)", + " │ ├── ○ .1.1 true the mount", + " │ └── ○ .1.2 seal the feed horn", + ] + ); + } } diff --git a/src/view/mod.rs b/src/view/mod.rs index 69fd198..3d82c78 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -83,6 +83,9 @@ pub enum Action { ToggleFilter, /// Focus the selected bead's pane in herdr. Focus, + /// Draw the selected bead as the only root, with everything beneath it and + /// nothing else, or put the forest back where it already stands that way. + FocusForest, /// Show the selected bead whole, as `bd show` would. ShowBead, /// Move the bead view to the next bead the bead it is showing names. From 810c7799196c2c1b9d245669156544463fe22b80 Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:26:04 +0100 Subject: [PATCH 2/6] feat(view): put the roots the mode holds back behind one line each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rooting the forest at one bead leaves every other root and every other project undrawn, and *degrade, never disappear* says they may not simply go. Each project keeps a collapsed line standing for the roots it is holding back, under its own line, opening onto them as the line over the trees the filter holds back already does. That line stands over open work with seats on it, which the filter's line never does: the filter keeps a tree with an agent on it, so its own line can say `no live agent` and be right. This one cannot. It carries what the roots behind it add up to, and says how many seats and how many beads want looking at, so a reader is never told by omission that there is nobody back there. It also names the key that brings them back, being the line a reader is looking at when they want them back. A row with no room for that hint gives it up whole, because `F for th…` names no key while the counts are the part of the row that is nowhere else on the screen. --- docs/design.md | 18 +++-- src/view/draw/groups.rs | 158 ++++++++++++++++++++++++++++++++++---- src/view/forest/layout.rs | 81 +++++++++++++++---- src/view/forest/mod.rs | 124 ++++++++++++++++++++++++++++-- src/view/lines.rs | 18 ++++- src/view/phrase.rs | 11 +++ 6 files changed, 366 insertions(+), 44 deletions(-) diff --git a/docs/design.md b/docs/design.md index 9fb2984..02930c7 100644 --- a/docs/design.md +++ b/docs/design.md @@ -951,7 +951,15 @@ was rooted at. The bead leaving the collection is the one thing that ends the mode on its own; a bead that closes is still in the collection, so closing the focused bead does not. Every root and project the mode holds back goes behind one collapsed line per project rather than off the screen, which is *degrade, -never disappear* binding here as everywhere. +never disappear* binding here as everywhere. That line stands over open work +with seats on it, which the filter's own line never does, so it says how many +seats and how many beads want looking at and never claims there are none. Its +own project's line keeps it, so a reader opens the project where it already +was. It is a group like the others: it rests shut, the keys that open a group +open it, and the roots inside it rest shut as the filter's do. It names the key +that brings them back, because it is the line a reader is looking at when they +want them back, and a row with no room for that hint gives it up whole rather +than cutting it to a key nobody can press. **A finished branch draws as one line and rests shut.** Its glyph, its fraction and its fold marker already say *finished, and holds more*; opening it @@ -2306,10 +2314,10 @@ holds, so a group over live panes — unconfigured, conflicts, unattributed — rests open; a group that reports on the reading rather than on work in flight — failed projects, hidden trees — rests shut, and hidden trees in particular holds trees hidden *because* nothing live is in them, so opening it would -contradict the rule it exists to serve. Hidden trees is the one group nothing -went wrong in — the filter put them there and a key takes them back out — so it -is the only one drawn without a warning, and the only line on the screen that -names a key: *a to show all*. A group that said only how many trees it hides +contradict the rule it exists to serve. Hidden trees is a group nothing went +wrong in — the filter put them there and a key takes them back out — so it is +drawn without a warning and it names that key: *a to show all*. The roots +`Shift+F` holds back are the other group read that way. A group that said only how many trees it hides would read as "nothing to see here" while hiding broken ones, so it also says how many of them carry findings: *4 trees with no live agent · 1 with findings*. The findings stay hidden — the reader asked for that — but the group diff --git a/src/view/draw/groups.rs b/src/view/draw/groups.rs index 02e10c2..13e3466 100644 --- a/src/view/draw/groups.rs +++ b/src/view/draw/groups.rs @@ -7,21 +7,26 @@ use crate::view::fitted::{Fitted, GAP}; use crate::view::lines::{Group, GroupKind, Item}; use crate::view::palette; use crate::view::phrase; -use crate::view::row::WARNING; +use crate::view::row::{AGENT, WARNING}; use super::{pane_marker, sentence}; /// What lifts the live-agent filter, said beside the trees it is holding back. const SHOW_ALL: &str = "a to show all"; -/// The line a group is drawn as. The hidden trees are the only group nothing -/// went wrong in — the filter put them there and a key takes them back out — -/// so they are the only one drawn without a warning. +/// What brings back the roots that rooting the forest at one bead put behind +/// a line. +const WHOLE_FOREST: &str = "F for the whole forest"; + +/// The line a group is drawn as. Two of them hold roots nothing went wrong in +/// — a key put them there and a key takes them back out — so those two are +/// the ones drawn without a warning. pub(super) fn group_line(prefix: &str, group: &Group) -> Fitted { let (said, hidden) = match group.kind { GroupKind::FailedProjects => (phrase::failed_projects(group.count), false), GroupKind::Conflicts => (phrase::conflicts(group.count), false), GroupKind::HiddenTrees => (phrase::hidden_trees(group.count, group.with_findings), true), + GroupKind::HeldBack => (phrase::held_back(group.count), true), GroupKind::Unattributed => (phrase::unattributed(group.count), false), GroupKind::Unconfigured => (phrase::unconfigured(group.count), false), }; @@ -31,17 +36,58 @@ pub(super) fn group_line(prefix: &str, group: &Group) -> Fitted { } else { (format!("{WARNING} {said}"), palette::ATTENTION) }; - let state = if hidden { - vec![Span::styled(SHOW_ALL, palette::QUIET)] - } else { - Vec::new() + let state = match group.kind { + GroupKind::HiddenTrees => vec![Span::styled(SHOW_ALL, palette::QUIET)], + GroupKind::HeldBack => held_back_state(group), + _ => Vec::new(), }; - Fitted::new( + let line = Fitted::new( vec![Span::raw(prefix.to_string()), Span::styled(said, style)], Vec::new(), state, - ) + ); + match group.kind { + GroupKind::HeldBack => line.briefly(held_back_counts(group)), + _ => line, + } +} + +/// What a line standing over the roots the mode is holding back says beside +/// itself: the seats in there, the beads wanting looking at, and the key that +/// brings them back. +/// +/// The reader asked for one bead, so the roots go; what they did not ask for +/// was to be told there is nobody on them. Said in the words a line resting +/// shut over the same things already uses. +fn held_back_state(group: &Group) -> Vec> { + let mut said = held_back_counts(group); + said.push(Span::styled(WHOLE_FOREST, palette::QUIET)); + said +} + +/// The same, where the row cannot afford the key as well. +/// +/// The hint goes first and goes whole, because `F for th…` names no key while +/// the counts are the part of this line a reader can read nowhere else. +fn held_back_counts(group: &Group) -> Vec> { + let mut said = Vec::new(); + let Some(counts) = &group.held else { + return said; + }; + if counts.live_agents > 0 { + said.push(Span::styled( + format!("{AGENT} {}", phrase::agents_beneath(counts.live_agents)), + palette::AGENT, + )); + } + if counts.anomalies > 0 { + said.push(Span::styled( + format!("{WARNING} {}", phrase::anomalies_beneath(counts.anomalies)), + palette::ATTENTION, + )); + } + said } /// The scope, where the directory chose it. Nothing went wrong, so it is @@ -140,6 +186,7 @@ mod tests { use crate::model::snapshot::{a_provider, build, Collected, LoosePane, UnconfiguredPane}; use crate::model::types::testing::{key as pane_key, A_SESSION}; use crate::model::types::PaneStatus; + use crate::view::fitted::CUT; use chrono::{TimeZone, Utc}; use pretty_assertions::assert_eq; use ratatui::style::Color; @@ -170,6 +217,7 @@ mod tests { project: Some("summit-works".into()), count: 4, with_findings: 0, + held: None, }; let broken = Group { with_findings: 2, @@ -231,23 +279,104 @@ mod tests { ); } - /// Every other group is something that went wrong, and is marked as such. - /// The hidden trees are not: the user asked for them to be hidden. + /// The reader rooted the forest at one bead and everything else went + /// behind this line, open work and the seats on it included. So the line + /// says how many, rather than leaving a reader to read one number as the + /// whole truth about what is back there. + #[test] + fn a_line_over_held_back_roots_says_its_seats_and_what_wants_looking_at() { + let group = Group { + kind: GroupKind::HeldBack, + project: Some("summit-works".into()), + count: 3, + with_findings: 0, + held: Some(Counts { + total: 12, + closed: 4, + live_agents: 2, + anomalies: 1, + }), + }; + + let drawn = Painted::of(group_line(SHUT, &group), 120, 1).rows(); + + assert!(drawn[0].contains("3 other roots"), "{drawn:?}"); + assert!(drawn[0].contains("2 agents beneath"), "{drawn:?}"); + assert!(drawn[0].contains("1 bead beneath"), "{drawn:?}"); + assert!(drawn[0].contains("F for the whole forest"), "{drawn:?}"); + } + + /// A row with no room for the way out says the counts without it. The + /// counts are what a reader can read nowhere else on the screen, and a + /// key named in part is a key they cannot press. + #[test] + fn a_row_too_narrow_for_the_way_out_still_says_what_is_back_there() { + let group = Group { + kind: GroupKind::HeldBack, + project: Some("summit-works".into()), + count: 3, + with_findings: 0, + held: Some(Counts { + total: 12, + closed: 4, + live_agents: 2, + anomalies: 1, + }), + }; + + let drawn = Painted::of(group_line(SHUT, &group), 64, 1).rows(); + + assert!(drawn[0].contains("3 other roots"), "{drawn:?}"); + assert!(drawn[0].contains("2 agents beneath"), "{drawn:?}"); + assert!(drawn[0].contains("1 bead beneath"), "{drawn:?}"); + assert!(!drawn[0].contains("F for"), "{drawn:?}"); + assert!(!drawn[0].contains(CUT), "{drawn:?}"); + } + + /// And says neither where there is neither. A nought said is a column + /// spent telling a reader about nothing. + #[test] + fn a_line_over_quiet_held_back_roots_says_nothing_of_seats_at_all() { + let group = Group { + kind: GroupKind::HeldBack, + project: Some("summit-works".into()), + count: 1, + with_findings: 0, + held: Some(Counts { + total: 9, + closed: 9, + live_agents: 0, + anomalies: 0, + }), + }; + + let drawn = Painted::of(group_line(SHUT, &group), 120, 1).rows(); + + assert!(drawn[0].contains("1 other root"), "{drawn:?}"); + assert!(!drawn[0].contains("beneath"), "{drawn:?}"); + assert!(drawn[0].contains("F for the whole forest"), "{drawn:?}"); + } + + /// A group holding something that went wrong is marked as such. The two + /// that hold whole roots are not: the reader asked for those roots to be + /// out of the way, one with the filter and one with the key that roots the + /// forest at a bead. #[test] - fn only_the_group_nothing_went_wrong_in_is_drawn_without_a_warning() { + fn only_a_group_holding_roots_the_reader_put_away_is_drawn_without_a_warning() { for kind in every_kind() { let group = Group { kind, project: None, count: 2, with_findings: 0, + held: None, }; let drawn = Painted::of(group_line(SHUT, &group), 80, 1).rows(); let marked = drawn[0].contains(WARNING); assert_eq!( marked, - kind != GroupKind::HiddenTrees, + !matches!(kind, GroupKind::HiddenTrees | GroupKind::HeldBack), "{kind:?}: {drawn:?}" ); } @@ -279,6 +408,7 @@ mod tests { project: None, count: 2, with_findings: 0, + held: None, }; let painted = Painted::of(group_line(SHUT, &group), 80, 1).row(0); diff --git a/src/view/forest/layout.rs b/src/view/forest/layout.rs index b14c209..7cdd0dc 100644 --- a/src/view/forest/layout.rs +++ b/src/view/forest/layout.rs @@ -77,8 +77,13 @@ pub(super) struct Rooted { /// Whether a group is drawn at all, which is whether the snapshot has put /// anything in it. -pub(super) fn group_drawn(snapshot: &Snapshot, kind: GroupKind, project: Option<&str>) -> bool { - group_of(snapshot, kind, project).is_some() +pub(super) fn group_drawn( + snapshot: &Snapshot, + kind: GroupKind, + project: Option<&str>, + rooted: Option<&Rooted>, +) -> bool { + group_of(snapshot, kind, project, rooted).is_some() } /// Whether a project's line is drawn: where anything hangs under it — a tree @@ -192,8 +197,13 @@ pub(super) fn trees_drawn(snapshot: &Snapshot) -> Vec<&Tree> { drawn } -fn group_of(snapshot: &Snapshot, kind: GroupKind, project: Option<&str>) -> Option { - let (count, with_findings) = match kind { +fn group_of( + snapshot: &Snapshot, + kind: GroupKind, + project: Option<&str>, + rooted: Option<&Rooted>, +) -> Option { + let (count, with_findings, held) = match kind { GroupKind::HiddenTrees => { let hidden = snapshot .hidden_trees @@ -202,18 +212,51 @@ fn group_of(snapshot: &Snapshot, kind: GroupKind, project: Option<&str>) -> Opti ( hidden.clone().count(), hidden.filter(|hidden| hidden.findings).count(), + None, ) } - _ => (group_items(snapshot, kind, project).len(), 0), + // Counted off the whole roots, because this line draws none of them + // and is all a reader gets of what is behind it. + GroupKind::HeldBack => { + let held = held_back(snapshot, project, rooted); + let counts = Counts::over(held.iter().flat_map(|tree| &tree.beads)); + (held.len(), 0, Some(counts)) + } + _ => (group_items(snapshot, kind, project).len(), 0, None), }; (count > 0).then_some(Group { kind, project: project.map(str::to_string), count, with_findings, + held, }) } +/// The roots one project is holding back because the forest is rooted at one +/// bead: every root it collected but the one that bead is drawn under. None at +/// all where the forest is rooted at no bead. +/// +/// Shown and hidden alike. The mode holds back what the filter was showing as +/// well as what it was not, and one line standing for both sets is the only +/// line that adds up. +fn held_back<'a>( + snapshot: &'a Snapshot, + project: Option<&str>, + rooted: Option<&Rooted>, +) -> Vec<&'a Tree> { + let Some(rooted) = rooted else { + return Vec::new(); + }; + snapshot + .collected + .iter() + .map(Arc::as_ref) + .filter(|tree| Some(tree.project.as_str()) == project) + .filter(|tree| root_key(tree) != rooted.place.tree) + .collect() +} + /// Every group the snapshot could draw, in the order it draws them: each /// project's own, then the ones below the trees. pub(super) fn every_group( @@ -341,7 +384,7 @@ impl Layout<'_> { // and one bead is the only root there is while the forest is // rooted at one. .filter(|kind| self.rooted.is_none() || *kind != GroupKind::HiddenTrees) - .filter_map(|kind| group_of(self.snapshot, kind, Some(&project))) + .filter_map(|kind| group_of(self.snapshot, kind, Some(&project), self.rooted)) .collect(); let mut entries = trees.len() + groups.len(); let mut trunk = Vec::new(); @@ -397,10 +440,10 @@ impl Layout<'_> { trunk.push(!last); } match kind { - GroupKind::HiddenTrees => { - let hidden = self.hidden_trees(project.as_deref()); - let count = hidden.len(); - for (n, tree) in hidden.into_iter().enumerate() { + GroupKind::HiddenTrees | GroupKind::HeldBack => { + let roots = self.roots_in(kind, project.as_deref()); + let count = roots.len(); + for (n, tree) in roots.into_iter().enumerate() { TreeLayout { folds: self.folds, tree, @@ -422,10 +465,13 @@ impl Layout<'_> { } } - /// The trees the filter hid from one project, which the snapshot still - /// holds. - fn hidden_trees(&self, project: Option<&str>) -> Vec<&Tree> { - hidden_trees(self.snapshot, project) + /// The roots one of the two groups that stand over whole roots is standing + /// over, for the project it is one of. + fn roots_in(&self, kind: GroupKind, project: Option<&str>) -> Vec<&Tree> { + match kind { + GroupKind::HeldBack => held_back(self.snapshot, project, self.rooted), + _ => hidden_trees(self.snapshot, project), + } } fn draw_items(&self, items: Vec, trunk: &[bool], lines: &mut Vec) { @@ -445,7 +491,7 @@ impl Layout<'_> { /// The groups below the trees: what has no project line to hang under. fn draw_groups(&self, lines: &mut Vec) { for kind in GroupKind::BELOW_THE_TREES { - let Some(group) = group_of(self.snapshot, kind, None) else { + let Some(group) = group_of(self.snapshot, kind, None, self.rooted) else { continue; }; self.draw_group(group, &mut Vec::new(), true, lines); @@ -666,8 +712,9 @@ fn group_items(snapshot: &Snapshot, kind: GroupKind, project: Option<&str>) -> V .cloned() .map(Item::Conflict) .collect(), - // Hidden trees are drawn as trees rather than as things in a group. - GroupKind::HiddenTrees => Vec::new(), + // These two hold whole roots, drawn as trees rather than as things in + // a group. + GroupKind::HiddenTrees | GroupKind::HeldBack => Vec::new(), GroupKind::Unattributed => snapshot .unattributed .iter() diff --git a/src/view/forest/mod.rs b/src/view/forest/mod.rs index 2ad2914..b127d0a 100644 --- a/src/view/forest/mod.rs +++ b/src/view/forest/mod.rs @@ -1075,8 +1075,11 @@ impl Forest { if let Some(tree) = self.snapshot.trees.first() { return Some(Handle::Bead(Place::root(root_key(tree)))); } + let rooted = self.rooted(); layout::every_group(&self.snapshot) - .find(|(kind, project)| layout::group_drawn(&self.snapshot, *kind, project.as_deref())) + .find(|(kind, project)| { + layout::group_drawn(&self.snapshot, *kind, project.as_deref(), rooted.as_ref()) + }) .map(|(kind, project)| Handle::Group(kind, project)) } @@ -1084,9 +1087,12 @@ impl Forest { fn present(&self, handle: &Handle) -> bool { match handle { Handle::Bead(place) | Handle::Elided(place) => self.drawn(place), - Handle::Group(kind, project) => { - layout::group_drawn(&self.snapshot, *kind, project.as_deref()) - } + Handle::Group(kind, project) => layout::group_drawn( + &self.snapshot, + *kind, + project.as_deref(), + self.rooted().as_ref(), + ), Handle::Item(key) => layout::group_holding(&self.snapshot, key).is_some(), Handle::Project(project) => layout::project_drawn(&self.snapshot, project), } @@ -1182,6 +1188,7 @@ mod tests { use crate::config::{Config, Scope}; use crate::model::join::{self, Joined, Listed, ProjectRows}; use crate::model::snapshot; + use crate::model::snapshot::Counts; use crate::model::snapshot::{ a_provider, build_tree, Collected, FailedProject, ProviderState, Readiness, TrackerFailure, TrackerState, A_PROVIDER, @@ -5141,7 +5148,9 @@ credential_command = "secret harbour" ]; handles.extend( layout::every_group(&snapshot) - .filter(|(kind, project)| layout::group_drawn(&snapshot, *kind, project.as_deref())) + .filter(|(kind, project)| { + layout::group_drawn(&snapshot, *kind, project.as_deref(), None) + }) .map(|(kind, project)| Handle::Group(kind, project)), ); assert_eq!(handles.len(), 9, "{handles:#?}"); @@ -5220,7 +5229,10 @@ credential_command = "secret harbour" GroupKind::FailedProjects => found.failed_projects += count, GroupKind::Unattributed => found.loose_panes += count, GroupKind::Unconfigured => found.unconfigured_panes += count, - GroupKind::HiddenTrees => {} + // Neither holds a finding of its own: each stands over + // whole roots, and what is wrong inside one of those is + // the root's to report when the group is opened. + GroupKind::HiddenTrees | GroupKind::HeldBack => {} }, _ => {} } @@ -6949,8 +6961,41 @@ credential_command = "secret harbour" " │ └─▸ … 3 more", " └── [Unattributed orbital] 2", "▾ ferry", + " ├─▸ [HeldBack ferry] 1", + " └── [Unattributed ferry] 1", + "▾ harbour", + " └─▸ [HeldBack harbour] 1", + "▸ [FailedProjects] 1", + "▾ [Unconfigured] 1", + "▾ [Conflicts] 1", + ] + ); + } + + /// Every other root is one line away rather than gone, under the project + /// it belongs to. Orbital has no other root, so it has no such line: the + /// beads the mode stopped drawing there are above the focused bead rather + /// than held back from it. + #[test] + fn the_roots_the_mode_stops_drawing_go_behind_one_line_per_project() { + let mut forest = flatten(snapshot()); + focus_on(&mut forest, "orb-7.1"); + + assert_eq!( + sketch(&forest) + .into_iter() + .filter(|row| !row.contains("── - ")) + .collect::>(), + vec![ + "▾ orbital", + " ├─▸ ○ .1 re-point the dish", + " │ └── ! Dangling(1)", + " └── [Unattributed orbital] 2", + "▾ ferry", + " ├─▸ [HeldBack ferry] 1", " └── [Unattributed ferry] 1", "▾ harbour", + " └─▸ [HeldBack harbour] 1", "▸ [FailedProjects] 1", "▾ [Unconfigured] 1", "▾ [Conflicts] 1", @@ -6958,6 +7003,73 @@ credential_command = "secret harbour" ); } + /// One line away means the line opens onto them. They rest shut inside it, + /// as the trees the filter is holding back do. + /// + /// Ferry's root was on the screen and harbour's was behind the filter, and + /// the line opens onto either, because one line stands for both sets. + #[test] + fn opening_the_line_over_the_held_back_roots_draws_them() { + for (project, root) in [ + ("ferry", "fer-2 unread"), + ("harbour", "hbr-3 dredge the channel"), + ] { + let mut forest = flatten(snapshot()); + focus_on(&mut forest, "orb-7.1"); + let at = forest + .lines() + .iter() + .position(|line| { + matches!(&line.content, Content::Group(group) + if group.kind == GroupKind::HeldBack + && group.project.as_deref() == Some(project)) + }) + .unwrap_or_else(|| panic!("no line holds {project} back: {:#?}", sketch(&forest))); + step_onto(&mut forest, at); + + assert!(forest.apply(Action::ExpandOrChild)); + + assert!(drawn_here(&forest, root), "{:#?}", sketch(&forest)); + } + } + + /// The line stands over open work with seats and anomalies on it, which is + /// the whole reason the reader pressed the key, so it counts them rather + /// than saying a number of roots and leaving them unsaid. + #[test] + fn the_line_over_the_held_back_roots_counts_the_seats_and_anomalies_in_them() { + let mut forest = flatten(snapshot()); + select_hidden_tree(&mut forest); + assert!(forest.apply(Action::FocusForest)); + + let held = forest + .lines() + .iter() + .find_map(|line| match &line.content { + Content::Group(group) + if group.kind == GroupKind::HeldBack + && group.project.as_deref() == Some("orbital") => + { + group.held.clone() + } + _ => None, + }) + .unwrap_or_else(|| panic!("no counts on orbital's line: {:#?}", sketch(&forest))); + + assert_eq!(held, counts_of(&forest, "orbital", "orb-7")); + assert!(held.live_agents > 0, "orbital is where the agents are"); + } + + /// What one of a project's trees adds up to, by its root. + fn counts_of(forest: &Forest, project: &str, root: &str) -> Counts { + forest + .snapshot() + .tree(&key(project, root)) + .unwrap_or_else(|| panic!("{project} has no tree at {root}")) + .counts + .clone() + } + /// The reader asked to finish the bead they pressed the key on, so that is /// the bead the forest they come back to is standing on, wherever they had /// walked to underneath it. diff --git a/src/view/lines.rs b/src/view/lines.rs index 7749719..fe6f6ac 100644 --- a/src/view/lines.rs +++ b/src/view/lines.rs @@ -216,6 +216,13 @@ pub struct Group { /// known by, so a fold on one project's group is not a fold on another's. pub project: Option, pub count: usize, + /// What the roots this group is holding back add up to, for the group that + /// holds whole roots back because the forest is rooted at one bead. + /// + /// Nothing for the groups that hold things rather than roots: a pane, a + /// failed project and a conflict are each drawn whole when the group opens, + /// and none of them is a set of beads to count. + pub held: Option, /// How many of the things this group holds carry findings the screen is /// not drawing, because the group holds them rather than showing them. /// @@ -234,6 +241,9 @@ pub enum GroupKind { FailedProjects, Unconfigured, Conflicts, + /// The roots the forest is not drawing because the reader rooted it at one + /// bead. *coined* + HeldBack, HiddenTrees, Unattributed, } @@ -253,7 +263,11 @@ impl GroupKind { /// order: the trees the filter is holding back, then the panes working in /// its paths that no bead claims. Everything beneath a project is under /// its one line, so a reader has one place to look. - pub const UNDER_A_PROJECT: [GroupKind; 2] = [GroupKind::HiddenTrees, GroupKind::Unattributed]; + pub const UNDER_A_PROJECT: [GroupKind; 3] = [ + GroupKind::HeldBack, + GroupKind::HiddenTrees, + GroupKind::Unattributed, + ]; /// Whether what a group holds is live, which is what rests it open. A /// count is not a view: a shut group over live panes says they exist and @@ -263,7 +277,7 @@ impl GroupKind { pub(crate) fn live(self) -> bool { match self { GroupKind::Unconfigured | GroupKind::Conflicts | GroupKind::Unattributed => true, - GroupKind::FailedProjects | GroupKind::HiddenTrees => false, + GroupKind::FailedProjects | GroupKind::HiddenTrees | GroupKind::HeldBack => false, } } } diff --git a/src/view/phrase.rs b/src/view/phrase.rs index da7c225..607f1fc 100644 --- a/src/view/phrase.rs +++ b/src/view/phrase.rs @@ -469,6 +469,17 @@ pub fn conflicts(count: usize) -> String { format!("{count} {conflict} nothing could settle") } +/// The roots the forest is not drawing because the reader rooted it at one +/// bead. +/// +/// *Other* rather than hidden: a key put them behind this line and the same +/// key brings them back, so nothing here has gone wrong and nothing here is +/// the tracker's doing. +pub fn held_back(count: usize) -> String { + let root = if count == 1 { "root" } else { "roots" }; + format!("{count} other {root}") +} + /// Trees the live-agent filter is holding back, and how many of those carry /// findings that are therefore not on screen. /// From d0bc21dc85671e11ca76de86853e60043bb6135c Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:27:42 +0100 Subject: [PATCH 3/6] fix(view): hold the mode to what it promises the reader Every root but the focused bead's is behind a line while the forest is rooted at one bead, so that line is what is shut over the beads in those roots. It joins the chain above them, which is the one answer both the ancestry a lost selection falls back to and the opening that reaches a line are read from. A search offers what it can be taken to, so it walks the focused tree from the focused bead and the other roots from theirs: a bead above the focused one is drawn nowhere, and counting it would step the reader onto it and report nothing found. Putting the forest back opens whatever has been shut over the bead it was rooted at, asked of the forest the reader is coming back to rather than the one they are leaving, so a bead already drawn there is left as it is. The press says the screen changed, which is what asks for the row to be drawn again. A collection that no longer holds the focused bead lets the place it stood on go. Held, it would take the next press of the key and spend it putting back a forest that was already back, and the bead coming back in a later collection would turn the mode on again with nobody asking. A bead the tracker moved is followed to where it moved to, by the bead rather than by the way down that used to reach it. --- docs/design.md | 12 ++- src/view/forest/mod.rs | 209 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 8 deletions(-) diff --git a/docs/design.md b/docs/design.md index 02930c7..56dc878 100644 --- a/docs/design.md +++ b/docs/design.md @@ -947,16 +947,22 @@ collapsed-except-selected and the rejection stands: the selection has no bearing on what is open, and it has none here either, because the mode stands on the bead named at the keystroke and moving about under it moves nothing. Pressing the key again puts the forest back, with the selection on the bead it -was rooted at. The bead leaving the collection is the one thing that ends the +was rooted at, opening whatever has been shut over that bead in the meantime. The bead leaving the collection is the one thing that ends the mode on its own; a bead that closes is still in the collection, so closing the -focused bead does not. Every root and project the mode holds back goes behind +focused bead does not, and a bead the tracker has moved is followed to where it +moved to. Every root and project the mode holds back goes behind one collapsed line per project rather than off the screen, which is *degrade, never disappear* binding here as everywhere. That line stands over open work with seats on it, which the filter's own line never does, so it says how many seats and how many beads want looking at and never claims there are none. Its own project's line keeps it, so a reader opens the project where it already was. It is a group like the others: it rests shut, the keys that open a group -open it, and the roots inside it rest shut as the filter's do. It names the key +open it, and the roots inside it rest shut as the filter's do. A bead in one of +them is reached by the keys that reach any bead, the line opening to let the +selection in, so looking a bead up in another root is not paid for with the +put-back key. A search offers what it can take the reader to, so it counts the +beads under the focused one and the beads in the roots behind the line, and a +bead the mode draws nowhere is no more a match than one no tracker holds. It names the key that brings them back, because it is the line a reader is looking at when they want them back, and a row with no room for that hint gives it up whole rather than cutting it to a key nobody can press. diff --git a/src/view/forest/mod.rs b/src/view/forest/mod.rs index b127d0a..b6fdb78 100644 --- a/src/view/forest/mod.rs +++ b/src/view/forest/mod.rs @@ -257,6 +257,13 @@ impl Forest { // snapshot, exactly as the folds and the cursor do. snapshot.refilter(self.snapshot.filter); self.take(snapshot); + // The bead leaving the collection ends the mode, and the place it + // stood on goes with it. Kept, it would take the next press of the + // key and spend it putting back a forest that is already back. + // + // Found again by the bead rather than by the way down to it, because + // a tracker that reparented it has moved the bead and not lost it. + self.focused = self.focused.take().and_then(|place| self.rerooted(&place)); self.spend_folds(&folded_over); self.cursor = ancestry.into_iter().find(|handle| self.present(handle)); self.lay_out(); @@ -348,7 +355,15 @@ impl Forest { _ => return chain, }; chain.extend(place.forebears().map(Handle::Bead)); - if self.hidden(&place.tree) { + // A root the mode is holding back is behind the line it put it + // behind, and the group the filter would have put it in is not drawn + // at all while the forest is rooted at one bead. + if self.held_back(&place.tree) { + chain.push(Handle::Group( + GroupKind::HeldBack, + Some(place.tree.project.clone()), + )); + } else if self.hidden(&place.tree) { chain.push(Handle::Group( GroupKind::HiddenTrees, Some(place.tree.project.clone()), @@ -358,6 +373,14 @@ impl Forest { chain } + /// Whether the mode is holding the tree a root names back, which it is + /// for every root but the one the forest is rooted at. + fn held_back(&self, root: &BeadKey) -> bool { + self.focused + .as_ref() + .is_some_and(|place| place.tree != *root) + } + /// Whether the filter is holding the tree a root names back. fn hidden(&self, root: &BeadKey) -> bool { self.snapshot @@ -444,8 +467,26 @@ impl Forest { /// tracker refused holds a place but no node, and there is no tree to draw /// from a bead that is not there. fn focus_forest(&mut self) { - if self.focused.is_some() { - self.cursor = self.focused.take().map(Handle::Bead); + if let Some(place) = self.focused.take() { + let on = Handle::Bead(place.clone()); + // Drawn again first, so what is shut over the bead is asked of + // the forest the reader is coming back to rather than of the one + // they are leaving. The rows it drew go back afterwards, because + // the press is read for whether the screen changed and these are + // the rows it changed from. + let rooted = self.lay_out(); + let on_screen = self + .lines + .iter() + .any(|line| handle_of(line).as_ref() == Some(&on)); + self.lines = rooted; + if !on_screen { + // They shut something over it from inside the mode, and a + // forest that comes back with the selection somewhere else + // has not put them back where they were. + self.open_over(&place); + } + self.cursor = Some(on); return; } let Some(Handle::Bead(place)) = self.cursor.clone() else { @@ -456,6 +497,16 @@ impl Forest { } } + /// Where the bead a place stood on is now, which is the place itself + /// while nothing has moved. Nothing at all once the collection no longer + /// holds that bead, which is what ends the mode. + fn rerooted(&self, place: &Place) -> Option { + if self.drawn(place) { + return Some(place.clone()); + } + self.place_of(place.steps.last()?) + } + /// Where the forest is rooted, where the reader has rooted it at a bead /// the snapshot in hand still draws. /// @@ -880,12 +931,21 @@ impl Forest { fn beads_drawn(&self) -> Vec<(BeadKey, String)> { let mut drawn = Vec::new(); let mut listed = BTreeSet::new(); + let rooted = self.rooted(); for tree in layout::trees_drawn(&self.snapshot) { + // The tree the forest is rooted at a bead of is walked from that + // bead, because the beads above it are drawn nowhere and a search + // that counted one would step onto it and find nothing there. + let way = rooted + .as_ref() + .filter(|rooted| rooted.place.tree == root_key(tree)) + .map_or(&[0][..], |rooted| &rooted.way); + let (at, above) = way.split_last().expect("a way down ends somewhere"); step_down( tree, self.facts.tree(&root_key(tree)), - 0, - &[], + *at, + above, &mut listed, &mut drawn, ); @@ -7153,6 +7213,145 @@ credential_command = "secret harbour" ); } + /// And the next press roots the forest at the bead the reader is on, + /// rather than being spent putting back a forest that is already back. + #[test] + fn the_key_roots_the_forest_afresh_once_the_focused_bead_has_gone() { + let mut forest = flatten(snapshot()); + toggle_fold_of(&mut forest, "orb-7.1"); + focus_on(&mut forest, "orb-7.1.1"); + let renamed = edited(ORBITAL, r#""id":"orb-7.1.1""#, r#""id":"orb-7.1.9""#); + forest.refresh(gather( + vec![ + tree_of("orbital", &renamed), + Tree::tracker_unreachable("ferry", "fer-2", TrackerFailure::Auth), + tree_of("harbour", HARBOUR), + ], + Vec::new(), + Filter::LiveAgents, + )); + + focus_on(&mut forest, "orb-7.1"); + + assert!( + !drawn_here(&forest, "fer-2"), + "rooted at the bead just asked for: {:#?}", + sketch(&forest) + ); + } + + /// Going to a bead opens what is shut over it, and under this mode what is + /// shut over every other root is the line the mode put them behind. A + /// search that found a bead it cannot reach is a search that failed. + #[test] + fn going_to_a_held_back_bead_opens_the_line_holding_its_root() { + let mut forest = flatten(snapshot()); + focus_on(&mut forest, "orb-7.1"); + + assert!( + forest.go_to(&key("harbour", "hbr-3")), + "cannot reach harbour: {:#?}", + sketch(&forest) + ); + assert_eq!(cursor(&forest), Some(&key("harbour", "hbr-3"))); + } + + /// A root the filter was showing as much as one it was not. The mode holds + /// both back behind the one line, so that line is what is shut over either. + #[test] + fn going_to_a_bead_in_a_root_the_filter_was_showing_opens_it_as_well() { + let mut forest = flatten(snapshot()); + select_hidden_tree(&mut forest); + assert!(forest.apply(Action::FocusForest)); + + assert!( + forest.go_to(&key("orbital", "orb-7.1")), + "cannot reach orbital: {:#?}", + sketch(&forest) + ); + assert_eq!(cursor(&forest), Some(&key("orbital", "orb-7.1"))); + } + + /// The reader is put back on the bead they were finishing even where they + /// shut something over it while they were down there. A forest that comes + /// back with the selection somewhere else has not put them back. + #[test] + fn putting_the_forest_back_opens_what_has_been_shut_over_the_bead() { + let mut forest = flatten(snapshot()); + focus_on(&mut forest, "orb-7.1"); + forest.folds.set(Handle::Project("orbital".into()), false); + + assert!(forest.apply(Action::FocusForest)); + + assert_eq!(cursor(&forest), Some(&key("orbital", "orb-7.1"))); + } + + /// A bead that moved is still the bead. What ends the mode is the bead + /// going out of the collection, and a tracker that reparented it has done + /// nothing of the kind. + #[test] + fn a_collection_that_moved_the_focused_bead_stays_rooted_at_it() { + let mut forest = flatten(snapshot()); + toggle_fold_of(&mut forest, "orb-7.1"); + focus_on(&mut forest, "orb-7.1.2"); + let moved = edited( + ORBITAL, + r#""seal the feed horn","status":"open", + "dependencies":[{"depends_on_id":"orb-7.1""#, + r#""seal the feed horn","status":"open", + "dependencies":[{"depends_on_id":"orb-7""#, + ); + + forest.refresh(gather( + vec![ + tree_of("orbital", &moved), + Tree::tracker_unreachable("ferry", "fer-2", TrackerFailure::Auth), + tree_of("harbour", HARBOUR), + ], + Vec::new(), + Filter::LiveAgents, + )); + + assert!( + !drawn_here(&forest, "fer-2"), + "still rooted at one bead: {:#?}", + sketch(&forest) + ); + assert!(drawn_here(&forest, "seal the feed horn")); + } + + /// Pressing the key to come back out changes what is on the screen, so the + /// press says so. A press the loop reads as changing nothing leaves the + /// rooted forest drawn over a forest that has been put back. + #[test] + fn putting_the_forest_back_says_the_screen_changed() { + let mut forest = flatten(snapshot()); + focus_on(&mut forest, "orb-7"); + + assert!(forest.apply(Action::FocusForest)); + } + + /// A search counts the matches it can take the reader to. Under this mode + /// the beads above the focused one are drawn nowhere, so a search that + /// counted them would step onto one and report it as nothing found. + #[test] + fn every_match_a_search_counts_under_the_mode_can_be_landed_on() { + let mut forest = flatten(snapshot()); + focus_on(&mut forest, "orb-7.1"); + + let first = forest.seek("the"); + let Landed::On { of, .. } = first else { + panic!("nothing matched: {first:?}") + }; + for step in 1..=of { + let landed = forest.next_match(true); + assert!( + matches!(landed, Some(Landed::On { .. })), + "match {step} of {of} cannot be landed on: {landed:#?}", + ); + } + } + /// A bead closing is not the bead leaving. `bdi` reads every bead a /// tracker holds, so a closed one stays in the collection and the forest /// stays rooted at it. From 7cc5b41c09097767a63b548476c096f7b1354b81 Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:25:59 +0100 Subject: [PATCH 4/6] fix(view): hold the beads above the focused bead behind the line Rooting the forest at one bead draws that bead where a root is drawn and stops there, so the beads above it are in no drawing. They go behind the line that holds every other root back, under the same project, and that line counts them: a seat on one of them is a seat nothing else on the screen says is there, and *no live agent is hidden* leaves no room for it. The focused bead stays at the root of the forest rather than being drawn a second time behind the line, so opening the line never redraws the tree the reader is standing in. What is behind it is the root they are working under and every branch off it, resting shut as a held-back root does. The line joins the chain above those beads, so a search counts them and going to one opens the line on the way. Every copy of that bead is left out of the root behind the line, so the mode draws it once, at the copy the key was pressed on. Where the forest is rooted is therefore asked before the trees are: for a bead its own tree reaches twice, the first way down to it, and to everything only that copy reaches, is a way down to a row nothing draws. A search that answered with one of those would count a bead it could not then land on. A search steps through the beads in the order the rows come out, and the drawing is asked for that order rather than told it a second time: the focused bead's tree from that bead, then the roots behind the line in the order the line opens onto them. A reader standing on the line and stepping to the next match steps into what is behind it, because a shut line is asked what it holds rather than read past, and this one holds roots. With every fold open, the beads that order yields are the beads the rows draw, in the order they draw them, which is what holds the two together as the mode moves the rows about. A root the tracker files under another root is followed the same way as a bead moved further down. Its place carries no steps, so the name to look it up by is the tree's own, and the mode goes on standing on the bead instead of ending on a collection that still holds it. The line counts the beads behind it rather than the beads in the roots they came from, because the focused bead and everything beneath it are on the screen already. A run behind the line stands for the members left in it and what is beneath them, counted by putting the bead drawn elsewhere among the beads the way down came through: that is the set the walk already stops at, so a way back into it is cut here exactly as the drawing cuts it as a loop. It says `3 roots held back`. The root the reader is standing in is behind it as well now, so *other* is not true of what is back there, and every root it counts has a row of its own nowhere else. A row inside the line is a bead row and says what is beneath it in the work, the focused branch included where it is beneath that bead. That is what a shut fold has counted everywhere since two copies of a bead were made not to add up, and `shut_over` now carries this instance of it. --- docs/design.md | 17 +- src/view/draw/groups.rs | 6 +- src/view/forest/layout.rs | 237 ++++++++++++++---- src/view/forest/mod.rs | 505 ++++++++++++++++++++++++++++++++++---- src/view/phrase.rs | 12 +- 5 files changed, 664 insertions(+), 113 deletions(-) diff --git a/docs/design.md b/docs/design.md index 56dc878..8b59da0 100644 --- a/docs/design.md +++ b/docs/design.md @@ -950,19 +950,24 @@ Pressing the key again puts the forest back, with the selection on the bead it was rooted at, opening whatever has been shut over that bead in the meantime. The bead leaving the collection is the one thing that ends the mode on its own; a bead that closes is still in the collection, so closing the focused bead does not, and a bead the tracker has moved is followed to where it -moved to. Every root and project the mode holds back goes behind +moved to. Everything the mode stops drawing goes behind one collapsed line per project rather than off the screen, which is *degrade, -never disappear* binding here as everywhere. That line stands over open work +never disappear* binding here as everywhere: every other root and every other +project's, and the focused bead's own root for the part of it that is left — +the beads above that bead and every branch off them, with the bead itself left +to the root of the forest rather than drawn a second time. That line stands over open work with seats on it, which the filter's own line never does, so it says how many -seats and how many beads want looking at and never claims there are none. Its +seats and how many beads want looking at and never claims there are none. It +counts the beads it is standing over rather than the roots they came from, +because the beads on the screen are not behind it. Its own project's line keeps it, so a reader opens the project where it already was. It is a group like the others: it rests shut, the keys that open a group open it, and the roots inside it rest shut as the filter's do. A bead in one of them is reached by the keys that reach any bead, the line opening to let the selection in, so looking a bead up in another root is not paid for with the -put-back key. A search offers what it can take the reader to, so it counts the -beads under the focused one and the beads in the roots behind the line, and a -bead the mode draws nowhere is no more a match than one no tracker holds. It names the key +put-back key. A search offers what it can take the reader to, which under this +mode is every bead the forest holds: the ones beneath the focused bead, and the +ones behind the line, which it opens on the way. It names the key that brings them back, because it is the line a reader is looking at when they want them back, and a row with no room for that hint gives it up whole rather than cutting it to a key nobody can press. diff --git a/src/view/draw/groups.rs b/src/view/draw/groups.rs index 13e3466..eedcd0c 100644 --- a/src/view/draw/groups.rs +++ b/src/view/draw/groups.rs @@ -300,7 +300,7 @@ mod tests { let drawn = Painted::of(group_line(SHUT, &group), 120, 1).rows(); - assert!(drawn[0].contains("3 other roots"), "{drawn:?}"); + assert!(drawn[0].contains("3 roots held back"), "{drawn:?}"); assert!(drawn[0].contains("2 agents beneath"), "{drawn:?}"); assert!(drawn[0].contains("1 bead beneath"), "{drawn:?}"); assert!(drawn[0].contains("F for the whole forest"), "{drawn:?}"); @@ -326,7 +326,7 @@ mod tests { let drawn = Painted::of(group_line(SHUT, &group), 64, 1).rows(); - assert!(drawn[0].contains("3 other roots"), "{drawn:?}"); + assert!(drawn[0].contains("3 roots held back"), "{drawn:?}"); assert!(drawn[0].contains("2 agents beneath"), "{drawn:?}"); assert!(drawn[0].contains("1 bead beneath"), "{drawn:?}"); assert!(!drawn[0].contains("F for"), "{drawn:?}"); @@ -352,7 +352,7 @@ mod tests { let drawn = Painted::of(group_line(SHUT, &group), 120, 1).rows(); - assert!(drawn[0].contains("1 other root"), "{drawn:?}"); + assert!(drawn[0].contains("1 root held back"), "{drawn:?}"); assert!(!drawn[0].contains("beneath"), "{drawn:?}"); assert!(drawn[0].contains("F for the whole forest"), "{drawn:?}"); } diff --git a/src/view/forest/layout.rs b/src/view/forest/layout.rs index 7cdd0dc..e485904 100644 --- a/src/view/forest/layout.rs +++ b/src/view/forest/layout.rs @@ -5,15 +5,16 @@ //! nothing here moves a fold: it asks which way one points and draws what //! that says. +use std::collections::BTreeSet; use std::sync::Arc; use crate::config::Scope; use crate::model::join::BeadKey; -use crate::model::snapshot::{Counts, Snapshot, Tree}; +use crate::model::snapshot::{Counts, Node, Snapshot, Tree}; use crate::model::tree::Link; use crate::view::lines::{ - first_copy, marker, notes_of, prefix, root_key, way_below, Content, Group, GroupKind, Item, - Line, Note, Place, ProjectLine, Unread, INDENT, + first_copy, marker, notes_of, prefix, root_key, run_size, way_below, Content, Group, GroupKind, + Item, Line, Note, Place, ProjectLine, Unread, INDENT, }; use crate::view::row; @@ -43,6 +44,11 @@ enum Child<'a> { /// Asked at every depth. A root is a bead row like any other, and the one /// question a fold raises — what did that just take off the screen — has one /// answer wherever it is asked. +/// +/// The same reading answers a root drawn behind the line the mode holding +/// roots back puts them behind: the bead the forest is rooted at is beneath +/// that root and drawn at the top of the screen, and this count holds it, for +/// the same reason two copies of a bead do not add up. fn shut_over(beneath: Counts, first: bool, folded: Option) -> Option { (folded == Some(false) && first).then_some(beneath) } @@ -139,33 +145,42 @@ fn hidden_trees<'a>(snapshot: &'a Snapshot, project: Option<&str>) -> Vec<&'a Tr /// /// A group resting shut draws none of its contents, so a reader standing on /// its line has nothing below it saying where they are among the beads. This -/// is read from `hidden_trees`, which is the source `trees_drawn` reads, so -/// the anchor and the order it points into cannot disagree. +/// is read from the same two answers `walked` reads, so the anchor and the +/// order it points into cannot disagree. /// -/// Only the hidden trees hold beads. The panes, failed projects and conflicts -/// the other groups hold are in no ordering of beads and have nothing here to -/// answer with. +/// Only the two groups over roots hold beads. The panes, failed projects and +/// conflicts the other groups hold are in no ordering of beads and have +/// nothing here to answer with. pub(super) fn first_bead_of( snapshot: &Snapshot, kind: GroupKind, project: Option<&str>, + rooted: Option<&Rooted>, ) -> Option { - match kind { - GroupKind::HiddenTrees => { - let tree = hidden_trees(snapshot, project).into_iter().next()?; - Some(BeadKey { - project: tree.project.clone(), - id: tree.beads.first()?.id.clone(), - }) + let tree = match kind { + GroupKind::HiddenTrees => hidden_trees(snapshot, project).into_iter().next()?, + GroupKind::HeldBack => { + held_back(snapshot, project, rooted) + .into_iter() + .next()? + .tree } - _ => None, - } + _ => return None, + }; + Some(BeadKey { + project: tree.project.clone(), + id: tree.beads.first()?.id.clone(), + }) } -/// Every tree the forest draws, in the order it draws them: project by -/// project as the config names them, and within a project the trees the -/// filter shows before the ones it hid, which is where `draw_project` puts -/// the hidden-trees group. +/// Every tree the forest draws and the way down it starts drawing from, in +/// the order the rows come out: project by project as the config names them, +/// and within a project the trees in the order `draw_project` puts them. +/// +/// Rooted at one bead, that is the bead's own tree walked from the bead, and +/// then every root behind the line in the order the line opens onto them — +/// the bead's own tree among them, walked from its root for the part of it +/// the mode stopped drawing. /// /// `snapshot.trees` cannot answer this. It is in the order the projects were /// read, which is why `snapshot.projects` exists at all — a project drawn @@ -179,20 +194,42 @@ pub(super) fn first_bead_of( /// in that chain, so going to a bead opens the project over it exactly as it /// opens the folds. An order that left those trees out would be an order a /// search could not use. -pub(super) fn trees_drawn(snapshot: &Snapshot) -> Vec<&Tree> { +pub(super) fn walked<'a>( + snapshot: &'a Snapshot, + rooted: Option<&Rooted>, +) -> Vec<(&'a Tree, Vec)> { let mut drawn = Vec::new(); for project in &snapshot.projects { if !project_drawn(snapshot, project) { continue; } + let Some(rooted) = rooted else { + drawn.extend( + snapshot + .trees + .iter() + .filter(|tree| tree.project == *project) + .map(|tree| (Arc::as_ref(tree), vec![0])), + ); + drawn.extend( + hidden_trees(snapshot, Some(project)) + .into_iter() + .map(|tree| (tree, vec![0])), + ); + continue; + }; + if rooted.place.tree.project == *project { + drawn.extend( + snapshot + .tree(&rooted.place.tree) + .map(|tree| (tree, rooted.way.clone())), + ); + } drawn.extend( - snapshot - .trees - .iter() - .filter(|tree| tree.project == *project) - .map(Arc::as_ref), + held_back(snapshot, Some(project), Some(rooted)) + .into_iter() + .map(|root| (root.tree, vec![0])), ); - drawn.extend(hidden_trees(snapshot, Some(project))); } drawn } @@ -215,11 +252,12 @@ fn group_of( None, ) } - // Counted off the whole roots, because this line draws none of them - // and is all a reader gets of what is behind it. + // Counted off the beads behind the line rather than off the roots they + // came from: this line is all a reader gets of what is behind it, and + // the bead the forest is rooted at is on the screen already. GroupKind::HeldBack => { let held = held_back(snapshot, project, rooted); - let counts = Counts::over(held.iter().flat_map(|tree| &tree.beads)); + let counts = Counts::over(held.iter().flat_map(Behind::beads)); (held.len(), 0, Some(counts)) } _ => (group_items(snapshot, kind, project).len(), 0, None), @@ -233,9 +271,62 @@ fn group_of( }) } -/// The roots one project is holding back because the forest is rooted at one -/// bead: every root it collected but the one that bead is drawn under. None at -/// all where the forest is rooted at no bead. +/// One root drawn behind a group's line, and the bead of it the forest is +/// drawing somewhere else. +/// +/// Only the root the focused bead stands in has such a bead. The mode draws +/// that bead where a root is drawn, so what is left behind the line is the +/// beads above it and every branch off them. +struct Behind<'a> { + tree: &'a Tree, + /// The bead drawn elsewhere, by its place among the tree's beads. + without: Option, +} + +impl<'a> Behind<'a> { + /// The beads this root leaves behind the line: every one it reaches + /// without stepping onto the bead drawn elsewhere, which is where the + /// drawing stops as well. + fn beads(&self) -> Vec<&'a Node> { + reached(self.tree, [0], self.without) + .into_iter() + .filter_map(|at| self.tree.beads.get(at)) + .collect() + } +} + +/// Every bead a walk from `from` reaches, `from` included, without stepping +/// onto the bead the forest is drawing somewhere else. +/// +/// Which is what a count of what a line stands over has to be walked with: the +/// drawing stops at that bead, so a count that went past it names work the +/// reader is already looking at. +fn reached( + tree: &Tree, + from: impl IntoIterator, + without: Option, +) -> BTreeSet { + let mut walked = BTreeSet::new(); + let mut left: Vec = from.into_iter().collect(); + while let Some(at) = left.pop() { + if Some(at) == without || !walked.insert(at) { + continue; + } + left.extend( + tree.children + .get(at) + .into_iter() + .flatten() + .map(|link| link.bead), + ); + } + walked +} + +/// What one project is holding back because the forest is rooted at one bead: +/// every root it collected but the one that bead stands in, and that one for +/// the part of it the mode stopped drawing. None at all where the forest is +/// rooted at no bead. /// /// Shown and hidden alike. The mode holds back what the filter was showing as /// well as what it was not, and one line standing for both sets is the only @@ -244,16 +335,30 @@ fn held_back<'a>( snapshot: &'a Snapshot, project: Option<&str>, rooted: Option<&Rooted>, -) -> Vec<&'a Tree> { +) -> Vec> { let Some(rooted) = rooted else { return Vec::new(); }; + let (focused, above) = rooted.way.split_last().expect("a way down ends somewhere"); snapshot .collected .iter() .map(Arc::as_ref) .filter(|tree| Some(tree.project.as_str()) == project) - .filter(|tree| root_key(tree) != rooted.place.tree) + .filter_map(|tree| { + if root_key(tree) != rooted.place.tree { + return Some(Behind { + tree, + without: None, + }); + } + // The root that bead is itself leaves nothing here: the whole tree + // hangs beneath it, so the mode stopped drawing none of it. + (!above.is_empty()).then_some(Behind { + tree, + without: Some(*focused), + }) + }) .collect() } @@ -299,7 +404,7 @@ struct Layout<'a> { rooted: Option<&'a Rooted>, } -impl Layout<'_> { +impl<'a> Layout<'a> { fn draw(&self) -> Vec { let mut lines = Vec::new(); // Every project the config names, in that order — the ones with rows @@ -396,6 +501,7 @@ impl Layout<'_> { facts: self.facts.tree(&root_key(tree)), rests_shut: false, rooted: self.rooted, + without: None, } .draw(&mut trunk, entries == 0, lines); } @@ -443,13 +549,14 @@ impl Layout<'_> { GroupKind::HiddenTrees | GroupKind::HeldBack => { let roots = self.roots_in(kind, project.as_deref()); let count = roots.len(); - for (n, tree) in roots.into_iter().enumerate() { + for (n, root) in roots.into_iter().enumerate() { TreeLayout { folds: self.folds, - tree, - facts: self.facts.tree(&root_key(tree)), + tree: root.tree, + facts: self.facts.tree(&root_key(root.tree)), rests_shut: true, rooted: None, + without: root.without, } .draw(trunk, n + 1 == count, lines); } @@ -465,12 +572,18 @@ impl Layout<'_> { } } - /// The roots one of the two groups that stand over whole roots is standing - /// over, for the project it is one of. - fn roots_in(&self, kind: GroupKind, project: Option<&str>) -> Vec<&Tree> { + /// The roots one of the two groups that stand over roots is standing over, + /// for the project it is one of. + fn roots_in(&self, kind: GroupKind, project: Option<&str>) -> Vec> { match kind { GroupKind::HeldBack => held_back(self.snapshot, project, self.rooted), - _ => hidden_trees(self.snapshot, project), + _ => hidden_trees(self.snapshot, project) + .into_iter() + .map(|tree| Behind { + tree, + without: None, + }) + .collect(), } } @@ -511,6 +624,10 @@ struct TreeLayout<'a> { /// The bead to draw this tree from, where the reader has rooted the forest /// at one. Its own root otherwise. rooted: Option<&'a Rooted>, + /// The bead to leave out, because the forest is drawing it somewhere + /// else. Only the root the focused bead stands in, drawn behind the line + /// the mode holds it back with, has one. + without: Option, } impl TreeLayout<'_> { @@ -609,7 +726,7 @@ impl TreeLayout<'_> { folded: Some(open), place: None, content: Content::Elided { - count: self.facts.run_size(self.tree, &members, above), + count: self.run_size(&members, above), under: parent.clone(), }, }); @@ -670,12 +787,40 @@ impl TreeLayout<'_> { /// one line for the run that is not. fn children_entries<'a>(&'a self, at: usize, above: &[usize]) -> Vec> { let (drawn, elided) = self.facts.split(self.tree, at, above); - let mut entries: Vec = drawn.into_iter().map(Child::Node).collect(); + let mut entries: Vec = drawn + .into_iter() + .filter(|link| self.draws(link)) + .map(Child::Node) + .collect(); + let elided: Vec<&Link> = elided.into_iter().filter(|link| self.draws(link)).collect(); if !elided.is_empty() { entries.push(Child::Elided(elided)); } entries } + + fn draws(&self, link: &Link) -> bool { + Some(link.bead) != self.without + } + + /// What a run stands for: its members and everything beneath them. Walked + /// again where a bead is being left out, because the tree's own answer was + /// worked out over a run this drawing is not making and reaches beads it + /// is not drawing. + /// + /// The bead left out is put among the beads the way down came through, + /// which is where the count already stops: a way back to one of those is + /// a loop the drawing cuts, and the bead drawn elsewhere is cut for the + /// same reason its rows are. + fn run_size(&self, members: &[&Link], above: &[usize]) -> usize { + match self.without { + Some(elsewhere) => { + let above: Vec = above.iter().copied().chain([elsewhere]).collect(); + run_size(self.tree, members, &above) + } + None => self.facts.run_size(self.tree, members, above), + } + } } impl Layout<'_> { diff --git a/src/view/forest/mod.rs b/src/view/forest/mod.rs index b6fdb78..72a66e8 100644 --- a/src/view/forest/mod.rs +++ b/src/view/forest/mod.rs @@ -355,10 +355,10 @@ impl Forest { _ => return chain, }; chain.extend(place.forebears().map(Handle::Bead)); - // A root the mode is holding back is behind the line it put it - // behind, and the group the filter would have put it in is not drawn - // at all while the forest is rooted at one bead. - if self.held_back(&place.tree) { + // A line the mode is holding back is behind the line it put it + // behind, and the group the filter would have put its tree in is not + // drawn at all while the forest is rooted at one bead. + if self.held_back(place) { chain.push(Handle::Group( GroupKind::HeldBack, Some(place.tree.project.clone()), @@ -373,12 +373,16 @@ impl Forest { chain } - /// Whether the mode is holding the tree a root names back, which it is - /// for every root but the one the forest is rooted at. - fn held_back(&self, root: &BeadKey) -> bool { - self.focused - .as_ref() - .is_some_and(|place| place.tree != *root) + /// Whether the mode is holding a line back, which it is for every line but + /// the bead the forest is rooted at and the ones beneath it. + /// + /// The rest of that bead's own root is held back as much as another root + /// is: the mode draws the bead where a root is drawn and stops there, so + /// the beads above it are behind the line the other roots are behind. + fn held_back(&self, place: &Place) -> bool { + self.focused.as_ref().is_some_and(|focused| { + focused.tree != place.tree || !place.steps.starts_with(&focused.steps) + }) } /// Whether the filter is holding the tree a root names back. @@ -500,11 +504,15 @@ impl Forest { /// Where the bead a place stood on is now, which is the place itself /// while nothing has moved. Nothing at all once the collection no longer /// holds that bead, which is what ends the mode. + /// + /// A place with no steps stands on the root of its tree, and a root filed + /// under another root has moved as much as any other bead: it is looked + /// for by the same name, which for that place is the tree's own. fn rerooted(&self, place: &Place) -> Option { if self.drawn(place) { return Some(place.clone()); } - self.place_of(place.steps.last()?) + self.place_of(place.steps.last().unwrap_or(&place.tree)) } /// Where the forest is rooted, where the reader has rooted it at a bead @@ -872,9 +880,12 @@ impl Forest { fn first_bead_under(&self) -> Option { let resting_on = self.lines.get(self.selected)?; let shut_over = match &resting_on.content { - Content::Group(group) if resting_on.folded == Some(false) => { - layout::first_bead_of(&self.snapshot, group.kind, group.project.as_deref()) - } + Content::Group(group) if resting_on.folded == Some(false) => layout::first_bead_of( + &self.snapshot, + group.kind, + group.project.as_deref(), + self.rooted().as_ref(), + ), _ => None, }; shut_over.or_else(|| { @@ -931,15 +942,7 @@ impl Forest { fn beads_drawn(&self) -> Vec<(BeadKey, String)> { let mut drawn = Vec::new(); let mut listed = BTreeSet::new(); - let rooted = self.rooted(); - for tree in layout::trees_drawn(&self.snapshot) { - // The tree the forest is rooted at a bead of is walked from that - // bead, because the beads above it are drawn nowhere and a search - // that counted one would step onto it and find nothing there. - let way = rooted - .as_ref() - .filter(|rooted| rooted.place.tree == root_key(tree)) - .map_or(&[0][..], |rooted| &rooted.way); + for (tree, way) in layout::walked(&self.snapshot, self.rooted().as_ref()) { let (at, above) = way.split_last().expect("a way down ends somewhere"); step_down( tree, @@ -973,13 +976,46 @@ impl Forest { /// its children in an order their sort does not give. `way_to` is handed /// the tree's facts for that reason, and it is the same order the search /// enumerates in, so the copy a count named is the copy landed on. + /// + /// What the forest is rooted at is asked first, because a way down the + /// tree answers with can be a row the mode draws nowhere. fn place_of(&self, key: &BeadKey) -> Option { - self.snapshot - .trees - .iter() - .chain(&self.snapshot.collected) - .filter(|tree| tree.project == key.project) - .find_map(|tree| way_to(tree, self.facts.tree(&root_key(tree)), &key.id)) + self.drawn_at_the_root(key).or_else(|| { + self.snapshot + .trees + .iter() + .chain(&self.snapshot.collected) + .filter(|tree| tree.project == key.project) + .find_map(|tree| way_to(tree, self.facts.tree(&root_key(tree)), &key.id)) + }) + } + + /// Where the forest draws a bead at the bead it is rooted at or beneath + /// it, which is where a reader comes to it first: those rows are drawn + /// above every root the mode is holding back. + /// + /// A bead its own tree reaches twice is drawn at the copy the reader + /// pressed the key on, and every copy of it is left out of the root behind + /// the line — so the first way down to that bead, and to everything only + /// it reaches, is a way down to a row nothing draws. + fn drawn_at_the_root(&self, key: &BeadKey) -> Option { + let focused = self.focused.as_ref()?; + if focused.steps.last().unwrap_or(&focused.tree) == key { + return Some(focused.clone()); + } + if focused.tree.project != key.project { + return None; + } + let (tree, way) = self.locate(focused)?; + let (at, above) = way.split_last()?; + stepped_to( + tree, + self.facts.tree(&root_key(tree)), + &key.id, + *at, + above, + focused, + ) } /// Open everything shut over a line: everything the line hangs under, and @@ -1316,6 +1352,25 @@ mod tests { /// A second root, nobody working in it either, holding a bead that waits /// on a row the tracker never returned. Whichever project files it, the /// tree is hidden with a finding still in it. + /// The harbour and the slipway as one tree, which is what the tracker + /// answers once somebody files the second root as a child of the first. + /// Two roots become one, and the bead a reader was finishing is where it + /// always was in their head and somewhere else in the answer. + const SLIPWAY_UNDER_HARBOUR: &str = r#"[ + {"id":"hbr-3","title":"dredge the channel","status":"open", + "priority":2,"issue_type":"epic"}, + {"id":"hbr-3.1","title":"survey the silt","status":"open", + "dependencies":[{"depends_on_id":"hbr-3","type":"parent-child"}], + "priority":2,"issue_type":"task"}, + {"id":"hbr-9","title":"re-deck the slipway","status":"open", + "dependencies":[{"depends_on_id":"hbr-3","type":"parent-child"}], + "priority":2,"issue_type":"epic"}, + {"id":"hbr-9.1","title":"strip the planking","status":"open", + "dependencies":[{"depends_on_id":"hbr-9","type":"parent-child"}, + {"depends_on_id":"hbr-4","type":"blocks"}], + "priority":2,"issue_type":"task"} + ]"#; + const SLIPWAY: &str = r#"[ {"id":"hbr-9","title":"re-deck the slipway","status":"open", "priority":2,"issue_type":"epic"}, @@ -7033,9 +7088,8 @@ credential_command = "secret harbour" } /// Every other root is one line away rather than gone, under the project - /// it belongs to. Orbital has no other root, so it has no such line: the - /// beads the mode stopped drawing there are above the focused bead rather - /// than held back from it. + /// it belongs to. Orbital's own root is behind that line too, for the part + /// of it the mode stopped drawing: the beads above the focused bead. #[test] fn the_roots_the_mode_stops_drawing_go_behind_one_line_per_project() { let mut forest = flatten(snapshot()); @@ -7050,6 +7104,7 @@ credential_command = "secret harbour" "▾ orbital", " ├─▸ ○ .1 re-point the dish", " │ └── ! Dangling(1)", + " ├─▸ [HeldBack orbital] 1", " └── [Unattributed orbital] 2", "▾ ferry", " ├─▸ [HeldBack ferry] 1", @@ -7076,45 +7131,60 @@ credential_command = "secret harbour" ] { let mut forest = flatten(snapshot()); focus_on(&mut forest, "orb-7.1"); - let at = forest - .lines() - .iter() - .position(|line| { - matches!(&line.content, Content::Group(group) - if group.kind == GroupKind::HeldBack - && group.project.as_deref() == Some(project)) - }) - .unwrap_or_else(|| panic!("no line holds {project} back: {:#?}", sketch(&forest))); - step_onto(&mut forest, at); - assert!(forest.apply(Action::ExpandOrChild)); + open_the_line_holding_roots_back(&mut forest, project); assert!(drawn_here(&forest, root), "{:#?}", sketch(&forest)); } } - /// The line stands over open work with seats and anomalies on it, which is - /// the whole reason the reader pressed the key, so it counts them rather - /// than saying a number of roots and leaving them unsaid. - #[test] - fn the_line_over_the_held_back_roots_counts_the_seats_and_anomalies_in_them() { - let mut forest = flatten(snapshot()); - select_hidden_tree(&mut forest); - assert!(forest.apply(Action::FocusForest)); + /// Which row one project's held-back roots are behind. + fn the_line_holding_roots_back(forest: &Forest, project: &str) -> usize { + forest + .lines() + .iter() + .position(|line| { + matches!(&line.content, Content::Group(group) + if group.kind == GroupKind::HeldBack + && group.project.as_deref() == Some(project)) + }) + .unwrap_or_else(|| panic!("no line holds {project} back: {:#?}", sketch(forest))) + } - let held = forest + /// Step onto that line and open it, with the keys a reader has. + fn open_the_line_holding_roots_back(forest: &mut Forest, project: &str) { + let at = the_line_holding_roots_back(forest, project); + step_onto(forest, at); + assert!(forest.apply(Action::ExpandOrChild)); + } + + /// What the line holding one project's roots back says it stands over. + fn held_by_the_line(forest: &Forest, project: &str) -> Counts { + forest .lines() .iter() .find_map(|line| match &line.content { Content::Group(group) if group.kind == GroupKind::HeldBack - && group.project.as_deref() == Some("orbital") => + && group.project.as_deref() == Some(project) => { group.held.clone() } _ => None, }) - .unwrap_or_else(|| panic!("no counts on orbital's line: {:#?}", sketch(&forest))); + .unwrap_or_else(|| panic!("no counts on {project}'s line: {:#?}", sketch(forest))) + } + + /// The line stands over open work with seats and anomalies on it, which is + /// the whole reason the reader pressed the key, so it counts them rather + /// than saying a number of roots and leaving them unsaid. + #[test] + fn the_line_over_the_held_back_roots_counts_the_seats_and_anomalies_in_them() { + let mut forest = flatten(snapshot()); + select_hidden_tree(&mut forest); + assert!(forest.apply(Action::FocusForest)); + + let held = held_by_the_line(&forest, "orbital"); assert_eq!(held, counts_of(&forest, "orbital", "orb-7")); assert!(held.live_agents > 0, "orbital is where the agents are"); @@ -7423,7 +7493,334 @@ credential_command = "secret harbour" " │ ├── ! Dangling(1)", " │ ├── ○ .1.1 true the mount", " │ └── ○ .1.2 seal the feed horn", + " ├─▸ [HeldBack orbital] 1", + ] + ); + } + + /// The mode stops drawing the beads above the focused one, and *degrade, + /// never disappear* binds over them as it does over a whole root: they go + /// behind the same line, under the project they are in, and the root they + /// hang from rests shut there as a held-back root does. + #[test] + fn the_beads_above_the_focused_bead_go_behind_the_line_as_a_root_does() { + let mut forest = flatten(snapshot()); + focus_on(&mut forest, "orb-7.1"); + + open_the_line_holding_roots_back(&mut forest, "orbital"); + + assert_eq!( + sketch(&forest) + .into_iter() + .take_while(|row| !row.contains("Unattributed")) + .collect::>(), + vec![ + "▾ orbital", + " ├─▸ ○ .1 re-point the dish", + " │ └── ! Dangling(1)", + " ├── [HeldBack orbital] 1", + " │ └─▸ ◐ orb-7 lift the ground station", + " │ └── ! Dangling(1)", ] ); } + + /// The focused bead is drawn where a root is drawn, so the line the rest + /// of its root is behind leaves it there rather than drawing it twice. + #[test] + fn the_line_leaves_the_focused_bead_to_the_root_of_the_forest() { + let mut forest = flatten(snapshot()); + focus_on(&mut forest, "orb-7.1"); + + open_the_line_holding_roots_back(&mut forest, "orbital"); + + assert_eq!( + sketch(&forest) + .iter() + .filter(|row| row.contains("re-point the dish")) + .count(), + 1, + "{:#?}", + sketch(&forest) + ); + } + + /// A seat on a bead above the focused one is a seat nothing else on the + /// screen says is there, so the line standing over that bead counts it. + #[test] + fn the_line_counts_the_seats_above_the_focused_bead() { + let mut forest = flatten(snapshot()); + focus_on(&mut forest, "orb-7.1"); + + let held = held_by_the_line(&forest, "orbital"); + + assert_eq!( + held.live_agents, + counts_of(&forest, "orbital", "orb-7").live_agents, + "every seat in orbital is on a bead the mode stopped drawing" + ); + assert!(held.live_agents > 0, "orbital is where the agents are"); + } + + /// It counts what it is standing over rather than the whole root the beads + /// came from. The focused bead and what hangs beneath it are on the screen, + /// and a line counting those sends a reader looking for rows they are + /// already reading. + #[test] + fn the_line_counts_only_the_beads_the_mode_stopped_drawing() { + let mut forest = flatten(snapshot()); + focus_on(&mut forest, "orb-7.1"); + + let held = held_by_the_line(&forest, "orbital"); + + assert_eq!( + held.total, + counts_of(&forest, "orbital", "orb-7").total - 3, + "orb-7.1 and the two beads beneath it are drawn at the root" + ); + } + + /// Going to a bead opens what is shut over it, and what is shut over a + /// bead above the focused one is the line the rest of its root is behind. + #[test] + fn going_to_a_bead_above_the_focused_one_opens_the_line_it_is_behind() { + let mut forest = flatten(snapshot()); + focus_on(&mut forest, "orb-7.1"); + + assert!( + forest.go_to(&key("orbital", "orb-7")), + "cannot reach the bead above: {:#?}", + sketch(&forest) + ); + assert_eq!(cursor(&forest), Some(&key("orbital", "orb-7"))); + } + + /// A run says what opening it would draw. Where the bead the forest is + /// rooted at came out of one, the run behind the line stands for the + /// siblings it left there rather than counting a bead drawn at the root of + /// the forest. + #[test] + fn a_run_behind_the_line_leaves_the_focused_bead_out_of_its_count() { + let mut forest = flatten(snapshot()); + assert!(forest.go_to(&key("orbital", "orb-7.2")), "no such bead"); + assert!(forest.apply(Action::FocusForest)); + + assert!( + forest.go_to(&key("orbital", "orb-7.3")), + "cannot reach a bead in the run: {:#?}", + sketch(&forest) + ); + + assert!( + drawn_here(&forest, "… 2 more"), + "the run stands for the siblings left in it: {:#?}", + sketch(&forest) + ); + } + + /// And a run with the focused bead somewhere beneath one of its members + /// stands for one bead fewer for the same reason, rather than for what the + /// whole tree would have put behind it. + #[test] + fn a_run_behind_the_line_leaves_out_a_focused_bead_beneath_a_member() { + let mut forest = flatten(depot()); + assert!(forest.go_to(&key("orbital", "dep-1.2.1")), "no such bead"); + assert!(forest.apply(Action::FocusForest)); + + assert!( + forest.go_to(&key("orbital", "dep-1.3")), + "cannot reach the run: {:#?}", + sketch(&forest) + ); + + assert!(drawn_here(&forest, "… 5 more"), "{:#?}", sketch(&forest)); + } + + /// A bead reachable more than once is drawn once for every way down to it, + /// and the key roots the forest at the copy the reader pressed it on. The + /// mode draws none of the others, so going to that bead goes to the copy + /// it is drawn at rather than to the first way down to it. + #[test] + fn going_to_the_focused_bead_reaches_the_copy_it_is_rooted_at() { + let mut forest = flatten(drawn_twice_in_one_tree()); + let [_, lower] = copies_of(&forest, "orb-9"); + step_onto(&mut forest, lower); + assert!(forest.apply(Action::FocusForest)); + + assert!( + forest.go_to(&key("orbital", "orb-9")), + "cannot reach the bead the forest is rooted at: {:#?}", + sketch(&forest) + ); + } + + /// And everything only that copy reaches. The first way down to one of + /// those beads goes through the copy the mode draws nowhere, so a search + /// answering with it would count a bead it could not land on. + #[test] + fn going_to_a_bead_under_the_focused_copy_reaches_it() { + let mut forest = flatten(drawn_twice_in_one_tree()); + let [_, lower] = copies_of(&forest, "orb-9"); + step_onto(&mut forest, lower); + assert!(forest.apply(Action::FocusForest)); + + assert!( + forest.go_to(&key("orbital", "orb-9.1")), + "cannot reach the bead beneath it: {:#?}", + sketch(&forest) + ); + assert_eq!(cursor(&forest), Some(&key("orbital", "orb-9.1"))); + } + + /// A root the tracker files under another root is still the bead the + /// reader was finishing. Its place stops being a root's, so the mode goes + /// looking for the bead, exactly as it does for a bead moved further down. + #[test] + fn a_collection_that_filed_the_focused_root_under_another_stays_rooted_at_it() { + let staffed = panes_on(&["hbr-9.1"]); + let mut forest = flatten(together("orbital", &[HARBOUR, SLIPWAY], &staffed)); + focus_on(&mut forest, "hbr-9"); + assert!( + !drawn_here(&forest, "dredge the channel"), + "rooted at one bead: {:#?}", + sketch(&forest) + ); + + forest.refresh(together("orbital", &[SLIPWAY_UNDER_HARBOUR], &staffed)); + + assert!( + drawn_here(&forest, "re-deck the slipway"), + "the bead is still where a root is drawn: {:#?}", + sketch(&forest) + ); + assert!( + drawn_here(&forest, "[HeldBack orbital]"), + "still rooted at one bead: {:#?}", + sketch(&forest) + ); + } + + /// A search counts in the order the rows are drawn, and the bead the + /// forest is rooted at is the first row on the screen however its tree + /// came in its project's own order. + #[test] + fn a_search_counts_from_the_bead_the_forest_is_rooted_at() { + let mut forest = flatten(together( + "orbital", + &[HARBOUR, SLIPWAY], + &panes_on(&["hbr-9.1"]), + )); + assert!(forest.go_to(&key("orbital", "hbr-3.1")), "no such bead"); + assert!(forest.apply(Action::FocusForest)); + + let landed = forest.seek("the"); + + let Landed::On { key: found, at, .. } = landed else { + panic!("nothing matched: {landed:?}") + }; + assert_eq!((found, at), (key("orbital", "hbr-3.1"), 1)); + } + + /// Stepping through matches from a row that is not a bead carries on from + /// where the reader is standing, and a shut line is asked what it holds + /// rather than read past. This line holds beads exactly as the filter's + /// does, so a reader standing on it steps into what is behind it. + #[test] + fn stepping_from_the_shut_line_carries_on_into_the_roots_behind_it() { + let mut forest = flatten(snapshot()); + focus_on(&mut forest, "orb-7.1"); + forest.seek("the"); + let at = the_line_holding_roots_back(&forest, "orbital"); + step_onto(&mut forest, at); + assert_eq!( + forest.lines()[at].folded, + Some(false), + "the line rests shut" + ); + + let landed = forest.next_match(true); + + let Some(Landed::On { key: found, .. }) = landed else { + panic!("nothing matched: {landed:?}") + }; + assert_eq!(found, key("orbital", "orb-7")); + } + + /// Open every fold on the screen, with the keys a reader has, until the + /// rows are the whole of what the forest holds. + /// + /// Counted out before it starts like any other walk here. Each press opens + /// one fold and draws what it was over, so a screen with a fold left shut + /// after a press per row it started with is one the walk says it never + /// got to the end of. + fn open_everything(forest: &mut Forest) { + walk::until( + forest, + |forest| shut_fold(forest).is_none(), + |forest| { + let at = shut_fold(forest).expect("a fold to open"); + forest.select_line(at); + forest.apply(Action::ExpandOrChild); + }, + |forest| format!("a fold would not open: {:#?}", sketch(forest)), + ); + } + + /// The first row on the screen resting shut over something. + fn shut_fold(forest: &Forest) -> Option { + forest + .lines() + .iter() + .position(|line| line.folded == Some(false)) + } + + /// The order a search counts in and the order the rows come out are the + /// same order, and both are the drawing's answer rather than two accounts + /// of it that agree because they were written to. Under the mode as + /// without it, because the mode is what moves the rows. + #[test] + fn a_search_enumerates_the_beads_in_the_order_the_rows_draw_them() { + for rooted in [None, Some("orb-7.1")] { + let mut forest = flatten(snapshot()); + if let Some(bead) = rooted { + focus_on(&mut forest, bead); + } + open_everything(&mut forest); + let mut met = BTreeSet::new(); + // A root whose tracker refused has a row and no bead on it, and a + // search offers beads. It is named on its line rather than left + // out, which is the one row here that stands for no bead. + let on_screen: Vec = forest + .lines() + .iter() + .filter(|line| matches!(line.content, Content::Bead(_))) + .filter_map(Line::bead) + .filter(|key| met.insert((*key).clone())) + .cloned() + .collect(); + + let counted: Vec = forest + .beads_drawn() + .into_iter() + .map(|(key, _)| key) + .collect(); + + assert_eq!(counted, on_screen, "rooted at {rooted:?}"); + } + } + + /// A search counts the matches it can take the reader to, and a line it + /// can open is a match it can take them to. + #[test] + fn a_search_counts_a_bead_above_the_focused_one() { + let mut forest = flatten(snapshot()); + focus_on(&mut forest, "orb-7.1"); + + let landed = forest.seek("lift the ground station"); + + let Landed::On { key: found, .. } = landed else { + panic!("nothing matched: {landed:?}") + }; + assert_eq!(found, key("orbital", "orb-7")); + } } diff --git a/src/view/phrase.rs b/src/view/phrase.rs index 607f1fc..dcd6b7c 100644 --- a/src/view/phrase.rs +++ b/src/view/phrase.rs @@ -472,12 +472,16 @@ pub fn conflicts(count: usize) -> String { /// The roots the forest is not drawing because the reader rooted it at one /// bead. /// -/// *Other* rather than hidden: a key put them behind this line and the same -/// key brings them back, so nothing here has gone wrong and nothing here is -/// the tracker's doing. +/// *Held back* rather than hidden: a key put them behind this line and the +/// same key brings them back, so nothing here has gone wrong and nothing here +/// is the tracker's doing. +/// +/// Not *other*, either. The root the focused bead stands in is behind this +/// line as well, for the part of it the mode stopped drawing, and every root +/// counted here has its own row nowhere but behind this line. pub fn held_back(count: usize) -> String { let root = if count == 1 { "root" } else { "roots" }; - format!("{count} other {root}") + format!("{count} {root} held back") } /// Trees the live-agent filter is holding back, and how many of those carry From 52198219e7e276aaa79f8181e0eb119bea32ce8b Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:26:35 +0100 Subject: [PATCH 5/6] fix(view): shorten a rooted forest's ids against the bead at its root The bead the forest is rooted at was drawn where a root is drawn and kept the id it has as a child, so the one bead the reader pressed the key for read as a suffix while every other root, its own parent among them, read whole. A column of shortened ids is read by putting the drawn root in front of each one, and the root this branch came out of is behind the line. So the drawing rooted at one bead shortens against that bead: it reads whole where a root would, and the beads under it read as its children. What is behind the line is a drawing of its own and shortens against its own root, so the root the focused branch came out of and the beads still in it read exactly as they do with the mode off. A bead with a way down on either side of the line has a row on either side, and each row reads against the root of the drawing it sits in: `abbreviate` cuts nothing off an id the drawn root is not a prefix of, so the two spellings differ and both put a reader on the one bead. The line's anchor looks past a root that holds no bead. A root whose tracker refused leads its project whatever the filter says and holds none, so a reader standing on the shut line and stepping to the next match walked past everything the line holds and came round to the top of the screen. --- docs/design.md | 5 +++- src/view/forest/layout.rs | 40 ++++++++++++++++--------- src/view/forest/mod.rs | 61 +++++++++++++++++++++++++++++++++++---- 3 files changed, 87 insertions(+), 19 deletions(-) diff --git a/docs/design.md b/docs/design.md index 8b59da0..431b39b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -967,7 +967,10 @@ them is reached by the keys that reach any bead, the line opening to let the selection in, so looking a bead up in another root is not paid for with the put-back key. A search offers what it can take the reader to, which under this mode is every bead the forest holds: the ones beneath the focused bead, and the -ones behind the line, which it opens on the way. It names the key +ones behind the line, which it opens on the way. The bead the forest is rooted +at reads its whole id where a root would, and the beads under it read as its +children: a column of shortened ids is read by putting the drawn root in front +of each one, and the root this branch came out of is behind the line. It names the key that brings them back, because it is the line a reader is looking at when they want them back, and a row with no room for that hint gives it up whole rather than cutting it to a key nobody can press. diff --git a/src/view/forest/layout.rs b/src/view/forest/layout.rs index e485904..9e46114 100644 --- a/src/view/forest/layout.rs +++ b/src/view/forest/layout.rs @@ -157,19 +157,21 @@ pub(super) fn first_bead_of( project: Option<&str>, rooted: Option<&Rooted>, ) -> Option { - let tree = match kind { - GroupKind::HiddenTrees => hidden_trees(snapshot, project).into_iter().next()?, - GroupKind::HeldBack => { - held_back(snapshot, project, rooted) - .into_iter() - .next()? - .tree - } + let roots = match kind { + GroupKind::HiddenTrees => hidden_trees(snapshot, project), + GroupKind::HeldBack => held_back(snapshot, project, rooted) + .into_iter() + .map(|root| root.tree) + .collect(), _ => return None, }; - Some(BeadKey { - project: tree.project.clone(), - id: tree.beads.first()?.id.clone(), + // A root whose tracker refused has a row and no bead, and it leads its + // project, so the first root here is the one most likely to hold nothing. + roots.into_iter().find_map(|tree| { + Some(BeadKey { + project: tree.project.clone(), + id: tree.beads.first()?.id.clone(), + }) }) } @@ -680,7 +682,7 @@ impl TreeLayout<'_> { place: Some(root.clone()), content: Content::Bead(row::cells( node, - &self.tree.root, + self.shortened_against(), bead.progress, shut_over(bead.beneath, first_copy(self.tree, at, above), folded), )), @@ -767,7 +769,7 @@ impl TreeLayout<'_> { place: Some(place.clone()), content: Content::Bead(row::cells( node, - &self.tree.root, + self.shortened_against(), bead.progress, shut_over(bead.beneath, first, folded), )), @@ -803,6 +805,18 @@ impl TreeLayout<'_> { Some(link.bead) != self.without } + /// The id every row here is shortened against: the bead this drawing + /// starts at. A reader reads a column of suffixes by putting the drawn + /// root in front of each one, so the bead the forest is rooted at reads + /// whole and what hangs under it reads against that. The root it came out + /// of is behind the line, shortening what is drawn there. + fn shortened_against(&self) -> &str { + match self.rooted { + Some(rooted) => &rooted.place.steps.last().unwrap_or(&rooted.place.tree).id, + None => &self.tree.root, + } + } + /// What a run stands for: its members and everything beneath them. Walked /// again where a bead is being left out, because the tree's own answer was /// worked out over a run this drawing is not making and reaches beads it diff --git a/src/view/forest/mod.rs b/src/view/forest/mod.rs index 72a66e8..6ea2bec 100644 --- a/src/view/forest/mod.rs +++ b/src/view/forest/mod.rs @@ -7102,7 +7102,7 @@ credential_command = "secret harbour" .collect::>(), vec![ "▾ orbital", - " ├─▸ ○ .1 re-point the dish", + " ├─▸ ○ orb-7.1 re-point the dish", " │ └── ! Dangling(1)", " ├─▸ [HeldBack orbital] 1", " └── [Unattributed orbital] 2", @@ -7476,6 +7476,11 @@ credential_command = "secret harbour" /// A bead deep in a tree is drawn where a root is drawn, and what hangs /// under it is what hangs under it anywhere else. + /// + /// It reads whole the way a root does, and the beads under it read against + /// it. A column of ids is read by putting the drawn root in front of each + /// one, so a suffix cut against a root that is behind the line names a + /// bead that is not there. #[test] fn focusing_a_bead_under_a_root_draws_it_where_that_root_was() { let mut forest = flatten(snapshot()); @@ -7489,10 +7494,10 @@ credential_command = "secret harbour" .collect::>(), vec![ "▾ orbital", - " ├── ○ .1 re-point the dish", + " ├── ○ orb-7.1 re-point the dish", " │ ├── ! Dangling(1)", - " │ ├── ○ .1.1 true the mount", - " │ └── ○ .1.2 seal the feed horn", + " │ ├── ○ .1 true the mount", + " │ └── ○ .2 seal the feed horn", " ├─▸ [HeldBack orbital] 1", ] ); @@ -7516,7 +7521,7 @@ credential_command = "secret harbour" .collect::>(), vec![ "▾ orbital", - " ├─▸ ○ .1 re-point the dish", + " ├─▸ ○ orb-7.1 re-point the dish", " │ └── ! Dangling(1)", " ├── [HeldBack orbital] 1", " │ └─▸ ◐ orb-7 lift the ground station", @@ -7525,6 +7530,24 @@ credential_command = "secret harbour" ); } + /// What is behind the line reads against the root it is drawn under, as it + /// does with the mode off. Only the drawing rooted at one bead shortens + /// against that bead, and the roots behind the line are not that drawing. + #[test] + fn a_bead_behind_the_line_reads_against_the_root_it_hangs_under() { + let mut forest = flatten(snapshot()); + focus_on(&mut forest, "orb-7.1"); + open_the_line_holding_roots_back(&mut forest, "orbital"); + + toggle_fold_of(&mut forest, "orb-7"); + + assert!( + drawn_here(&forest, "○ .7 log the survey marks"), + "{:#?}", + sketch(&forest) + ); + } + /// The focused bead is drawn where a root is drawn, so the line the rest /// of its root is behind leaves it there rather than drawing it twice. #[test] @@ -7746,6 +7769,34 @@ credential_command = "secret harbour" assert_eq!(found, key("orbital", "orb-7")); } + /// A root whose tracker refused leads its project whatever the filter + /// says, and it holds no bead. The line has to look past it for the bead + /// it stands on, or a reader stepping off it walks past everything it + /// holds and wraps round to the top of the screen. + #[test] + fn stepping_from_the_shut_line_reaches_past_a_root_holding_no_bead() { + let mut forest = flatten(gather( + vec![ + tree_of("orbital", ORBITAL), + Tree::tracker_unreachable("orbital", "orb-0", TrackerFailure::Auth), + tree_of("harbour", HARBOUR), + ], + Vec::new(), + Filter::LiveAgents, + )); + focus_on(&mut forest, "orb-7.1"); + forest.seek("the"); + let at = the_line_holding_roots_back(&forest, "orbital"); + step_onto(&mut forest, at); + + let landed = forest.next_match(true); + + let Some(Landed::On { key: found, .. }) = landed else { + panic!("nothing matched: {landed:?}") + }; + assert_eq!(found, key("orbital", "orb-7")); + } + /// Open every fold on the screen, with the keys a reader has, until the /// rows are the whole of what the forest holds. /// From 7726e63404b3c2afcad46672343c90ee7a0f438c Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:38:49 +0100 Subject: [PATCH 6/6] fix(view): end the mode when the focused root stops reading A root whose tracker refuses on a later read keeps a row saying it would not read, and the mode was following the row rather than the bead. So it stayed on with nothing to root the forest at: every root came back on the screen while the mode was still set, and the next press was spent putting back a forest that was already back rather than rooting it at the bead the reader was on. A bead the collection no longer holds ends the mode, and a root that will not read is such a bead however much of its header is still drawn. --- src/view/forest/mod.rs | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/view/forest/mod.rs b/src/view/forest/mod.rs index 6ea2bec..2fb1945 100644 --- a/src/view/forest/mod.rs +++ b/src/view/forest/mod.rs @@ -508,8 +508,12 @@ impl Forest { /// A place with no steps stands on the root of its tree, and a root filed /// under another root has moved as much as any other bead: it is looked /// for by the same name, which for that place is the tree's own. + /// + /// Asked for the bead rather than for the row: a root whose tracker has + /// since refused keeps a row saying so, and there is no tree to draw from + /// a bead that is not there. fn rerooted(&self, place: &Place) -> Option { - if self.drawn(place) { + if self.locate(place).is_some() { return Some(place.clone()); } self.place_of(place.steps.last().unwrap_or(&place.tree)) @@ -7310,6 +7314,38 @@ credential_command = "secret harbour" ); } + /// A tracker that stops answering for the focused root has taken that bead + /// out of the collection as surely as one that dropped it, so the mode + /// ends. The root keeps a row saying it would not read, and a mode reading + /// that row as the bead still being there would spend the next press + /// putting back a forest that is already back. + #[test] + fn the_key_roots_the_forest_afresh_once_the_focused_root_stopped_reading() { + let mut forest = flatten(built(Filter::All)); + focus_on(&mut forest, "orb-7"); + forest.refresh(gather( + vec![ + Tree::tracker_unreachable("orbital", "orb-7", TrackerFailure::Auth), + tree_of("harbour", HARBOUR), + ], + Vec::new(), + Filter::All, + )); + assert!( + drawn_here(&forest, "hbr-3 dredge the channel"), + "every root is back: {:#?}", + sketch(&forest) + ); + + focus_on(&mut forest, "hbr-3"); + + assert!( + !drawn_here(&forest, "⚠ orb-7 unread"), + "rooted at the bead just asked for: {:#?}", + sketch(&forest) + ); + } + /// Going to a bead opens what is shut over it, and under this mode what is /// shut over every other root is the line the mode put them behind. A /// search that found a bead it cannot reach is a search that failed.