diff --git a/docs/design.md b/docs/design.md index 4d25e5d..431b39b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -934,6 +934,47 @@ 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, 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. 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: 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. 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, 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. 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. + **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 @@ -2287,10 +2328,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 @@ -2364,6 +2405,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/draw/groups.rs b/src/view/draw/groups.rs index 02e10c2..eedcd0c 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 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:?}"); + } + + /// 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 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:?}"); + 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 root held back"), "{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 fa2de76..9e46114 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,25 +44,52 @@ 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) } /// Every line the snapshot draws, in render order. `facts` is what the /// snapshot answered when the forest took it. -pub(super) fn draw(snapshot: &Snapshot, facts: &Facts, folds: &Folds) -> 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 { - 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 @@ -117,33 +145,44 @@ 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(), - }) - } - _ => None, - } + 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, + }; + // 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(), + }) + }) } -/// 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 @@ -157,26 +196,53 @@ 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 } -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 @@ -185,18 +251,119 @@ 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 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(Behind::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, }) } +/// 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 +/// line that adds up. +fn held_back<'a>( + snapshot: &'a Snapshot, + project: Option<&str>, + rooted: Option<&Rooted>, +) -> 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_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() +} + /// 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( @@ -234,9 +401,12 @@ 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<'_> { +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 @@ -297,16 +467,31 @@ 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() - .filter_map(|kind| group_of(self.snapshot, kind, Some(&project))) + // 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), self.rooted)) .collect(); let mut entries = trees.len() + groups.len(); let mut trunk = Vec::new(); @@ -317,6 +502,8 @@ impl Layout<'_> { tree, facts: self.facts.tree(&root_key(tree)), rests_shut: false, + rooted: self.rooted, + without: None, } .draw(&mut trunk, entries == 0, lines); } @@ -361,15 +548,17 @@ 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, 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); } @@ -385,10 +574,19 @@ 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 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) + .into_iter() + .map(|tree| Behind { + tree, + without: None, + }) + .collect(), + } } fn draw_items(&self, items: Vec, trunk: &[bool], lines: &mut Vec) { @@ -408,7 +606,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); @@ -425,15 +623,31 @@ 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>, + /// 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<'_> { /// `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 +666,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()), @@ -468,9 +682,9 @@ 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, 0, &[]), folded), + shut_over(bead.beneath, first_copy(self.tree, at, above), folded), )), }); @@ -479,7 +693,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(); } @@ -514,7 +728,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(), }, }); @@ -555,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), )), @@ -575,12 +789,52 @@ 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 + } + + /// 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 + /// 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<'_> { @@ -617,8 +871,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 5f8aae3..2fb1945 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(); @@ -248,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(); @@ -339,7 +355,15 @@ impl Forest { _ => return chain, }; chain.extend(place.forebears().map(Handle::Bead)); - if self.hidden(&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()), + )); + } else if self.hidden(&place.tree) { chain.push(Handle::Group( GroupKind::HiddenTrees, Some(place.tree.project.clone()), @@ -349,6 +373,18 @@ impl Forest { chain } + /// 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. fn hidden(&self, root: &BeadKey) -> bool { self.snapshot @@ -406,6 +442,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 +460,80 @@ 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 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 { + return; + }; + if self.locate(&place).is_some() { + self.focused = Some(place); + } + } + + /// 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. + /// + /// 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.locate(place).is_some() { + return Some(place.clone()); + } + self.place_of(place.steps.last().unwrap_or(&place.tree)) + } + + /// 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 +640,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)) @@ -772,9 +884,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(|| { @@ -831,12 +946,13 @@ impl Forest { fn beads_drawn(&self) -> Vec<(BeadKey, String)> { let mut drawn = Vec::new(); let mut listed = BTreeSet::new(); - for tree in layout::trees_drawn(&self.snapshot) { + 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, self.facts.tree(&root_key(tree)), - 0, - &[], + *at, + above, &mut listed, &mut drawn, ); @@ -864,13 +980,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 @@ -960,7 +1109,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 @@ -1025,8 +1175,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)) } @@ -1034,9 +1187,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), } @@ -1132,6 +1288,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, @@ -1199,6 +1356,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"}, @@ -5091,7 +5267,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:#?}"); @@ -5170,7 +5348,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 => {} }, _ => {} } @@ -6862,4 +7043,871 @@ 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", + " ├─▸ [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'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()); + focus_on(&mut forest, "orb-7.1"); + + assert_eq!( + sketch(&forest) + .into_iter() + .filter(|row| !row.contains("── - ")) + .collect::>(), + vec![ + "▾ orbital", + " ├─▸ ○ orb-7.1 re-point the dish", + " │ └── ! Dangling(1)", + " ├─▸ [HeldBack orbital] 1", + " └── [Unattributed orbital] 2", + "▾ ferry", + " ├─▸ [HeldBack ferry] 1", + " └── [Unattributed ferry] 1", + "▾ harbour", + " └─▸ [HeldBack harbour] 1", + "▸ [FailedProjects] 1", + "▾ [Unconfigured] 1", + "▾ [Conflicts] 1", + ] + ); + } + + /// 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"); + + open_the_line_holding_roots_back(&mut forest, project); + + assert!(drawn_here(&forest, root), "{:#?}", sketch(&forest)); + } + } + + /// 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))) + } + + /// 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(project) => + { + group.held.clone() + } + _ => None, + }) + .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"); + } + + /// 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. + #[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) + ); + } + + /// 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) + ); + } + + /// 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. + #[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. + #[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. + /// + /// 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()); + 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", + " ├── ○ orb-7.1 re-point the dish", + " │ ├── ! Dangling(1)", + " │ ├── ○ .1 true the mount", + " │ └── ○ .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", + " ├─▸ ○ orb-7.1 re-point the dish", + " │ └── ! Dangling(1)", + " ├── [HeldBack orbital] 1", + " │ └─▸ ◐ orb-7 lift the ground station", + " │ └── ! Dangling(1)", + ] + ); + } + + /// 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] + 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")); + } + + /// 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. + /// + /// 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/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/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. diff --git a/src/view/phrase.rs b/src/view/phrase.rs index ea0bfc0..6e068d1 100644 --- a/src/view/phrase.rs +++ b/src/view/phrase.rs @@ -469,6 +469,21 @@ 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. +/// +/// *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} {root} held back") +} + /// Trees the live-agent filter is holding back, and how many of those carry /// findings that are therefore not on screen. ///