From aa604ef8eb9d345d606b1b12fba4b0cad52b6bee Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:09:38 +0100 Subject: [PATCH 1/2] fix(view): shorten a bead id against the node above it An id was measured against the tree's root, so a branch whose own id was nothing like the root's drew in full and so did every bead beneath it, including the ones the branch's id is the front of. A reader met a column of whole ids on exactly the pairs they could most easily have read as one thing. The rule is now that a bead omits the part of its id the bead on the row above already spells, and draws whole where that bead's id is not the front of its own. It holds at every depth and in every tree, so walking up the rows and joining what each says gives the whole id back. That takes the focused-root case with it. A rooted forest shortened every row against the bead it started at, which is this rule written for one depth only; the bead a rooted forest starts at is now a row with nothing above it, and draws whole for the same reason a tree's root does. A copy drawn under a bead it does not descend from now reads whole where it used to read as that bead's child. The sluice fixture draws both copies of one bead, and the second says `slu-1.1` rather than `.1`. --- docs/design.md | 22 +++++------ src/view/draw/mod.rs | 2 +- src/view/forest/layout.rs | 16 +------- src/view/forest/mod.rs | 73 ++++++++++++++++++++++++++-------- src/view/row.rs | 82 +++++++++++++++++++++++---------------- 5 files changed, 119 insertions(+), 76 deletions(-) diff --git a/docs/design.md b/docs/design.md index 431b39b..c8b9f21 100644 --- a/docs/design.md +++ b/docs/design.md @@ -967,10 +967,7 @@ 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 +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. @@ -1985,13 +1982,16 @@ glyph, literal because `bd`'s are and do not move with the terminal's theme escape for it and inheriting is what lets a row's own brightness reach its glyph. -**An id is shown as what it adds to its root's**, `.20` for `smt-4kd3p.20`, -and kept whole where it does not carry that prefix followed by a dot — the -dangling and re-parented nodes, and the root itself — because a bare suffix -would place it under a root it does not belong to. **It is drawn in the colour -of the glyph beside it**, so a status arrives as a block a reader finds rather -than as the one column a glyph occupies; an open bead's id goes without a -colour for the same reason its glyph does. +**An id is shown as what it adds to the id above it**, `.20` for +`smt-4kd3p.20` drawn under `smt-4kd3p`, and kept whole where the row above it +does not carry its id followed by a dot — the dangling and re-parented nodes, +and a row with nothing above it — because a bare suffix would place it under a +parent it does not belong to. The rule holds at every depth, so walking up the +rows and joining what each says gives the whole id back, which is what makes a +column of them readable. **It is drawn in the colour of the glyph beside it**, +so a status arrives as a block a reader finds rather than as the one column a +glyph occupies; an open bead's id goes without a colour for the same reason its +glyph does. **A ground and two tiers say how live a row is**, which is the one thing about a bead `bd list` has no way to know and so the one thing this scale is diff --git a/src/view/draw/mod.rs b/src/view/draw/mod.rs index a6b0e6a..86a51a3 100644 --- a/src/view/draw/mod.rs +++ b/src/view/draw/mod.rs @@ -373,7 +373,7 @@ mod tests { } pub(super) fn row(node: &Node) -> Row { - row::cells(node, "smt-4kd3p", None, None) + row::cells(node, Some("smt-4kd3p"), None, None) } /// A project whose roots all read, so its line is its name and its counts. diff --git a/src/view/forest/layout.rs b/src/view/forest/layout.rs index 9e46114..dd40fd7 100644 --- a/src/view/forest/layout.rs +++ b/src/view/forest/layout.rs @@ -682,7 +682,7 @@ impl TreeLayout<'_> { place: Some(root.clone()), content: Content::Bead(row::cells( node, - self.shortened_against(), + None, bead.progress, shut_over(bead.beneath, first_copy(self.tree, at, above), folded), )), @@ -769,7 +769,7 @@ impl TreeLayout<'_> { place: Some(place.clone()), content: Content::Bead(row::cells( node, - self.shortened_against(), + Some(&parent.key().id), bead.progress, shut_over(bead.beneath, first, folded), )), @@ -805,18 +805,6 @@ 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 2fb1945..303c04d 100644 --- a/src/view/forest/mod.rs +++ b/src/view/forest/mod.rs @@ -2581,8 +2581,8 @@ credential_command = "secret harbour" "▾ orbital", " └── ○ tow-1 raise the tower", " ├── ○ .1 stand the mast", - " │ └── ○ .1.1 bolt the sections", - " │ └── ○ .1.1.1 dress the cables", + " │ └── ○ .1 bolt the sections", + " │ └── ○ .1 dress the cables", " └─▸ ○ .2 pour the base", ]; @@ -2625,8 +2625,8 @@ credential_command = "secret harbour" "▾ orbital", " └── ○ tow-1 raise the tower", " ├── ○ .1 stand the mast", - " │ └── ○ .1.1 bolt the sections", - " │ └── ◐ .1.1.1 dress the cables", + " │ └── ○ .1 bolt the sections", + " │ └── ◐ .1 dress the cables", " └─▸ ○ .2 pour the base", ] ); @@ -2696,7 +2696,7 @@ credential_command = "secret harbour" assert!( sketch(&forest) .iter() - .any(|line| line.contains(".1.1.1 dress the cables")), + .any(|line| line.contains(".1 dress the cables")), "{:#?}", sketch(&forest) ); @@ -3204,10 +3204,10 @@ credential_command = "secret harbour" " └── ◐ rly-2 re-site the relay", " ├── ○ .1 trench the run", " ├── ✓ .2 strike the old mast", - " │ └── ✓ .2.1 drop the guys", - " │ └── ◐ .2.1.1 cut the stays", + " │ └── ✓ .1 drop the guys", + " │ └── ◐ .1 cut the stays", " ├── ✓ .4 lift the feeder", - " │ └── ✓ .4.1 coil the heliax", + " │ └── ✓ .1 coil the heliax", " └─▸ … 4 more", ] ); @@ -3608,9 +3608,9 @@ credential_command = "secret harbour" " └── ◐ sdg-4 re-point the crossover", " ├─▸ ◐ .3 re-signal the box", " ├── ✓ .1 slew the up line", - " │ ├── ○ .1.2 weld the closure rail", - " │ ├─▸ ✓ .1.1 key the switch", - " │ └── ✓ .1.3 lift the old chairs", + " │ ├── ○ .2 weld the closure rail", + " │ ├─▸ ✓ .1 key the switch", + " │ └── ✓ .3 lift the old chairs", " └─▸ ✓ .2 clip the down line", ] ); @@ -5447,7 +5447,7 @@ credential_command = "secret harbour" " └── ◐ slu-1 rehang the sluice", " ├─▸ ◐ .1 forge the new pintles", " └── ○ .2 hang the gate", - " └┄▸ ◐ .1 forge the new pintles", + " └┄▸ ◐ slu-1.1 forge the new pintles", ] ); @@ -5459,14 +5459,53 @@ credential_command = "secret harbour" "▾ orbital", " └── ◐ slu-1 rehang the sluice", " ├── ◐ .1 forge the new pintles", - " │ └── ○ .1.1 cast the pintle blanks", + " │ └── ○ .1 cast the pintle blanks", " └── ○ .2 hang the gate", - " └┄┄ ◐ .1 forge the new pintles", - " └── ○ .1.1 cast the pintle blanks", + " └┄┄ ◐ slu-1.1 forge the new pintles", + " └── ○ .1 cast the pintle blanks", ] ); } + /// A column of short ids is only readable if a reader can rebuild the + /// whole one from it: they put what the row above says in front of what + /// this row says, and stop at a row that reads whole because there is + /// nothing left to put in front of it. Over every bead these fixtures + /// draw, that walk gives back the id `bd` holds it under. + #[test] + fn walking_up_the_rows_and_joining_the_ids_gives_a_beads_whole_id_back() { + let fixtures = [ + flatten(snapshot()), + flatten(tower_staffed(&["tow-1.1.1.1"])), + flatten(alone("orbital", SLUICE, &panes_on(&["slu-1.1"]))), + flatten(alone("orbital", RELAY, &two_panes())), + flatten(alone("orbital", SIDING, &panes_on(&["sdg-4.3"]))), + ]; + + let mut shortened = 0; + for mut forest in fixtures { + forest.apply(Action::ExpandSubtree); + let mut above: Vec = Vec::new(); + for line in forest.lines() { + let (Some(place), Content::Bead(row)) = (&line.place, &line.content) else { + continue; + }; + above.truncate(place.steps.len()); + let rebuilt = match above.last() { + Some(parent) if row.id.starts_with('.') => format!("{parent}{}", row.id), + _ => row.id.clone(), + }; + + assert_eq!(rebuilt, place.key().id, "{:#?}", sketch(&forest)); + + shortened += usize::from(row.id.starts_with('.')); + above.push(rebuilt); + } + } + + assert!(shortened > 0, "no row was drawn short to walk up from"); + } + /// Four columns a level, every line in a forest with trees in it, /// whatever that line is doing. /// @@ -6557,12 +6596,12 @@ credential_command = "secret harbour" /// The forest row is the one place `bdi` ever prints a shortened id — /// `row::abbreviate` has no other caller — so on a long screen the short /// id is the only spelling the reader has been shown. A substring is what - /// reaches it: `.1.1` is a prefix of nothing. + /// reaches it: `.1` is a prefix of nothing. #[test] fn a_search_matches_the_shortened_id_the_row_draws() { let mut forest = flatten(snapshot()); - assert_eq!(forest.seek(".1.1"), went_to("orbital", "orb-7.1.1", 1, 1)); + assert_eq!(forest.seek(".1"), went_to("orbital", "orb-7.1", 1, 4)); } #[test] diff --git a/src/view/row.rs b/src/view/row.rs index 1cc6cd0..dcfd321 100644 --- a/src/view/row.rs +++ b/src/view/row.rs @@ -90,7 +90,7 @@ pub fn says_the_same_about_its_link(badge: &Badged, said: &str) -> bool { pub fn cells( node: &Node, - root: &str, + above: Option<&str>, progress: Option, shut_over: Option, ) -> Row { @@ -137,7 +137,7 @@ pub fn cells( Row { status: node.status.clone(), glyph: status_glyph(&node.status), - id: abbreviate(&node.id, root).to_string(), + id: abbreviate(&node.id, above).to_string(), title: node.title.clone(), badges: node.badges.clone(), progress, @@ -176,12 +176,17 @@ pub fn status_glyph(status: &Status) -> char { } } -/// A node's id with its root's prefix dropped, which is what makes a column of -/// ids readable. A node that does not carry the root's prefix keeps its whole -/// id: a bare suffix would place it under a root it does not belong to, and -/// the dangling and re-parented nodes are exactly the ones that would lie. -pub fn abbreviate<'a>(id: &'a str, root: &str) -> &'a str { - id.strip_prefix(root) +/// A node's id with the part the id above it already spells dropped, which is +/// what makes a column of ids readable: walking up the rows and joining what +/// they say gives the whole id back. A node whose parent's id is not the front +/// of its own keeps its whole id, because a bare suffix would place it under a +/// parent it does not belong to, and the dangling and re-parented nodes are +/// exactly the ones that would lie. A node with nothing above it — a tree's +/// root, or the bead a rooted forest starts at — has nothing to measure +/// against and is drawn whole. +pub fn abbreviate<'a>(id: &'a str, above: Option<&str>) -> &'a str { + above + .and_then(|above| id.strip_prefix(above)) .filter(|rest| rest.starts_with('.')) .unwrap_or(id) } @@ -322,28 +327,34 @@ mod tests { staffed.agent = Some(agent(JoinSource::AgentPane)); assert_eq!( - cells(&staffed, ROOT, None, None).glyph, - cells(&node("smt-4kd3p.20", Status::InProgress), ROOT, None, None).glyph + cells(&staffed, Some(ROOT), None, None).glyph, + cells( + &node("smt-4kd3p.20", Status::InProgress), + Some(ROOT), + None, + None + ) + .glyph ); } #[test] - fn a_node_under_the_root_shows_only_what_it_adds_to_it() { - assert_eq!(abbreviate("smt-4kd3p.20", ROOT), ".20"); - assert_eq!(abbreviate("smt-4kd3p.1.4", ROOT), ".1.4"); + fn a_node_shows_only_what_it_adds_to_the_id_above_it() { + assert_eq!(abbreviate("smt-4kd3p.20", Some(ROOT)), ".20"); + assert_eq!(abbreviate("smt-4kd3p.1.4", Some("smt-4kd3p.1")), ".4"); } - /// A dangling or re-parented node is drawn under a root it does not - /// descend from, and a bare suffix there would say it does. + /// A dangling or re-parented node is drawn under a bead it does not descend + /// from, and a bare suffix there would say it does. #[test] - fn a_node_that_does_not_descend_from_the_root_keeps_its_whole_id() { - assert_eq!(abbreviate("mdw-6qzt4.3", ROOT), "mdw-6qzt4.3"); - assert_eq!(abbreviate("smt-4kd3pX.3", ROOT), "smt-4kd3pX.3"); + fn a_node_that_does_not_descend_from_the_id_above_it_keeps_its_whole_id() { + assert_eq!(abbreviate("mdw-6qzt4.3", Some(ROOT)), "mdw-6qzt4.3"); + assert_eq!(abbreviate("smt-4kd3pX.3", Some(ROOT)), "smt-4kd3pX.3"); } #[test] - fn the_root_keeps_its_whole_id() { - assert_eq!(abbreviate(ROOT, ROOT), ROOT); + fn a_node_with_nothing_above_it_keeps_its_whole_id() { + assert_eq!(abbreviate(ROOT, None), ROOT); } #[test] @@ -418,7 +429,7 @@ mod tests { #[test] fn a_bead_with_nothing_wrong_carries_no_marker_at_all() { - let row = cells(&node("smt-4kd3p.20", Status::Open), ROOT, None, None); + let row = cells(&node("smt-4kd3p.20", Status::Open), Some(ROOT), None, None); assert_eq!(row.anomalies, None); assert_eq!(row.agent, None); @@ -454,7 +465,7 @@ mod tests { key: "delivery_pr".into(), }]; - let row = cells(&unfilled, ROOT, None, None); + let row = cells(&unfilled, Some(ROOT), None, None); assert!( row.notes.iter().any(|note| note.contains("delivery_pr")), @@ -471,7 +482,7 @@ mod tests { Some(&format!("https://forge.invalid/orbital{HOSTILE}/pull/12")), ); - let row = cells(&hostile, ROOT, None, None); + let row = cells(&hostile, Some(ROOT), None, None); assert!( row.notes.iter().any(|note| note.contains("delivery_pr")), @@ -488,7 +499,7 @@ mod tests { Some("https://forge.invalid/orbital/atlas/pull/12"), ); - let row = cells(&hostile, ROOT, None, None); + let row = cells(&hostile, Some(ROOT), None, None); assert!( row.notes.iter().any(|note| note.contains("delivery_pr")), @@ -505,7 +516,7 @@ mod tests { let mut hostile = badged("⇢ atlas #12", Some(SOMEWHERE)); hostile.badges[0].short = Some(format!("⇢ #12{HOSTILE}")); - let row = cells(&hostile, ROOT, None, None); + let row = cells(&hostile, Some(ROOT), None, None); assert!( row.notes @@ -522,7 +533,7 @@ mod tests { let mut unlinked = badged("⏸ waiting", None); unlinked.badges[0].short = Some(format!("⏸{HOSTILE}")); - let row = cells(&unlinked, ROOT, None, None); + let row = cells(&unlinked, Some(ROOT), None, None); assert_eq!(row.notes, Vec::::new()); } @@ -531,7 +542,7 @@ mod tests { fn a_badge_that_draws_its_link_leaves_nothing_on_the_row() { let linked = badged("⇢ #12", Some(SOMEWHERE)); - let row = cells(&linked, ROOT, None, None); + let row = cells(&linked, Some(ROOT), None, None); assert_eq!(row.notes, Vec::::new()); } @@ -544,7 +555,7 @@ mod tests { let mut both = badged("⇢ atlas #12", Some(SOMEWHERE)); both.badges[0].short = Some("⇢ #12".into()); - let row = cells(&both, ROOT, None, None); + let row = cells(&both, Some(ROOT), None, None); assert_eq!(row.notes, Vec::::new()); } @@ -553,7 +564,7 @@ mod tests { /// lose, so it leaves no note however it renders. #[test] fn a_badge_with_no_link_leaves_nothing_on_the_row() { - let row = cells(&badged("⏸ waiting", None), ROOT, None, None); + let row = cells(&badged("⏸ waiting", None), Some(ROOT), None, None); assert_eq!(row.notes, Vec::::new()); } @@ -561,7 +572,7 @@ mod tests { #[test] fn a_status_outside_bds_own_set_leaves_the_word_bd_used_on_the_row() { let odd = node("smt-4kd3p.20", Status::Other("triage".into())); - let row = cells(&odd, ROOT, None, None); + let row = cells(&odd, Some(ROOT), None, None); assert_eq!(row.glyph, '?'); assert!( @@ -590,7 +601,7 @@ mod tests { }, ]; - let row = cells(&badged, ROOT, None, None); + let row = cells(&badged, Some(ROOT), None, None); let drawn: Vec<&str> = row.badges.iter().map(|b| b.text.as_str()).collect(); assert_eq!(drawn, vec!["⇢ #12", "⏸ waiting"]); @@ -620,7 +631,7 @@ mod tests { }, ]; - let row = cells(&badged, ROOT, None, None); + let row = cells(&badged, Some(ROOT), None, None); assert_eq!( row.badges[0].link.as_deref(), @@ -636,7 +647,12 @@ mod tests { #[test] fn a_row_says_what_the_bead_says() { - let row = cells(&node("smt-4kd3p.20", Status::Blocked), ROOT, None, None); + let row = cells( + &node("smt-4kd3p.20", Status::Blocked), + Some(ROOT), + None, + None, + ); assert_eq!(row.status, Status::Blocked); assert_eq!(row.glyph, '●'); From 86c3b007c7c7e01c3a147df6d8a8c5e46979755e Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:23:01 +0100 Subject: [PATCH 2/2] test(view): search for the id the row drew, not one written out The test promising that a reader can type what the row showed them spelled the query by hand. A search matches on the whole id, so a hand-spelled suffix passes on that alone and never touches the drawn form at all. Taking the query off the row couples the two. With the layout weakened so every row draws its whole id, the hand-spelled `.1` still passes and this does not. --- src/view/forest/mod.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/view/forest/mod.rs b/src/view/forest/mod.rs index 303c04d..b230f41 100644 --- a/src/view/forest/mod.rs +++ b/src/view/forest/mod.rs @@ -6595,13 +6595,18 @@ credential_command = "secret harbour" /// The forest row is the one place `bdi` ever prints a shortened id — /// `row::abbreviate` has no other caller — so on a long screen the short - /// id is the only spelling the reader has been shown. A substring is what - /// reaches it: `.1` is a prefix of nothing. + /// id is the only spelling the reader has been shown, and what they type + /// has to reach the bead they read it off. The query is taken from the row + /// rather than written out here, because a search matches on the whole id + /// and a query spelled by hand would pass on that alone. + /// + /// A substring is what reaches it: the drawn form is a prefix of nothing. #[test] fn a_search_matches_the_shortened_id_the_row_draws() { let mut forest = flatten(snapshot()); + let drawn = row_of(&forest, "orb-7.1").id.clone(); - assert_eq!(forest.seek(".1"), went_to("orbital", "orb-7.1", 1, 4)); + assert_eq!(forest.seek(&drawn), went_to("orbital", "orb-7.1", 1, 4)); } #[test]