From baed7d1285fe608e8dbfea4ae42136bfd7daaccb Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:18:45 +0100 Subject: [PATCH 1/2] feat(tui): hide and show the tail with t While the tail is hidden the forest takes its rows and bdi reads no pane. Showing it again reads the selected pane afresh. --- docs/design.md | 9 + src/tui/drive.rs | 1 + src/tui/keys.rs | 8 + src/tui/screen.rs | 192 ++++++++++++++++-- src/view/draw/bands.rs | 94 +++++---- src/view/draw/mod.rs | 13 +- src/view/forest/mod.rs | 1 + src/view/mod.rs | 2 + ...tail_and_reads_no_pane_until_it_is_back.rs | 104 ++++++++++ 9 files changed, 363 insertions(+), 61 deletions(-) create mode 100644 tests/t_hides_the_tail_and_reads_no_pane_until_it_is_back.rs diff --git a/docs/design.md b/docs/design.md index e23bc45..52c21e8 100644 --- a/docs/design.md +++ b/docs/design.md @@ -3089,6 +3089,14 @@ it yields those before the forest yields any: the forest is the thing this tool exists to show. A read asks herdr for as many lines as the band had room for on the last frame, so a band that grows fills on the next read. +`t` hides the band and gives its rows to the forest, and `t` again brings it +back. While the band is hidden `bdi` reads no pane, since nothing would be +drawn from the read: a read already out is answered into nothing, and none is +due. Bringing the band back reads the selected pane at once, and until that +answer lands the band says it is reading rather than show rows as old as the +hiding. The band is shown at every start. The choice lasts the run and is not +remembered. + ### The reader says what their background is `bdi` cannot see the reader's background and does not ask. A `[theme]` section @@ -3446,6 +3454,7 @@ The bindings are vim-like, with the arrows as aliases: | `s` | cycle which copy of a bead opens, under the selected node | | `S` | cycle which copy of a bead opens, across the whole forest | | `F` | draw the selected bead as the only root, or put the forest back | +| `t` | hide or show the pane's output under the forest | | `/` | find part of a bead's id or title, wherever the forest draws it | | `n`, `^G` | go to the next bead matching the search | | `N`, `^T` | go to the one before it | diff --git a/src/tui/drive.rs b/src/tui/drive.rs index 2c80558..6e4f114 100644 --- a/src/tui/drive.rs +++ b/src/tui/drive.rs @@ -629,6 +629,7 @@ fn answered( | Action::CycleSpine | Action::CycleSpineForest | Action::ToggleFilter + | Action::ToggleTail | Action::FocusForest | Action::Search | Action::NextMatch diff --git a/src/tui/keys.rs b/src/tui/keys.rs index 0253f83..c01812e 100644 --- a/src/tui/keys.rs +++ b/src/tui/keys.rs @@ -109,6 +109,12 @@ pub(super) const BINDINGS: &[Binding] = &[ does: "draw the selected bead as the only root, or put the forest back", hint: None, }, + Binding { + keys: &[alone(KeyCode::Char('t'), "t")], + action: Action::ToggleTail, + does: "hide or show the pane's output under the forest", + hint: None, + }, Binding { keys: &[alone(KeyCode::Char('/'), "/")], action: Action::Search, @@ -538,6 +544,7 @@ pub(super) mod tests { Action::CycleSpine, Action::CycleSpineForest, Action::ToggleFilter, + Action::ToggleTail, Action::Focus, Action::FocusForest, Action::ShowBead, @@ -574,6 +581,7 @@ pub(super) mod tests { | Action::CycleSpine | Action::CycleSpineForest | Action::ToggleFilter + | Action::ToggleTail | Action::Focus | Action::FocusForest | Action::ShowBead diff --git a/src/tui/screen.rs b/src/tui/screen.rs index 2a9b152..f9a288a 100644 --- a/src/tui/screen.rs +++ b/src/tui/screen.rs @@ -24,6 +24,7 @@ use crate::model::join::BeadKey; use crate::model::snapshot::Snapshot; use crate::model::types::PaneKey; use crate::view::bindings::key_bindings; +use crate::view::draw::TailBand; use crate::view::forest::{self, Forest}; use crate::view::lines::Place; use crate::view::phrase; @@ -137,6 +138,9 @@ struct Shown { /// puts on the screen does and needs no program outside it. clipboard: Box, tail: Tail, + /// Whether the band is on the screen at all. Shown at every start: the + /// reader's choice lasts the run and no longer. + tail_band: TailBand, /// The pane the band on screen is about, so a selection moving within it /// does not spend a call on the provider for the answer already drawn. tailing: Option, @@ -245,6 +249,7 @@ impl Shown { let mut shown = Self { tail: tail::tail(&forest), tailing: tail::target(&forest).pane().cloned(), + tail_band: TailBand::Shown, forest, panes, clipboard, @@ -372,7 +377,7 @@ impl Shown { /// trade the bound is spent on: the tail can fall behind the selection, /// and the keyboard never does. fn ask(&mut self) { - if self.reading != Reading::Nothing { + if self.reading != Reading::Nothing || self.tail_band == TailBand::Hidden { return; } let Tail::Reading { pane } = &self.tail else { @@ -497,6 +502,30 @@ impl Shown { } } + /// Hide the band, or bring it back with a fresh read of the selected pane. + /// + /// Nothing reads the pane while the band is hidden, since nothing would be + /// drawn from the read. So hiding supersedes a read already out and + /// disarms the next one, and `ask` asks for nothing until the band is + /// back. Coming back is `retail`: the rows the band held when it went are + /// as old as the hiding, and the band says it is reading until the answer + /// lands. + fn toggle_tail(&mut self) { + match self.tail_band { + TailBand::Shown => { + self.tail_band = TailBand::Hidden; + if self.reading == Reading::Outstanding { + self.reading = Reading::Superseded; + } + self.due = None; + } + TailBand::Hidden => { + self.tail_band = TailBand::Shown; + self.retail(); + } + } + } + /// Follow the selection, where it has left the pane the tail is showing. fn follow(&mut self) { if tail::moved_on(&self.forest, self.tailing.as_ref()) { @@ -531,7 +560,7 @@ impl Shown { /// than left on the last frame's, so a pointer is answered for the screen /// it pointed at whatever has been drawn since. fn clicked(&mut self, screen: Rect, row: u16) -> bool { - let bands = draw::regions(screen, self.forest.lines().len()); + let bands = draw::regions(screen, self.forest.lines().len(), self.tail_band); self.forest.fit(bands.forest.height as usize); match draw::line_at( @@ -741,6 +770,10 @@ impl Shown { if action == Action::NextRelated { return self.step_related(); } + if action == Action::ToggleTail { + self.toggle_tail(); + return true; + } if action == Action::CopyId { return self.copy_id(); } @@ -1006,31 +1039,24 @@ fn said_of(landed: forest::Landed) -> Said { /// broken. A window goes on last because it sits over the forest rather /// than in place of it. /// -/// Answers how many of the pane's lines the band had room for, under its rule. +/// Answers how many of the pane's lines the band has room for under its rule, +/// hidden or not, so a band brought back asks for a band's worth. #[allow(clippy::too_many_arguments)] fn paint( frame: &mut Frame, forest: &mut Forest, layout: &Layout, tail: draw::Band<'_>, + tail_band: TailBand, over: Over<'_>, foot: draw::Foot, collecting: &[Awaited], lapsed: &[String], now: DateTime, ) -> u16 { - let bands = draw::regions(frame.area(), forest.lines().len()); + let bands = draw::regions(frame.area(), forest.lines().len(), tail_band); forest.fit(bands.forest.height as usize); - draw::draw( - frame, - frame.area(), - forest, - layout, - collecting, - lapsed, - now, - foot, - ); + draw::draw(frame, bands, forest, layout, collecting, lapsed, now, foot); draw::draw_tail(frame, bands.tail, tail); match over { Over::Nothing => {} @@ -1052,7 +1078,10 @@ fn paint( } } } - bands.tail.height.saturating_sub(1) + draw::regions(frame.area(), forest.lines().len(), TailBand::Shown) + .tail + .height + .saturating_sub(1) } /// The window over the forest, where one is up, with what drawing it needs: @@ -1189,6 +1218,7 @@ impl View for Screen { let Shown { forest, tail, + tail_band, show, collecting, lapsed, @@ -1220,6 +1250,7 @@ impl View for Screen { forest, &drawing.layout, band, + *tail_band, over, foot, collecting, @@ -1352,7 +1383,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", - " … 36 more bindings · no room on a screen this short", + " … 37 more bindings · no room on a screen this short", ] ); } @@ -1462,6 +1493,7 @@ mod tests { " s cycle which copy of a bead opens, under the selected node", " S cycle which copy of a bead opens, across the whole forest", " F draw the selected bead as the only root, or put the forest back", + " t hide or show the pane's output under the forest", " / find part of a bead's id or title, wherever the forest draws it", " n, ^G go to the next bead matching the search", " N, ^T go to the one before it", @@ -1680,6 +1712,7 @@ mod tests { fn painted( forest: &mut Forest, tail: &Tail, + tail_band: TailBand, over: Over<'_>, pressed: Pressed<'_>, collecting: &[Awaited], @@ -1703,6 +1736,7 @@ mod tests { tail, background: Background::Dark, }, + tail_band, over, foot, collecting, @@ -1722,6 +1756,7 @@ mod tests { painted( forest, tail, + TailBand::Shown, over, Pressed::default(), &[], @@ -1751,6 +1786,7 @@ mod tests { painted( forest, tail, + TailBand::Shown, Over::Nothing, Pressed::default(), collecting, @@ -1817,6 +1853,7 @@ mod tests { painted( &mut forest, &Tail::Silent("nothing to tail"), + TailBand::Shown, Over::Nothing, Pressed::default(), &[], @@ -2359,7 +2396,11 @@ mod tests { /// viewport that scrolled back to the top is the same bug wearing a /// different hat, and only the rows show the difference. fn forest_band(shown: &mut Shown, width: u16, height: u16) -> Vec { - let bands = draw::regions(Rect::new(0, 0, width, height), shown.forest.lines().len()); + let bands = draw::regions( + Rect::new(0, 0, width, height), + shown.forest.lines().len(), + TailBand::Shown, + ); let rows = screen_of(&mut shown.forest, &shown.tail, width, height, Over::Nothing).rows(); rows[..bands.forest.height as usize].to_vec() } @@ -3152,7 +3193,7 @@ mod tests { fn a_click_selects_the_line_the_forest_drew_on_that_row() { let screen = Rect::new(0, 0, 60, 24); let mut shown = shown(a_grove(30)); - let band = draw::regions(screen, shown.forest.lines().len()).forest; + let band = draw::regions(screen, shown.forest.lines().len(), shown.tail_band).forest; assert!(shown.clicked(screen, band.y + 3)); assert_eq!(shown.forest.selected_line(), 3); @@ -3180,7 +3221,7 @@ mod tests { fn a_click_beneath_the_forest_selects_nothing() { let screen = Rect::new(0, 0, 60, 24); let mut shown = shown(a_grove(30)); - let bands = draw::regions(screen, shown.forest.lines().len()); + let bands = draw::regions(screen, shown.forest.lines().len(), shown.tail_band); shown.clicked(screen, bands.forest.y + 3); let selected = shown.forest.selected_line(); @@ -3549,6 +3590,112 @@ mod tests { ); } + /// The screen as `Shown` would draw it, with the band hidden or not as + /// the reader left it. + fn shown_screen(shown: &mut Shown, width: u16, height: u16) -> Vec { + painted( + &mut shown.forest, + &shown.tail, + shown.tail_band, + Over::Nothing, + Pressed::default(), + &[], + &[], + width, + height, + ) + .rows() + } + + /// `t` takes the band away and the forest draws on its rows, and `t` + /// again puts it back where it was. + #[test] + fn t_gives_the_tails_rows_to_the_forest_and_takes_them_back() { + let (mut shown, _) = shown_asking(a_grove(30)); + let rule = |screen: &[String]| screen.iter().position(|row| row.starts_with('─')); + let last_bead = |screen: &[String]| { + screen + .iter() + .rposition(|row| row.contains("a bead in the grove")) + .expect("the forest draws beads") + }; + let before = shown_screen(&mut shown, 60, 24); + let under_the_forest = rule(&before).expect("the band's rule is drawn"); + + assert!(press(&mut shown, KeyCode::Char('t'))); + let hidden = shown_screen(&mut shown, 60, 24); + + assert_eq!(rule(&hidden), None, "no band, so no rule: {hidden:#?}"); + assert_eq!( + last_bead(&hidden), + 22, + "the forest runs down to the row above the keys: {hidden:#?}" + ); + + assert!(press(&mut shown, KeyCode::Char('t'))); + let back = shown_screen(&mut shown, 60, 24); + + assert_eq!(rule(&back), Some(under_the_forest), "{back:#?}"); + assert_eq!(last_bead(&back), last_bead(&before), "{back:#?}"); + } + + /// Nothing is drawn from a read while the band is hidden, so none is + /// asked for: not the one an answer already out would have asked for + /// next, not one on the band's clock, and not one for a pane the + /// selection moves on to. Bringing the band back asks for the selected + /// pane at once, and says it is reading rather than show rows as old as + /// the hiding. + #[test] + fn a_hidden_tail_reads_no_pane_until_it_is_shown_again() { + let (mut shown, panes) = shown_asking(a_staffed_grove(6)); + let opened_on = panes.reads(); + let answered = an_instant(); + shown.tailed(read(A_SELECTED_PANE, &["what it says"]), answered); + shown.reread(answered + EVERY); + let reading = panes.reads(); + assert_eq!(reading.len(), opened_on.len() + 1, "a read is out"); + + press(&mut shown, KeyCode::Char('t')); + let later = answered + EVERY * 100; + shown.tailed(read(A_SELECTED_PANE, &["what it says next"]), later); + assert_eq!( + shown.rereads_in(later), + None, + "nothing is due while the band is hidden" + ); + shown.reread(later); + to_the_last_row(&mut shown); + + assert_eq!(panes.reads(), reading, "nothing was read while hidden"); + + press(&mut shown, KeyCode::Char('t')); + let resting_on = match &shown.tail { + Tail::Reading { pane } => pane.clone(), + other => panic!("the band says it is reading: {other:?}"), + }; + assert_eq!( + panes.reads(), + [reading, vec![format!("{} {}", resting_on.id, tail::LINES)]].concat(), + "the pane the selection rests on is read as the band comes back" + ); + } + + /// The band is hidden with nothing out and a read due: the due read is + /// not made. + #[test] + fn hiding_the_tail_disarms_the_read_that_was_due() { + let (mut shown, panes) = shown_asking(a_staffed_grove(6)); + let answered = an_instant(); + shown.tailed(read(A_SELECTED_PANE, &["what it says"]), answered); + let filled = panes.reads(); + + press(&mut shown, KeyCode::Char('t')); + shown.reread(answered + EVERY); + + assert_eq!(panes.reads(), filled); + assert_eq!(shown.rereads_in(answered + EVERY), None); + } + /// A pane that has said nothing new since the last read is the common /// case four times a second, and it is not a change to the screen. #[test] @@ -4527,6 +4674,7 @@ mod tests { painted( &mut shown.forest, &shown.tail, + TailBand::Shown, Over::Nothing, Pressed { said: shown.said.as_ref(), @@ -4697,7 +4845,11 @@ mod tests { }; let rows = screen_of(&mut forest, &tail, 40, 12, Over::Nothing).rows(); - let bands = draw::regions(Rect::new(0, 0, 40, 12), forest.lines().len()); + let bands = draw::regions( + Rect::new(0, 0, 40, 12), + forest.lines().len(), + TailBand::Shown, + ); assert!( rows[bands.tail.y as usize].contains("w:p1"), diff --git a/src/view/draw/bands.rs b/src/view/draw/bands.rs index a693a3d..395383d 100644 --- a/src/view/draw/bands.rs +++ b/src/view/draw/bands.rs @@ -17,6 +17,13 @@ pub struct Regions { pub keys: Rect, } +/// Whether the tail's band is on the screen, which the reader turns with `t`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TailBand { + Shown, + Hidden, +} + /// Divide the screen between the forest, the tail and the key bar. /// /// `lines` is how many rows the forest has to show. The tail takes the rows @@ -25,14 +32,21 @@ pub struct Regions { /// tail keeps `LINES` and its rule and gives up those before the forest gives /// up any, and the forest is never left with none: a `bdi` with no tree on /// screen is not showing the thing it exists to show. -pub fn regions(area: Rect, lines: usize) -> Regions { +/// +/// A hidden tail takes no rows, and the forest has them all. +pub fn regions(area: Rect, lines: usize, band: TailBand) -> Regions { let keys = key_rows(area); let rows = area.height - keys; - let looking = (tail::LINES + 1).min(rows.saturating_sub(1) / 2); - let free = rows.saturating_sub(u16::try_from(lines).unwrap_or(u16::MAX)); - let tail = looking - .max(free.min(area.height / 2)) - .min(rows.saturating_sub(1)); + let tail = match band { + TailBand::Shown => { + let looking = (tail::LINES + 1).min(rows.saturating_sub(1) / 2); + let free = rows.saturating_sub(u16::try_from(lines).unwrap_or(u16::MAX)); + looking + .max(free.min(area.height / 2)) + .min(rows.saturating_sub(1)) + } + TailBand::Hidden => 0, + }; let forest = rows - tail; Regions { @@ -104,7 +118,7 @@ mod tests { #[test] fn a_full_screen_gives_the_forest_most_of_it_the_tail_a_look_and_the_keys_a_row() { - let bands = regions(Rect::new(0, 0, 80, 24), A_TALL_TREE); + let bands = regions(Rect::new(0, 0, 80, 24), A_TALL_TREE, TailBand::Shown); assert_eq!(bands.forest, Rect::new(0, 0, 80, 16)); assert_eq!( @@ -119,7 +133,7 @@ mod tests { /// for and a screen showing no tree is showing nothing. #[test] fn a_short_screen_takes_the_rows_from_the_tail_and_not_from_the_forest() { - let bands = regions(Rect::new(0, 0, 80, 10), A_TALL_TREE); + let bands = regions(Rect::new(0, 0, 80, 10), A_TALL_TREE, TailBand::Shown); assert_eq!(bands.forest.height, 5); assert_eq!(bands.tail.height, 4); @@ -130,7 +144,7 @@ mod tests { /// to half the screen, so no blank sits between the tree and the pane. #[test] fn a_short_tree_gives_the_tail_its_free_rows_up_to_half_the_screen() { - let bands = regions(Rect::new(0, 0, 80, 40), 5); + let bands = regions(Rect::new(0, 0, 80, 40), 5, TailBand::Shown); assert_eq!(bands.tail.height, 20, "half of forty rows"); assert_eq!(bands.forest.height, 19); @@ -139,7 +153,7 @@ mod tests { #[test] fn a_tree_leaving_less_than_half_the_screen_free_gives_the_tail_exactly_that() { - let bands = regions(Rect::new(0, 0, 80, 40), 25); + let bands = regions(Rect::new(0, 0, 80, 40), 25, TailBand::Shown); assert_eq!(bands.forest.height, 25, "every line of the tree is drawn"); assert_eq!(bands.tail.height, 14); @@ -149,7 +163,7 @@ mod tests { /// screen, however short the tree. #[test] fn a_tree_leaving_fewer_rows_free_than_the_tail_keeps_is_scrolled_instead() { - let bands = regions(Rect::new(0, 0, 80, 24), 20); + let bands = regions(Rect::new(0, 0, 80, 24), 20, TailBand::Shown); assert_eq!(bands.tail.height, tail::LINES + 1); assert_eq!(bands.forest.height, 16); @@ -159,7 +173,7 @@ mod tests { fn the_forest_keeps_a_row_however_little_room_there_is() { for lines in [0, 1, A_TALL_TREE] { for height in 1..=8 { - let bands = regions(Rect::new(0, 0, 80, height), lines); + let bands = regions(Rect::new(0, 0, 80, height), lines, TailBand::Shown); assert!( bands.forest.height >= 1, "{height} rows, {lines} lines: {bands:?}" @@ -173,7 +187,7 @@ mod tests { #[test] fn the_smallest_screens_spend_their_rows_on_the_forest_first() { assert_eq!( - regions(Rect::new(0, 0, 80, 2), 0), + regions(Rect::new(0, 0, 80, 2), 0, TailBand::Shown), Regions { forest: Rect::new(0, 0, 80, 1), tail: Rect::new(0, 1, 80, 0), @@ -181,7 +195,7 @@ mod tests { } ); assert_eq!( - regions(Rect::new(0, 0, 80, 1), 0), + regions(Rect::new(0, 0, 80, 1), 0, TailBand::Shown), Regions { forest: Rect::new(0, 0, 80, 1), tail: Rect::new(0, 1, 80, 0), @@ -194,24 +208,36 @@ mod tests { /// the last frame left there, and an overlap would draw two things at once. #[test] fn the_three_bands_tile_the_screen_exactly() { - for (height, lines) in - (0..40).flat_map(|height| [(height, 0), (height, 9), (height, A_TALL_TREE)]) - { - let area = Rect::new(3, 7, 80, height); - let bands = regions(area, lines); - - assert_eq!(bands.forest.y, area.y, "{height}"); - assert_eq!( - bands.tail.y, - bands.forest.y + bands.forest.height, - "{height}" - ); - assert_eq!(bands.keys.y, bands.tail.y + bands.tail.height, "{height}"); - assert_eq!( - bands.forest.height + bands.tail.height + bands.keys.height, - area.height, - "{height}" - ); + for band in [TailBand::Shown, TailBand::Hidden] { + for (height, lines) in + (0..40).flat_map(|height| [(height, 0), (height, 9), (height, A_TALL_TREE)]) + { + let area = Rect::new(3, 7, 80, height); + let bands = regions(area, lines, band); + let at = format!("{band:?} on {height} rows"); + + assert_eq!(bands.forest.y, area.y, "{at}"); + assert_eq!(bands.tail.y, bands.forest.y + bands.forest.height, "{at}"); + assert_eq!(bands.keys.y, bands.tail.y + bands.tail.height, "{at}"); + assert_eq!( + bands.forest.height + bands.tail.height + bands.keys.height, + area.height, + "{at}" + ); + } + } + } + + /// A hidden tail gives the forest every row the keys leave, whether the + /// tree needs them or not. + #[test] + fn a_hidden_tail_gives_the_forest_every_row_above_the_keys() { + for lines in [5, A_TALL_TREE] { + let bands = regions(Rect::new(0, 0, 80, 24), lines, TailBand::Hidden); + + assert_eq!(bands.forest, Rect::new(0, 0, 80, 23), "{lines} lines"); + assert_eq!(bands.tail.height, 0, "{lines} lines"); + assert_eq!(bands.keys, Rect::new(0, 23, 80, 1), "{lines} lines"); } } @@ -232,7 +258,7 @@ mod tests { fn every_row_of_the_forest_names_the_line_drawn_on_it() { for wheeled in 0..3 { let (width, height) = (60, 24); - let band = regions(Rect::new(0, 0, width, height), A_TALL_TREE).forest; + let band = regions(Rect::new(0, 0, width, height), A_TALL_TREE, TailBand::Shown).forest; let mut forest = opened(&snapshot( vec![grove(40)], Vec::new(), @@ -280,7 +306,7 @@ mod tests { /// the selection can sit on. #[test] fn a_row_outside_the_forest_band_names_none() { - let bands = regions(Rect::new(0, 0, 60, 24), A_TALL_TREE); + let bands = regions(Rect::new(0, 0, 60, 24), A_TALL_TREE, TailBand::Shown); let (from, lines) = (0, 100); for row in [bands.tail.y, bands.tail.y + 3, bands.keys.y] { diff --git a/src/view/draw/mod.rs b/src/view/draw/mod.rs index ea665a6..872ea49 100644 --- a/src/view/draw/mod.rs +++ b/src/view/draw/mod.rs @@ -33,7 +33,7 @@ use crate::view::query::Query; use crate::view::row::{Cell, Layout, Widths, AGENT, WARNING}; use crate::view::{Freshness, Notice, Said}; -pub use bands::{key_rows, line_at, regions}; +pub use bands::{key_rows, line_at, regions, Regions, TailBand}; pub(crate) use bead::identity_widths; pub use tail::{draw_tail, Band}; @@ -126,12 +126,12 @@ pub struct Foot<'a> { pub keys: &'a [String], } -/// Draw the forest and the foot, leaving the tail's band to whoever holds a -/// tail. +/// Draw the forest and the foot into the bands `regions` gave them, leaving +/// the tail's band to whoever holds a tail. #[allow(clippy::too_many_arguments)] pub fn draw( frame: &mut Frame, - area: Rect, + bands: Regions, forest: &Forest, layout: &Layout, collecting: &[Awaited], @@ -139,7 +139,6 @@ pub fn draw( now: DateTime, foot: Foot, ) { - let bands = regions(area, forest.lines().len()); let lines = forest.lines(); let selected = forest.selected_line(); let height = bands.forest.height as usize; @@ -669,7 +668,7 @@ mod tests { Painted::drawn_by(width, height, |frame| { draw( frame, - frame.area(), + regions(frame.area(), forest.lines().len(), TailBand::Shown), forest, &Layout::default(), collecting, @@ -699,7 +698,7 @@ mod tests { .draw(|frame| { draw( frame, - frame.area(), + regions(frame.area(), forest.lines().len(), TailBand::Shown), &forest, &Layout::default(), &[], diff --git a/src/view/forest/mod.rs b/src/view/forest/mod.rs index eedd834..ac64049 100644 --- a/src/view/forest/mod.rs +++ b/src/view/forest/mod.rs @@ -602,6 +602,7 @@ impl Forest { true } Action::Focus + | Action::ToggleTail | Action::ShowBead | Action::NextRelated | Action::Back diff --git a/src/view/mod.rs b/src/view/mod.rs index 2f929bf..79c9ac9 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -95,6 +95,8 @@ pub enum Action { CycleSpineForest, /// Show every tree, rather than only those with a live agent. ToggleFilter, + /// Hide the tail and give its rows to the forest, or bring it back. + ToggleTail, /// Focus the selected bead's pane in herdr. Focus, /// Draw the selected bead as the only root, with everything beneath it and diff --git a/tests/t_hides_the_tail_and_reads_no_pane_until_it_is_back.rs b/tests/t_hides_the_tail_and_reads_no_pane_until_it_is_back.rs new file mode 100644 index 0000000..3ebc734 --- /dev/null +++ b/tests/t_hides_the_tail_and_reads_no_pane_until_it_is_back.rs @@ -0,0 +1,104 @@ +//! `t` takes the tail away and gives its rows to the forest, and `t` again +//! brings it back with a fresh read of the pane. +//! +//! While the tail is hidden nothing is drawn from a read, so none is made. +//! The shimmed herdr counts every read it is asked for, and the band's +//! interval is short enough that a band still reading would ask dozens of +//! times over the window this test waits. + +mod terminal; + +use std::time::Duration; + +use terminal::driver::{Driven, GIVING_UP}; +use terminal::shims::{ShimmedHerdr, A_PANE}; +use terminal::{a_home_naming_one_project_settled, a_socket_of_its_own, rows_drawn, rows_of}; + +const ROWS: u16 = 40; +const COLS: u16 = 120; + +/// `G`, which puts the selection on the last row: the one pane the shimmed +/// herdr reports. +const LAST_ROW: &[u8] = b"G"; + +const TOGGLE_THE_TAIL: &[u8] = b"t"; + +/// Part of the heading over the panes working outside every configured +/// project, from `view::phrase`, which says the pane's row is there to land +/// on. +const A_PANE_ROW_HAS_ARRIVED: &[u8] = "every configured project".as_bytes(); + +/// What is on the pane before the tail is hidden, and what is on it by the +/// time the tail comes back. +const A_BUILD_RUNNING: &str = "rebuilding .#larkspur\n"; +const A_BUILD_FINISHED: &str = "rebuilt .#larkspur, generation 541\n"; +const WHILE_IT_RUNS: &[u8] = "rebuilding".as_bytes(); +const ONCE_IT_IS_DONE: &[u8] = "generation".as_bytes(); + +/// A band asking twenty times a second. +const ASKING_OFTEN: &str = "\n[tui]\ntail_refresh_millis = 50\n"; + +/// How long the reads are counted over while the tail is hidden: forty asks' +/// worth for a band that was still reading. +const A_WINDOW: Duration = Duration::from_secs(2); + +/// A gap this long between bytes means the frame is drawn. +const A_SILENCE: Duration = Duration::from_millis(300); + +/// The screen drawn whole, by growing the terminal a row and putting it back: +/// a frame drawn as a difference from the one before holds only the cells +/// that moved. +fn the_whole_screen(bdi: &mut Driven) -> Vec { + bdi.settle(A_SILENCE, GIVING_UP); + let grown = bdi.resize(ROWS + 1, COLS); + bdi.answer_to(grown, GIVING_UP); + bdi.settle(A_SILENCE, GIVING_UP); + let back = bdi.resize(ROWS, COLS); + bdi.answer_to(back, GIVING_UP) +} + +#[test] +fn t_hides_the_tail_reads_nothing_while_it_is_hidden_and_reads_afresh_to_bring_it_back() { + let home = a_home_naming_one_project_settled("toggle-tail", ASKING_OFTEN); + let herdr = ShimmedHerdr::beside(&home); + herdr.shows(A_BUILD_RUNNING); + let mut environment = herdr.environment(); + environment.push(a_socket_of_its_own(&home)); + + let mut bdi = Driven::bdi(ROWS, COLS, home, &environment); + bdi.read_until(A_PANE_ROW_HAS_ARRIVED, GIVING_UP); + bdi.send(LAST_ROW); + bdi.read_until(WHILE_IT_RUNS, GIVING_UP); + let shown = the_whole_screen(&mut bdi); + let named_while_shown = rows_of(&shown, A_PANE.as_bytes()).len(); + + bdi.send(TOGGLE_THE_TAIL); + let hidden = the_whole_screen(&mut bdi); + + assert_eq!( + rows_of(&hidden, WHILE_IT_RUNS), + Vec::::new(), + "the pane's output is off the screen: {:#?}", + rows_drawn(&hidden) + ); + assert_eq!( + rows_of(&hidden, A_PANE.as_bytes()).len(), + named_while_shown - 1, + "the rule naming the pane went with the band, and the forest's row \ + for the pane stayed: {:#?}", + rows_drawn(&hidden) + ); + + let before = herdr.reads(); + std::thread::sleep(A_WINDOW); + assert_eq!( + herdr.reads(), + before, + "the pane was read while the tail was hidden\n{}", + bdi.timeline() + ); + + herdr.shows(A_BUILD_FINISHED); + bdi.send(TOGGLE_THE_TAIL); + bdi.read_until(ONCE_IT_IS_DONE, GIVING_UP); +} From a32bf100845654b977c19d9d003b4f8942443c00 Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:36:09 +0100 Subject: [PATCH 2/2] test(tui): pin that a hidden tail reads a cropped pane no more --- src/tui/screen.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/tui/screen.rs b/src/tui/screen.rs index 1af5cce..825a5d7 100644 --- a/src/tui/screen.rs +++ b/src/tui/screen.rs @@ -3484,6 +3484,21 @@ mod tests { ); } + /// A cropped pane is read whole, and a hidden band reads it no more than + /// any other pane. + #[test] + fn a_hidden_tail_reads_a_cropped_pane_no_more() { + let (mut shown, panes) = shown_cropping_claude_code_over("claude"); + let opened_on = panes.reads(); + + press(&mut shown, KeyCode::Char('t')); + let later = an_instant() + EVERY * 100; + shown.tailed(read(A_SELECTED_PANE, &A_CLAUDE_CODE_SCREEN), later); + shown.reread(later); + + assert_eq!(panes.reads(), opened_on); + } + /// A crop is the agent's, so a pane holding an agent the reader gave none /// is read and drawn exactly as a pane was before there were crops. #[test]