diff --git a/ROADMAP.md b/ROADMAP.md index 0749cdc3..18e1a52f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,7 +348,7 @@ Every item on the deferral shelf below has a milestone here, so a milestone-filt | ✅ | `SHEET_KEEP` names two things, so removing a gesture reassigns the never-drop set by rank | [#464](https://github.com/breferrari/vigia/issues/464) | | ✅ | A release that removes a key can be filed as internal, and 0.42.0 was | [#467](https://github.com/breferrari/vigia/issues/467) | | ✅ | A hidden path still wakes the watch. **Deferred by [#495](https://github.com/breferrari/vigia/issues/495): it changes what I1 measures** | [#501](https://github.com/breferrari/vigia/issues/501) | -| ⬜ | A note's rows are built whole before the clamp trims them. **Deferred by [#452](https://github.com/breferrari/vigia/issues/452): the steady frame is 12.557ms against 12.483ms as prose, and it is the frame an answer arrives on that scales** | [#530](https://github.com/breferrari/vigia/issues/530) | +| ✅ | A note's rows are built whole before the clamp trims them. **Deferred by [#452](https://github.com/breferrari/vigia/issues/452): the steady frame is 12.557ms against 12.483ms as prose, and it is the frame an answer arrives on that scales** | [#530](https://github.com/breferrari/vigia/issues/530) | | ✅ | The caret row's weight is the one modifier a theme file cannot reach | [#195](https://github.com/breferrari/vigia/issues/195) | | ⬜ | Three overlays are kept apart by hand in eight places. **Deferred by [#511](https://github.com/breferrari/vigia/issues/511) and again by [#508](https://github.com/breferrari/vigia/issues/508): a refactor of the two that predate the branches that found it.** [#519](https://github.com/breferrari/vigia/issues/519) is its duplicate, filed before this row was found and closed against it | [#515](https://github.com/breferrari/vigia/issues/515) | | ⬜ | The sheet's tables are audited, not derived, so the keymap can still drift into them | [#312](https://github.com/breferrari/vigia/issues/312) | @@ -580,6 +580,7 @@ Items that moved into an *earlier* phase than planned. Recorded for the same rea | Exclude files frozen at watch start ([#621](https://github.com/breferrari/vigia/issues/621)) | Off the Shelf, 2026-09-30 | Filed by #619 and still real. Two stats a tick while events flow, none while idle. | | Mixed release notes fail the bump ([#624](https://github.com/breferrari/vigia/issues/624)) | Off the Shelf, 2026-09-30 | Taken by the unattended loop, whose merged #617 and #622 it breaks the next release on. Workflow work, which the loop takes when it blocks releasing the loop's own PRs. | | The heat projection's cost follows the file ([#55](https://github.com/breferrari/vigia/issues/55)) | Off the Shelf, 2026-09-30 | Taken by the unattended loop when `next.sh` ran dry. The row's reason was that nothing had ever taken it, which is not a reason that can hold. The fix is a slot beside the cached diff: a file's heat strip is projected once per computed diff and served from the frame while that diff is reused, so a frame pays a copy per drawn heading and the walk over the hunks is paid once per computed diff | +| A note's rows are built whole before the clamp trims them ([#530](https://github.com/breferrari/vigia/issues/530)) | Off the Shelf, 2026-09-30 | Taken by the reader's approval of the plan when `next.sh` ran dry. The row's reason was a cost statement, not a blocker: the arrival frame of a fenced answer scaled with the answer's length and was paid once. A note is now counted whole and built only where the clamp keeps it, and a quoted block is parsed as far as the last line the frame draws, so an arrival costs a screenful | --- diff --git a/RULINGS.md b/RULINGS.md index d91f0f6b..9e069c4f 100644 --- a/RULINGS.md +++ b/RULINGS.md @@ -185,6 +185,18 @@ The middle column **is** the floor. One real sibling pays the compile in full. T > > **Fixed 2026-09-04 ([#412](https://github.com/breferrari/vigia/issues/412)), which makes the line above false.** Every fresh diff now puts its height beside it, and the walk asks only the span, with one stat. A print that moved inside the settle margin keeps its height until the file settles, and the walk reads it once then. Re-reading inside the margin was #84's 20.71ms breach of I9. So the in-margin re-measure above became a deferral: one stat per file per tick, and one read when the file settles. That frame is asserted against I9. +## I4 — a note's rows and a quoted block's parse follow the window, 2026-09-30 + +Before [#530](https://github.com/breferrari/vigia/issues/530), `Pin::rows` built every row of every note twice a frame, once for the clamp's count and once to emit, and `answer_of` parsed every line of a fenced block on the frame the answer arrived. Measured in release on the note rig on 2026-09-12: a 2,000-line fenced answer cost 160.5ms on its arrival frame against 13.2ms as prose, and 10,000 lines cost 757.2ms. The steady frame held (12.557ms fenced against 12.483ms prose for twenty four-line answers), which is why the row sat on the Shelf as "paid once". + +The mechanism: a note is one list of sections (`Pin::sections`) that the count sums and the build walks, so the clamp's exact count and the emitted rows cannot differ, and a unit test (`view::tests::count_matches_build`) holds every shape, width and window to that. The window functions report the rows they walked, so a part is not counted before it is built. A quoted block keeps its parser position (`Quote::parse`) and `Pass::quoted_to` fills it forward to the last line the window reaches; `HighlightStats::quoted_lines` counts the lines parsed. `View::built` counts the note rows a screen built. + +Measured on the branch, release, with `arrival_holds_the_budget`: the 80x160 pane's fenced arrival went from **119.98ms p50 on `main` to 18.69ms**, against 13.14ms for the same bytes as prose, and it parsed 110 lines for 115 rows; the 80x24 pane reads **7.90ms fenced against 7.55ms prose**, 8 lines parsed for 13 rows. The tall pane's remainder is its window, a hundred-odd lines of grammar on top of a 13ms frame, so the gate times the ordinary pane and reads the tall one. The steady quoted gate moved from 12.557ms to 12.52ms fenced. + +Three alternatives were rejected. A lazy clamp that never needs an exact count: the bottom clamp computes `dropped = total - height` and a box pulls the window by row sums, so the count must be exact, and only the build can be windowed. Parsing at placement with a line bound from the layout: placement runs before the clamp decides the window, so the bound would be circular; the rows the clamp keeps are the only position that knows it. Sharing the hunk's `Entry::fill_to`: a hunk has checkpoints, a rewind and three parse states because a line of it changes before every frame; a quote has one `Side` and is replaced whole on rewrite, so the ten-line fill loop is the only shape shared and a shared type would carry the hunk's machinery for nothing. + +Left as it is: the bottom clamp on an answer taller than the pane parses to the last drawn row, which is the whole block, once, the same first touch the #51 bullet records for `G` into a hunk. The count walk is a width scan of the whole answer per frame, twice at most; `prose_pieces` rescans a paragraph's remainder per piece, which a 100 KB single line would feel, and which predates this change. + ## I2 — why it is two numbers > [!NOTE] diff --git a/SPEC.md b/SPEC.md index 67788f39..1679c138 100644 --- a/SPEC.md +++ b/SPEC.md @@ -74,7 +74,7 @@ A regression past any budget **fails the build.** > **The measurement histories behind these rows are in [`RULINGS.md`](RULINGS.md)** > > - **I1** has a cost its row does not measure. `crossterm`'s mouse bundle sets `?1003h`, so pointer motion over the pane wakes the loop, and that cannot be reclaimed portably. **A batch that asks for nothing, moves no pointer mark and meets no deadline paints nothing**, so the wake is all it costs ([#154](https://github.com/breferrari/vigia/issues/154)). -> - **I4** was **narrowed 2026-08-01**. A height is hunk boundaries and line counts, not content. That is the row's first exception, and it is why #49's refusal of a repository-wide total still holds for sums over content. The height walk became **incremental 2026-08-04** (#101). Since 2026-09-04, a file that moved inside the settle margin keeps its height until it settles. It is then read once, on a wake at the margin's end ([#412](https://github.com/breferrari/vigia/issues/412), [#425](https://github.com/breferrari/vigia/issues/425)). The numbers and three rejected alternatives are in the ledger. +> - **I4** was **narrowed 2026-08-01**. A height is hunk boundaries and line counts, not content. That is the row's first exception, and it is why #49's refusal of a repository-wide total still holds for sums over content. The height walk became **incremental 2026-08-04** (#101). Since 2026-09-04, a file that moved inside the settle margin keeps its height until it settles. It is then read once, on a wake at the margin's end ([#412](https://github.com/breferrari/vigia/issues/412), [#425](https://github.com/breferrari/vigia/issues/425)). The numbers and three rejected alternatives are in the ledger. A note's rows and a quoted block's parse follow the window too ([#530](https://github.com/breferrari/vigia/issues/530)): a note is counted whole and built only where the clamp keeps it, and a block is parsed as far as the last line a frame draws, with the parser position kept beside the block. The frame a fenced answer arrives on costs a screenful of grammar, and a block drawn from its bottom costs the whole block once: on a tall pane either is a first touch outside I9, the shape the #51 bullet in §10 records for a hunk. Ruled 2026-09-30, reader. User-facing: an answer the agent quotes as code no longer freezes the pane for the length of the block when it arrives. Gated by `crates/vigia/tests/budgets.rs::arrival_holds_the_budget` on the ordinary pane and by `crates/vigia/tests/notes.rs::parse_follows_the_window` and `rows_follow_the_window` on the counters. The numbers and the alternatives are in the ledger. > - **I2** is two numbers because re-diffing (`gix`, Phase 1) and re-highlighting (`syntect`, Phase 2) have different dependencies and phases. The 18.58ms-vs-3.27ms measurement that forced the split is in the ledger. > - **I8** no longer names `SIGINT` for Ctrl-C, because raw mode makes Ctrl-C a key event, never a signal. The externally delivered `SIGINT` is **covered since 2026-08-08** ([#24](https://github.com/breferrari/vigia/issues/24)). It was taken for both tier-1 platforms in one issue, because the single-task version was Unix-only and [#16](https://github.com/breferrari/vigia/issues/16) had already rejected a guarantee that means different things on different targets. Measurement made that affordable: neither half adds a crate to any graph. The row's exclusions now read the same on both platforms. > - **I3**'s scheduled soak runs 30 minutes a week on Linux over the 20 x 200 fixture. The fixture settles in about thirty seconds, so the window measures the plateau. *Ruled 2026-09-29, reader.* The full 24h runs by `workflow_dispatch` or locally. The sample count is fixed, so the statistic is computed the same way at any window. diff --git a/crates/vigia-core/src/highlight.rs b/crates/vigia-core/src/highlight.rs index 56a0d1a1..63b10f46 100644 --- a/crates/vigia-core/src/highlight.rs +++ b/crates/vigia-core/src/highlight.rs @@ -116,9 +116,13 @@ pub struct HighlightStats { pub bytes: u64, /// Hunks dropped because they left the viewport. pub evicted: u64, - /// Blocks quoted in a note's answer that were parsed. One per block per - /// write, since the parse outlives every frame that draws the same text. + /// Blocks quoted in a note's answer whose parse began. One per block per + /// write while the note's line is on the pane, since the parse outlives + /// every frame that asks for it. pub quoted: u64, + /// Lines of those blocks run through the parser, a subset of `lines`: a + /// block is parsed as far as the last line a frame draws. + pub quoted_lines: u64, } /// Scope prefixes and what each one means, most specific first. @@ -290,13 +294,8 @@ pub struct Uncompiled { pub lines: Vec, } -/// One block quoted in a note's answer, parsed whole and kept between frames. -/// -/// A hunk is parsed forward only and rewound, because it can be a thousand lines -/// and one of them changes before every frame. A quote is neither: the agent -/// writes an answer once and rewrites it whole, so the checkpoints and the -/// rewind buy nothing and the block is parsed in one go the frame its content -/// first appears. +/// One block quoted in a note's answer, kept between frames and filled forward +/// to the last line a frame draws. Never rewound: an answer is rewritten whole. struct Quote { /// The note this block belongs to. id: String, @@ -311,7 +310,11 @@ struct Quote { /// The grammar this block drew plain waiting for, where nothing had compiled /// one when it was parsed. deferred: Option, - /// Spans per line, covering each line's bytes exactly. + /// Where the grammar stands after `lines`, so the next line asked for parses + /// from there. `None` where the block draws plain. + parse: Option, + /// Spans per line, covering each line's bytes exactly, filled forward to the + /// last line a frame drew and never rebuilt. lines: Vec>, } @@ -940,17 +943,77 @@ impl Highlighter { path: &str, lines: &[String], ) -> &[Vec] { + self.quoted_to(id, ordinal, token, path, lines, lines.len()) + } + + /// Whether the block at `at` waits on a grammar the warmer has compiled + /// since, or on one its fence no longer names: either way a draw would + /// rebuild it. + fn stale(&self, at: usize, token: Option<&str>) -> bool { + let Some(scope) = self.quotes[at].deferred else { + return false; + }; + let named = token.and_then(|token| self.syntaxes.find_syntax_by_token(token)); + compiled(scope, self.attempted.as_deref()) + || named.is_some_and(|syntax| syntax.scope != scope) + } + + /// The demand for a block's grammar, renewed every frame the block still + /// waits on one: the pass clears it, so one raised where the parse happens + /// is gone before a warmer is served. + fn demand(&mut self, slot: usize, token: Option<&str>, path: &str, lines: &[String]) { + if let Some(scope) = self.quotes[slot].deferred + && !lines.is_empty() + && self.demanded.insert(scope) + { + self.uncompiled.push(Uncompiled { + token: token.map(str::to_owned), + path: path.to_owned(), + lines: lines.to_vec(), + }); + } + } + + /// Keeps a block's parse without asking for a line. A block behind the + /// window costs the frame a lookup, a lock while it waits on a grammar, and + /// one rebuild on the frame the grammar arrives; a rewrite of its text is + /// seen when it is next drawn. + fn hold( + &mut self, + id: &str, + ordinal: usize, + token: Option<&str>, + path: &str, + lines: &[String], + ) { + let found = self + .quotes + .iter() + .position(|quote| quote.ordinal == ordinal && quote.id == id); + let Some(at) = found.filter(|&at| !self.stale(at, token)) else { + self.quoted_to(id, ordinal, token, path, lines, 0); + return; + }; + self.demand(at, token, path, lines); + self.quotes[at].live = true; + } + + fn quoted_to( + &mut self, + id: &str, + ordinal: usize, + token: Option<&str>, + path: &str, + lines: &[String], + upto: usize, + ) -> &[Vec] { + let upto = upto.min(lines.len()); let digest = digest_of(token, lines); let found = self .quotes .iter() .position(|quote| quote.ordinal == ordinal && quote.id == id); - let hit = found.filter(|&at| { - let thaw = self.quotes[at] - .deferred - .is_some_and(|scope| compiled(scope, self.attempted.as_deref())); - self.quotes[at].digest == digest && !thaw - }); + let hit = found.filter(|&at| self.quotes[at].digest == digest && !self.stale(at, token)); let slot = match hit { Some(at) => at, @@ -960,7 +1023,6 @@ impl Highlighter { // `&mut self` alone the borrow checker sees one whole thing. let Self { syntaxes, - table, quotes, stats, attempted, @@ -985,30 +1047,12 @@ impl Highlighter { let deferred = syntax .map(|syntax| syntax.scope) .filter(|scope| !compiled(*scope, attempted.as_deref())); - let parsed = syntax.filter(|_| deferred.is_none()).map(|syntax| { - // One side, not two: a quoted block is one stream of text - // and has no index side to keep apart from a working-tree one. - let mut side = Side::new(syntax); - let mut buf = String::new(); - lines - .iter() - .map(|text| { - stats.lines += 1; - stats.bytes += text.len() as u64; - buf.clear(); - buf.push_str(text); - buf.push('\n'); - side.spans(&buf, text, syntaxes, table) - }) - .collect() - }); - let filled = match parsed { - Some(filled) => { - stats.quoted += 1; - filled - } - None => lines.iter().map(|text| plain(text.len())).collect(), - }; + // One side, not two: a quoted block is one stream of text and + // has no index side to keep apart from a working-tree one. + let parse = syntax.filter(|_| deferred.is_none()).map(Side::new); + if parse.is_some() { + stats.quoted += 1; + } let built = Quote { id: id.to_owned(), ordinal, @@ -1016,7 +1060,8 @@ impl Highlighter { live: false, missed: false, deferred, - lines: filled, + parse, + lines: Vec::new(), }; // Over the stale entry where there was one, so a note whose answer // the agent keeps rewriting does not walk down the vector. @@ -1033,23 +1078,38 @@ impl Highlighter { } }; - // The demand, renewed every frame it is still true, which is `spans`' - // own rule and not a flourish: every frame after the first hits the - // cache, so a demand raised only where the parse happens is raised once - // and cleared by the next pass before anything has offered it a warmer. - // Shared with the paths' own dedup, so a grammar both want is warmed by - // whichever the shell serves first rather than twice. - if let Some(scope) = self.quotes[slot].deferred - && !lines.is_empty() - && self.demanded.insert(scope) + // Filled forward to the last line the frame draws and no further, so the + // frame a block arrives on costs a screenful of it. { - self.uncompiled.push(Uncompiled { - token: token.map(str::to_owned), - path: path.to_owned(), - lines: lines.to_vec(), - }); + let Self { + syntaxes, + table, + quotes, + stats, + .. + } = self; + let quote = &mut quotes[slot]; + let mut buf = String::new(); + while quote.lines.len() < upto { + let text = &lines[quote.lines.len()]; + let spans = match &mut quote.parse { + Some(side) => { + stats.lines += 1; + stats.quoted_lines += 1; + stats.bytes += text.len() as u64; + buf.clear(); + buf.push_str(text); + buf.push('\n'); + side.spans(&buf, text, syntaxes, table) + } + None => plain(text.len()), + }; + quote.lines.push(spans); + } } + self.demand(slot, token, path, lines); + self.quotes[slot].live = true; &self.quotes[slot].lines } @@ -1111,6 +1171,34 @@ impl Pass<'_> { self.highlighter.quoted(id, ordinal, token, path, lines) } + /// Keeps a block's parse between frames without asking for a line, and its + /// demand for a grammar standing. A rewrite is seen when the block is drawn. + pub fn hold( + &mut self, + id: &str, + ordinal: usize, + token: Option<&str>, + path: &str, + lines: &[String], + ) { + self.highlighter.hold(id, ordinal, token, path, lines); + } + + /// [`Self::quoted`] as far as line `upto` and no further, so a frame parses + /// the lines it draws. What comes back covers at least those. + pub fn quoted_to( + &mut self, + id: &str, + ordinal: usize, + token: Option<&str>, + path: &str, + lines: &[String], + upto: usize, + ) -> &[Vec] { + self.highlighter + .quoted_to(id, ordinal, token, path, lines, upto) + } + /// Counters for what the highlighter has done, mid-pass. pub fn stats(&self) -> HighlightStats { self.highlighter.stats() diff --git a/crates/vigia-core/tests/support/mod.rs b/crates/vigia-core/tests/support/mod.rs index c0e8ce77..155e51a0 100644 --- a/crates/vigia-core/tests/support/mod.rs +++ b/crates/vigia-core/tests/support/mod.rs @@ -610,6 +610,7 @@ pub fn highlight_delta(before: HighlightStats, after: HighlightStats) -> Highlig bytes: after.bytes - before.bytes, evicted: after.evicted - before.evicted, quoted: after.quoted - before.quoted, + quoted_lines: after.quoted_lines - before.quoted_lines, } } diff --git a/crates/vigia-core/tests/warm.rs b/crates/vigia-core/tests/warm.rs index 2b991e44..acd1555e 100644 --- a/crates/vigia-core/tests/warm.rs +++ b/crates/vigia-core/tests/warm.rs @@ -1365,3 +1365,69 @@ fn two_blocks_of_one_language_warm_once() { .expect("the warmer thread"); assert_eq!(report.warmed, 1, "one grammar was compiled three times"); } + +/// A block held behind the window keeps asking for its grammar until a warm +/// answers, and is rebuilt rather than held once one has. +#[test] +fn held_demand() { + let mut highlighter = Highlighter::new(); + let lines = vec![ + "if [ -f /etc/hosts ]; then".to_owned(), + " exit 0".to_owned(), + ]; + { + let mut pass = highlighter.pass(); + let _ = pass.quoted("n1", 0, Some("sh"), "src/mod_0.rs", &lines); + } + let demand = highlighter.uncompiled().to_vec(); + assert_eq!(demand.len(), 1, "the block raised no demand: {demand:?}"); + let begun = highlighter.stats().quoted; + + // Behind the window for two frames: held, not drawn, and not rebuilt. + for _ in 0..2 { + let mut pass = highlighter.pass(); + pass.hold("n1", 0, Some("sh"), "src/mod_0.rs", &lines); + drop(pass); + assert_eq!( + highlighter.uncompiled().to_vec(), + demand, + "a held block stopped asking for its grammar" + ); + } + assert_eq!( + highlighter.stats().quoted, + begun, + "a held block waiting on its grammar was rebuilt rather than held" + ); + + // Warmed while held: the next hold rebuilds the block rather than keeping it + // deferred, so the demand ends and a draw finds colour. + highlighter + .warm_quoted(demand, None) + .join() + .expect("the warmer thread"); + { + let mut pass = highlighter.pass(); + pass.hold("n1", 0, Some("sh"), "src/mod_0.rs", &lines); + } + assert!( + highlighter.uncompiled().is_empty(), + "a held block kept demanding a grammar already compiled: {:?}", + highlighter.uncompiled() + ); + assert_eq!( + highlighter.stats().quoted, + begun + 1, + "the warm frame's hold did not rebuild the block once" + ); + let mut pass = highlighter.pass(); + let coloured = pass + .quoted("n1", 0, Some("sh"), "src/mod_0.rs", &lines) + .iter() + .flat_map(|line| line.iter().map(|span| span.class)) + .any(|class| class != Class::Plain); + assert!( + coloured, + "the block drew plain after its grammar was warmed" + ); +} diff --git a/crates/vigia/src/quote.rs b/crates/vigia/src/quote.rs index 529ecfcb..b428a1e5 100644 --- a/crates/vigia/src/quote.rs +++ b/crates/vigia/src/quote.rs @@ -209,39 +209,107 @@ pub struct CodeRow { pub indent: usize, } -/// A quoted block's lines as rows of at most `room` columns, with `spans` from -/// the grammar covering each line. +/// Where `line` breaks to fit `room` columns, or nowhere with no room to break +/// into, in which case the row is the line and the painter clips it. There is +/// never a cut per byte, so the line's own length bounds the walk without +/// inventing a number for it. +fn cuts_of(line: &str, room: usize) -> Vec { + if room == 0 { + return Vec::new(); + } + crate::render::breaks_of(line, room, line.len()) +} + +/// Rows a quoted block takes at `room` columns, counted from its breaks alone. #[must_use] -pub fn code_rows(lines: &[String], spans: &[Vec], room: usize) -> Vec { - let mut out = Vec::new(); +pub fn code_row_count(lines: &[String], room: usize) -> usize { + lines.iter().map(|line| 1 + cuts_of(line, room).len()).sum() +} + +/// One row of a quoted block before its runs are known: the line it is a piece +/// of, the piece, and the columns a continuation stands in by. +struct Piece { + line: usize, + range: Range, + indent: usize, +} + +/// The rows a window of a quoted block draws, before their runs are known. +pub struct Window { + pieces: Vec, + /// Rows the walk passed, which is the block's whole count where the window + /// did not fill. + pub walked: usize, + /// Lines the window reaches, which is how far the grammar has to be asked. + pub end: usize, +} + +/// The rows of a quoted block from the `skip`th, at most `take` of them, walked +/// from the first line that reaches the window and no earlier one. +#[must_use] +pub fn code_window(lines: &[String], room: usize, skip: usize, take: usize) -> Window { + let mut pieces = Vec::new(); + let mut passed = 0usize; for (at, line) in lines.iter().enumerate() { - let runs = runs_of(spans.get(at).map_or(&[][..], Vec::as_slice), line.len()); - // No room to break into, so the row is the line and the painter clips it. - if room == 0 { - out.push(CodeRow { - text: line.clone(), - runs, - indent: 0, - }); + if pieces.len() >= take { + break; + } + let cuts = cuts_of(line, room); + // Passed over whole, so its indent is never measured. + if passed + cuts.len() < skip { + passed += cuts.len() + 1; continue; } - // There is never a cut per byte, so the line's own length bounds the walk - // without inventing a number for it. - let cuts = crate::render::breaks_of(line, room, line.len()); let indent = crate::render::indent_of(line, room); let mut start = 0usize; for cut in cuts.iter().copied().chain(std::iter::once(line.len())) { - out.push(CodeRow { - text: line[start..cut].to_owned(), - runs: merged(rebase(&runs, &(start..cut))), + if passed < skip { + passed += 1; + start = cut; + continue; + } + if pieces.len() >= take { + break; + } + pieces.push(Piece { + line: at, + range: start..cut, // Every row but the one the line starts on, which is what // `start` already says. indent: if start > 0 { indent } else { 0 }, }); + passed += 1; start = cut; } } - out + let end = pieces.last().map_or(0, |piece| piece.line + 1); + Window { + pieces, + walked: passed, + end, + } +} + +/// [`code_window`]'s rows with their runs from `spans`, plain where `spans` +/// stops short of a line. +#[must_use] +pub fn code_rows_of(lines: &[String], spans: &[Vec], window: Window) -> Vec { + window + .pieces + .into_iter() + .map(|piece| { + let line = &lines[piece.line]; + let runs = runs_of( + spans.get(piece.line).map_or(&[][..], Vec::as_slice), + line.len(), + ); + CodeRow { + text: line[piece.range.clone()].to_owned(), + runs: merged(rebase(&runs, &piece.range)), + indent: piece.indent, + } + }) + .collect() } /// One run per span, and the whole line plain where the highlighter gave none. diff --git a/crates/vigia/src/view.rs b/crates/vigia/src/view.rs index 39df4c49..85b5c5ce 100644 --- a/crates/vigia/src/view.rs +++ b/crates/vigia/src/view.rs @@ -527,6 +527,8 @@ struct Pin { /// Index into the logical rows. row: usize, id: String, + /// The file the note is on, whose grammar a block with no fence name takes. + path: String, body: String, reply: Option, /// The word on the last body row. @@ -543,9 +545,8 @@ struct Pin { resolved: bool, } -/// Resolved where the notes are placed rather than in [`Pin::rows`], which the -/// layout calls more than once a frame while it clamps: a quoted block's grammar -/// is asked for on the frame the agent writes it and on no other. +/// Resolved where the notes are placed. A quoted block is parsed where the rows +/// are kept, in [`Answer::rows_in`], as far as the last line the clamp draws. #[derive(Debug, Clone)] struct Answer { /// What the agent wrote, whole. B20 sends that rather than what was drawn, @@ -554,76 +555,163 @@ struct Answer { parts: Vec, } -/// A [`crate::quote::Chunk`] once its block has been through the highlighter. +/// A [`crate::quote::Chunk`], its block numbered among the answer's so the +/// highlighter keeps one parse per block. #[derive(Debug, Clone)] enum Part { Prose(String), - /// Spans are empty where no grammar answered. Code { + token: Option, + ordinal: usize, lines: Vec, - spans: Vec>, }, } impl Answer { - fn rows(&self, room: usize) -> Vec { - let mut out = Vec::new(); + /// Rows the answer takes at `room` columns, counted without building one. + fn count(&self, room: usize) -> usize { + self.parts + .iter() + .map(|part| match part { + Part::Prose(text) => prose_count(text, room, true), + Part::Code { lines, .. } => crate::quote::code_row_count(lines, room), + }) + .sum() + } + + /// Keeps every block's parse, so a block whose rows are off the window this + /// frame is not swept, and a grammar it waits for is still demanded. + fn keep(&self, pass: &mut Pass<'_>, id: &str, path: &str) { for part in &self.parts { - match part { - Part::Prose(text) => out.extend(prose_runs(text, room)), - Part::Code { lines, spans } => { - out.extend(crate::quote::code_rows(lines, spans, room)); - } + if let Part::Code { + token, + ordinal, + lines, + } = part + { + pass.hold(id, *ordinal, token.as_deref(), path, lines); } } - // A resolved note draws its answer and nothing else, so an answer with - // no rows of its own is the whole note gone from the pane. - if out.is_empty() { - out.push(CodeRow { - text: String::new(), - runs: Vec::new(), - indent: 0, - }); + } + + /// The answer's rows from the `skip`th, at most `take` of them. A quoted + /// block in the window has its grammar asked as far as its last drawn line, + /// and draws plain with no pass to ask. + fn rows_in( + &self, + room: usize, + skip: usize, + take: usize, + mut pass: Option<&mut Pass<'_>>, + id: &str, + path: &str, + ) -> Vec { + let mut out = Vec::new(); + // Rows walked so far, which is every row of a part the window is past. + let mut passed = 0usize; + for part in &self.parts { + if out.len() >= take { + break; + } + let (from, want) = (skip.saturating_sub(passed), take - out.len()); + passed += match part { + Part::Prose(text) => { + let (rows, walked) = prose_rows_in(text, room, true, from, want); + out.extend(rows); + walked + } + Part::Code { + token, + ordinal, + lines, + } => { + let window = crate::quote::code_window(lines, room, from, want); + let spans: &[Vec] = match pass.as_deref_mut() { + Some(pass) if window.end > 0 => { + pass.quoted_to(id, *ordinal, token.as_deref(), path, lines, window.end) + } + _ => &[], + }; + let walked = window.walked; + out.extend(crate::quote::code_rows_of(lines, spans, window)); + walked + } + }; } out } } -/// [`prose_rows`] with each paragraph's backticked runs unwrapped and marked. -/// Beside it rather than replacing it: the reader's own words go through that -/// one, and a backtick they typed is a character they typed. -fn prose_runs(text: &str, room: usize) -> Vec { - crate::quote::lines_of(text) - .flat_map(|paragraph| { - let paragraph = crate::render::detabbed(paragraph); - let (paragraph, runs) = crate::quote::inline(¶graph); - prose_pieces(¶graph, room) - .into_iter() - .map(|piece| CodeRow { - text: paragraph[piece.clone()].to_owned(), - runs: crate::quote::rebase(&runs, &piece), - indent: 0, - }) - .collect::>() - }) - .collect() +/// Every row `text` breaks into at `room` columns, in order, as the paragraph +/// it is a piece of, the piece, and the paragraph's runs where `inlined` +/// unwrapped its backticks. `each` answers whether to go on, so a count and a +/// window are one walk stopped at different rows. +fn prose_walk( + text: &str, + room: usize, + inlined: bool, + mut each: impl FnMut(&str, std::ops::Range, &[Run]) -> bool, +) { + for paragraph in crate::quote::lines_of(text) { + let paragraph = crate::render::detabbed(paragraph); + let (paragraph, runs) = if inlined { + crate::quote::inline(¶graph) + } else { + (std::borrow::Cow::Borrowed(&*paragraph), Vec::new()) + }; + for piece in prose_pieces(¶graph, room) { + if !each(¶graph, piece, &runs) { + return; + } + } + } } -/// `text` in rows of at most `room` columns, broken the way prose breaks: at -/// every newline, then at the last blank that fits, and inside a word only when -/// the word alone is wider than the row. The blank a row breaks on is drawn on -/// neither row. Empty text is one empty row, so a note with no body still has a -/// row for its word. -fn prose_rows(text: &str, room: usize) -> Vec { - crate::quote::lines_of(text) - .flat_map(|paragraph| { - let paragraph = crate::render::detabbed(paragraph); - prose_pieces(¶graph, room) - .into_iter() - .map(|piece| paragraph[piece].to_owned()) - .collect::>() - }) - .collect() +/// Rows `text` takes at `room` columns. +fn prose_count(text: &str, room: usize, inlined: bool) -> usize { + let mut rows = 0usize; + prose_walk(text, room, inlined, |_, _, _| { + rows += 1; + true + }); + rows +} + +/// Columns the last row of `text` takes at `room` columns. +fn last_prose_width(text: &str, room: usize) -> usize { + let mut last = 0usize; + prose_walk(text, room, false, |paragraph, piece, _| { + last = crate::render::width_of(¶graph[piece]); + true + }); + last +} + +/// `text` from the `skip`th row, at most `take` rows, and how many rows the +/// walk passed, which is every row where the window did not fill. The runs are +/// the paragraph's backticked stretches where `inlined`, and none for the +/// reader's own words: a backtick they typed is a character they typed. +fn prose_rows_in( + text: &str, + room: usize, + inlined: bool, + skip: usize, + take: usize, +) -> (Vec, usize) { + let mut out = Vec::new(); + let mut at = 0usize; + prose_walk(text, room, inlined, |paragraph, piece, runs| { + if at >= skip { + out.push(CodeRow { + text: paragraph[piece.clone()].to_owned(), + runs: crate::quote::rebase(runs, &piece), + indent: 0, + }); + } + at += 1; + out.len() < take + }); + (out, at) } /// Where one paragraph breaks into rows of at most `room` columns, as byte @@ -760,53 +848,182 @@ fn answer_row(rows: &mut Vec, pin: &Pin, lead: NoteLead, drawn: CodeRow, la }); } +/// The widths a note draws at under a content width of `content`: the room +/// beside the lead, whether the body is boxed, and the room inside the box. +fn note_widths(word: &str, content: usize) -> (usize, bool, usize) { + ( + content.saturating_sub(NOTE_LEAD), + content >= crate::render::edge_width(word), + content.saturating_sub(BOX_FRAME), + ) +} + +/// One stretch of a note's rows, with how many it has. +#[derive(Debug, Clone, Copy)] +enum Section { + Top, + Body(usize), + Bottom, + /// The narrow rung's body, and whether the word takes a row of its own. + Bar { + rows: usize, + own: bool, + }, + Reply(usize), +} + +impl Section { + fn rows(self) -> usize { + match self { + Self::Top | Self::Bottom => 1, + Self::Body(rows) | Self::Reply(rows) => rows, + Self::Bar { rows, own } => rows + usize::from(own), + } + } + + /// Rows a note's sections take together. + fn total(sections: &[Self]) -> usize { + sections.iter().map(|section| section.rows()).sum() + } +} + +/// What the window keeps of a section of `count` rows that starts `passed` rows +/// in, as the section's own skip and take, or `None` where it keeps nothing: +/// `have` rows are built already. +fn section_window( + passed: usize, + count: usize, + skip: usize, + take: usize, + have: usize, +) -> Option<(usize, usize)> { + (passed + count > skip && have < take).then(|| (skip.saturating_sub(passed), take - have)) +} + impl Pin { - /// The display rows this note takes under a content width of `content`. - fn rows(&self, content: usize) -> Vec { - let room = content.saturating_sub(NOTE_LEAD); - let boxed = content >= crate::render::edge_width(self.word); - let inner = content.saturating_sub(BOX_FRAME); - let pieces = |text: &str| prose_rows(text, room); - let mut rows = Vec::new(); - if !self.resolved || self.reply.is_none() { + /// Whether the word takes a row of its own under the bar: the last body row + /// has no room for it, and the reader's words are never cut to fit it. + fn word_takes_a_row(&self, room: usize) -> bool { + let last = last_prose_width(&self.body, room); + let gap = usize::from(last > 0); + self.word.len() <= room && last + gap + self.word.len() > room + } + + /// Whether the body is drawn, which is every note but a resolved one that + /// carries an answer. + fn draws_body(&self) -> bool { + !self.resolved || self.reply.is_none() + } + + /// The note's rows as sections in draw order, counted without building a + /// row. The count sums them and the build walks them. + fn sections(&self, content: usize) -> Vec
{ + let (room, boxed, inner) = note_widths(self.word, content); + let mut out = Vec::new(); + if self.draws_body() { if boxed { - row(&mut rows, self, NoteLead::Top, String::new(), false); - for text in prose_rows(&self.body, inner) { - row(&mut rows, self, NoteLead::Body, text, false); - } - row( - &mut rows, - self, - NoteLead::Bottom, - self.word.to_owned(), - true, - ); + out.extend([ + Section::Top, + Section::Body(prose_count(&self.body, inner, false)), + Section::Bottom, + ]); } else { - let mut body = pieces(&self.body); - // The word shares the last row, or takes one of its own where - // that row has none: the reader's words are never cut to fit it. - let last = body - .last() - .map_or(0, |piece| crate::render::width_of(piece)); - let gap = usize::from(last > 0); - if self.word.len() <= room && last + gap + self.word.len() > room { - body.push(String::new()); - } - let count = body.len(); - for (piece, text) in body.into_iter().enumerate() { - row(&mut rows, self, NoteLead::Bar, text, piece + 1 == count); - } + out.push(Section::Bar { + rows: prose_count(&self.body, room, false), + own: self.word_takes_a_row(room), + }); } } if let Some(reply) = &self.reply { - for (piece, drawn) in reply.rows(room).into_iter().enumerate() { - let lead = if piece == 0 { - NoteLead::Reply - } else { - NoteLead::Blank - }; - answer_row(&mut rows, self, lead, drawn, false); + out.push(Section::Reply(reply.count(room))); + } + out + } + + /// The display rows this note takes under a content width of `content`. + fn count(&self, content: usize) -> usize { + Section::total(&self.sections(content)) + } + + /// The display rows of this note from the `skip`th, at most `take` of them, + /// walking `sections` as [`Self::sections`] gave them. + fn rows_in( + &self, + content: usize, + sections: &[Section], + skip: usize, + take: usize, + mut pass: Option<&mut Pass<'_>>, + ) -> Vec { + let (room, _, inner) = note_widths(self.word, content); + let mut rows = Vec::new(); + let mut passed = 0usize; + for §ion in sections { + if let Some((from, want)) = + section_window(passed, section.rows(), skip, take, rows.len()) + { + match section { + Section::Top => row(&mut rows, self, NoteLead::Top, String::new(), false), + Section::Bottom => { + row( + &mut rows, + self, + NoteLead::Bottom, + self.word.to_owned(), + true, + ); + } + Section::Body(_) => { + for piece in prose_rows_in(&self.body, inner, false, from, want).0 { + row(&mut rows, self, NoteLead::Body, piece.text, false); + } + } + Section::Bar { rows: body, own } => { + let mut texts: Vec = + prose_rows_in(&self.body, room, false, from, want) + .0 + .into_iter() + .map(|piece| piece.text) + .collect(); + // The word's own row is the one past the body's last. + if own && texts.len() < want { + texts.push(String::new()); + } + let count = body + usize::from(own); + for (piece, text) in texts.into_iter().enumerate() { + row( + &mut rows, + self, + NoteLead::Bar, + text, + from + piece + 1 == count, + ); + } + } + Section::Reply(_) => { + let Some(reply) = &self.reply else { + continue; + }; + let drawn = reply.rows_in( + room, + from, + want, + pass.as_deref_mut(), + &self.id, + &self.path, + ); + for (piece, drawn) in drawn.into_iter().enumerate() { + let lead = if from + piece == 0 { + NoteLead::Reply + } else { + NoteLead::Blank + }; + answer_row(&mut rows, self, lead, drawn, false); + } + } + } } + passed += section.rows(); } rows } @@ -895,27 +1112,33 @@ fn notes_at<'n>( held } -/// `reply` split into words and quoted blocks, each block through the grammar -/// its fence names or the note's own file's. The ordinal is the block's place +/// `reply` split into words and quoted blocks. The ordinal is the block's place /// among this answer's, so an answer can quote twice and each half keep a parse. -fn answer_of(note: &Note, reply: &str, mut highlighter: Option<&mut Pass<'_>>) -> Answer { +/// Nothing is parsed here: the rows the clamp keeps ask for their grammar. +fn answer_of(reply: &str) -> Answer { let mut parts = Vec::new(); let mut ordinal = 0usize; for chunk in crate::quote::chunks(reply) { match chunk { Chunk::Prose(text) => parts.push(Part::Prose(text)), Chunk::Code { token, lines } => { - let spans = match highlighter.as_deref_mut() { - Some(pass) => pass - .quoted(¬e.id, ordinal, token.as_deref(), ¬e.path, &lines) - .to_vec(), - None => Vec::new(), - }; + parts.push(Part::Code { + token, + ordinal, + lines, + }); ordinal += 1; - parts.push(Part::Code { lines, spans }); } } } + // A resolved note draws its answer and nothing else, so an answer with no + // rows of its own draws one empty row rather than leaving the pane. + if parts + .iter() + .all(|part| matches!(part, Part::Code { lines, .. } if lines.is_empty())) + { + parts.push(Part::Prose(String::new())); + } Answer { text: reply.to_owned(), parts, @@ -1160,6 +1383,9 @@ pub struct View { pub read: usize, /// [`FileEntry`] values built for the record rather than for a row. pub recorded: usize, + /// Note rows built for this screen, which the window bounds: a note taller + /// than the pane is counted whole and built only where the clamp keeps it. + pub built: usize, /// The busiest bucket any tracked file holds, which every sparkline on this /// screen is drawn against. pub scale: Scale, @@ -1518,6 +1744,7 @@ impl View { total_rows: 0, churn: None, rows_above: 0, + built: 0, files, hidden: frame.hidden(), // Until the walk below runs, the request is passed through with only its @@ -1803,7 +2030,15 @@ impl View { && (anchored || single || (view.top.row > 0 && !view.landed)) && view.top != floor); - let trimmed = view.wrap_rows(width, wrap, height, at_bottom, &walked, rows); + let trimmed = view.wrap_rows( + width, + wrap, + height, + at_bottom, + &walked, + rows, + highlight.then_some(&mut highlighter), + ); view.ended &= index >= stop && consumed; // After the walk, because only the walk knows where the diff landed. @@ -2036,10 +2271,7 @@ impl View { crate::render::content_width(gutter, width) }; let mut under: usize = if drawn { - // Counted by building, where a diff line is counted by its breaks - // alone: a note's row shape follows its text, its state and its width - // at once, and a second expression of it is one the clamp can differ on. - walked.pins.iter().map(|pin| pin.rows(content).len()).sum() + walked.pins.iter().map(|pin| pin.count(content)).sum() } else { 0 }; @@ -2066,6 +2298,7 @@ impl View { /// the bottom clamp trimmed off the front: [`Self::top`] still names the first. /// The display rows are a line's continuations and, when `drawn`, the rows of /// the notes pinned under it, and both are counted in the same unit. + #[allow(clippy::too_many_arguments)] fn wrap_rows( &mut self, width: usize, @@ -2074,6 +2307,7 @@ impl View { at_bottom: bool, walked: &Walked, drawn: bool, + mut pass: Option<&mut Pass<'_>>, ) -> usize { let pins = &walked.pins; // Only where a width was passed, so a caller that named none leaves @@ -2101,25 +2335,49 @@ impl View { _ => Vec::new(), }) .collect(); - // Built once, so the clamp and the emit below agree on their count. - let mut under: Vec> = vec![Vec::new(); breaks.len()]; - if drawn { - for pin in pins { - under[pin.row].extend(pin.rows(content)); - } + // Counted here and built below, only as far as the window reaches: a + // note can be taller than the pane, and the count is what the clamp + // needs of the rows it drops. + let sections: Vec> = if drawn { + pins.iter().map(|pin| pin.sections(content)).collect() + } else { + Vec::new() + }; + let counts: Vec = sections + .iter() + .map(|sections| Section::total(sections)) + .collect(); + let mut under: Vec = vec![0; breaks.len()]; + // Which pins sit under each logical row, in the order they were placed. + let mut by_row: Vec> = vec![Vec::new(); breaks.len()]; + for (i, (pin, count)) in pins.iter().zip(&counts).enumerate() { + under[pin.row] += count; + by_row[pin.row].push(i); } // The box stands first under its line, whatever else is pinned there, - // and whether or not the rows are shown: a mode is not a toggle. + // and whether or not the rows are shown: a mode is not a toggle. Built + // whole: it is the reader's draft. let mut boxed_rows = 0usize; let mut caret_at = 0usize; + let mut box_built: Vec = Vec::new(); if let Some(boxed) = &walked.boxed { let (rows, caret) = box_rows(boxed, content); boxed_rows = rows.len(); caret_at = caret; - under[boxed.row].splice(0..0, rows); + under[boxed.row] += boxed_rows; + box_built = rows; } - let cost = |at: usize| breaks[at].len() + 1 + under[at].len(); + let cost = |at: usize| breaks[at].len() + 1 + under[at]; let total: usize = (0..breaks.len()).map(cost).sum(); + // Every pinned answer's blocks, whether the window reaches them or not: + // the pass sweeps what nothing asks for. + if drawn && let Some(pass) = pass.as_deref_mut() { + for pin in pins { + if let Some(reply) = &pin.reply { + reply.keep(pass, &pin.id, &pin.path); + } + } + } // Nothing on this screen wraps and nothing sits under a row, so the rows // are the display rows and every index already names one. @@ -2201,6 +2459,7 @@ impl View { // The display row each logical row's own first piece landed on, so the // marks and the segments can be carried across the split. let mut landed: Vec> = vec![None; breaks.len()]; + let mut built = 0usize; for (at, row) in self.rows.drain(..).enumerate() { if at < from { continue; @@ -2283,14 +2542,32 @@ impl View { } } } - for note_row in std::mem::take(&mut under[at]) { + // The rows under this line, as far as the window reaches: the box + // whole and then skipped like any row, each note from the first row + // it keeps. + let mut pending: Vec = Vec::new(); + if walked.boxed.as_ref().is_some_and(|boxed| boxed.row == at) { + let mut rows = std::mem::take(&mut box_built); + let from = skip.min(rows.len()); + skip -= from; + pending.extend(rows.drain(from..)); + } + for &i in &by_row[at] { + let count = counts[i]; + let from = skip.min(count); + skip -= from; + let want = height.saturating_sub(out.len() + pending.len()); + if from < count && want > 0 { + let rows = + pins[i].rows_in(content, §ions[i], from, want, pass.as_deref_mut()); + built += rows.len(); + pending.extend(rows); + } + } + for note_row in pending { if out.len() >= height { break; } - if skip > 0 { - skip -= 1; - continue; - } // Recorded as a wrapped line's is: the rows hold prose broken at // blanks drawn on neither side, so rejoining them spaces it wrong. if !continues(¬e_row, out.last()) @@ -2309,6 +2586,7 @@ impl View { } self.rows = out; self.whole = whole; + self.built = built; self.notes.marked = pins .iter() .filter(|pin| pin.marks) @@ -2600,7 +2878,7 @@ impl View { self.notes.segments.push((first, diff.path.clone(), origin)); } if !notes.is_empty() { - pin(pins, ¬es, diff, heading, &placed, highlighter); + pin(pins, ¬es, diff, heading, &placed); } if let Some(stand_in) = boxed { *box_pin = place_box(stand_in, diff, heading, &placed); @@ -2762,7 +3040,6 @@ fn pin( diff: &FileDiff, heading: Option, placed: &[(Side, u32, usize)], - mut highlighter: Option<&mut Pass<'_>>, ) { let mut rows = Rows::of(diff); for note in notes { @@ -2782,11 +3059,9 @@ fn pin( pins.push(Pin { row, id: note.id.clone(), + path: note.path.clone(), body: note.body.clone(), - reply: note - .reply - .as_deref() - .map(|reply| answer_of(note, reply, highlighter.as_deref_mut())), + reply: note.reply.as_deref().map(answer_of), word, faded, marks, @@ -3341,4 +3616,64 @@ mod tests { 0 ); } + + /// Every shape a note draws in: boxed and bar bodies of three lengths, + /// with and without an answer, resolved and not, and the two answers with + /// no rows of their own. + fn every_pin() -> Vec { + let long = "a note long enough to wrap at every width this walks, several \ + times over, with more words than one row of forty holds"; + let answer = "words before\n```rust\nfn a() {}\nfn b() { let value = 1; }\n```\n\ + and words after, more than one row of them at forty columns"; + let mut out = Vec::new(); + for resolved in [false, true] { + for reply in [None, Some(answer), Some(""), Some("```\n```")] { + for body in ["", "short", long] { + out.push(Pin { + row: 0, + id: "n".to_owned(), + path: "src/a.rs".to_owned(), + body: body.to_owned(), + reply: reply.map(answer_of), + word: "sent", + faded: false, + marks: true, + from: None, + resolved, + }); + } + } + } + out + } + + #[test] + fn count_matches_build() { + for pin in every_pin() { + for content in [0, 6, 9, 12, 20, 40, 80] { + let sections = pin.sections(content); + let built = pin.rows_in(content, §ions, 0, usize::MAX, None); + let shape = format!( + "resolved {} reply {:?} body {:?} at {content} columns", + pin.resolved, + pin.reply.as_ref().map(|reply| reply.text.as_str()), + pin.body + ); + assert_eq!( + pin.count(content), + built.len(), + "the count and the build disagree: {shape}" + ); + assert!(!built.is_empty(), "a note drew no row at all: {shape}"); + // And every window of it is the same rows the whole build holds. + for skip in 0..built.len() { + for take in [1, 2, built.len()] { + let window = pin.rows_in(content, §ions, skip, take, None); + let whole: Vec = built.iter().skip(skip).take(take).cloned().collect(); + assert_eq!(window, whole, "window {skip}+{take} differs: {shape}"); + } + } + } + } + } } diff --git a/crates/vigia/tests/budgets.rs b/crates/vigia/tests/budgets.rs index e23666c9..1855e4fe 100644 --- a/crates/vigia/tests/budgets.rs +++ b/crates/vigia/tests/budgets.rs @@ -101,6 +101,22 @@ fn body(app: &App, files: usize) -> usize { layout(app, files).diff } +/// Note rows on the screen carrying a class the grammar gave them. A run the +/// grammar never coloured is still a run, so this asks for a class it had to +/// produce: a pane of blocks drawn plain would satisfy a gate named for what +/// quoting costs. +fn coloured_note_rows(view: &View) -> usize { + view.rows + .iter() + .filter(|row| { + matches!(row, Row::Note { runs, .. } + if runs + .iter() + .any(|run| !matches!(run.class, None | Some(Class::Plain)))) + }) + .count() +} + /// One frame of the shell, timed whole: diff, collect, paint. fn shell_frame( frame: &mut Frame, @@ -3046,16 +3062,7 @@ fn a_frame_whose_answers_quote_code_holds_the_frame_budget() { // A run the grammar never coloured is still a run, so this asks for a class // the grammar had to produce: a pane of blocks drawn plain would satisfy a // gate named for what quoting costs. - let coloured = view - .rows - .iter() - .filter(|row| { - matches!(row, Row::Note { runs, .. } - if runs - .iter() - .any(|run| !matches!(run.class, None | Some(Class::Plain)))) - }) - .count(); + let coloured = coloured_note_rows(&view); assert!( coloured >= 20, "{coloured} rows on the timed screen carry a class the grammar gave \ @@ -3086,3 +3093,193 @@ fn a_frame_whose_answers_quote_code_holds_the_frame_budget() { }, ); } + +/// Lines a long answer quotes, so its arrival is a parse the window bounds. +const ARRIVING_LINES: usize = 2_000; + +/// What one pane pays on the frame a long fenced answer arrives, against the +/// same bytes as prose: the block is parsed as far as the rows the pane draws, +/// so every sample is an arrival, each answer differing from the last in a line +/// the window never reaches. +struct Arrival { + /// The fenced and the prose arm, where the pane was timed. + timed: Option<(Samples, Samples)>, + /// Lines of the block one screen parsed, against the rows it has. + parsed: u64, + rows: usize, + /// Rows of the timed screen carrying a class the grammar gave them. + coloured: usize, +} + +fn arrival_on(name: &str, pane: Rect, timed: bool) -> Arrival { + let scratch = Scratch::large_diff(name, FILES, LINES); + let worktree = scratch.worktree(); + let mut frame = worktree.frame(); + settle(&mut frame); + // Past the plain first frame, so a read with no warmup still parses. + let mut app = App::past_first_paint(); + let mut highlighter = Highlighter::eager(); + let mut history = History::new(); + let screen = layout_of(&app, pane, FILES); + let theme = Theme::default(); + let mut buf = Buffer::empty(pane); + + app.apply( + Action::Scroll(isize::try_from(LINES + 2).expect("a sane depth")), + &mut frame, + screen.diff, + ) + .expect("scroll to the working-tree side"); + + let block = generated(ARRIVING_LINES, "quoted"); + let answer = |fenced: bool, sample: usize| { + if fenced { + format!("swapped it for this:\n```rust\n{block}// sample {sample}\n```") + } else { + format!("swapped it for this:\n{block}sample {sample}") + } + }; + let (_, diff) = frame.diff(0).expect("diff"); + let (line, text) = diff.rows_on(Side::New)[0]; + let (path, text) = (diff.path.clone(), text.to_owned()); + let note = |fenced: bool, sample: usize| { + vec![Note { + id: "arriving".to_owned(), + path: path.clone(), + side: Side::New, + line, + text: text.clone(), + first: None, + body: "the reader's words".to_owned(), + status: Status::Seen, + reply: Some(answer(fenced, sample)), + written: std::time::SystemTime::now(), + }] + }; + + let mut edits = 0usize; + // One highlighter for both arms: every sample is a fresh digest, so nothing + // an arm keeps could serve the other. + let mut next_frame = |frame: &mut Frame, + app: &mut App, + highlighter: &mut Highlighter, + history: &mut History, + fenced: bool| { + scratch.edit_line( + EDITED_PATH, + 0, + &format!("fn edited_{edits}() {{ let value = {edits}; }}"), + ); + edits += 1; + app.set_notes(note(fenced, edits)); + time_cpu(|| { + sample(history, scratch.root(), EDITED_PATH); + shell_frame(frame, app, highlighter, history, &mut buf, &theme, screen); + }) + }; + + let samples = timed.then(|| { + for _ in 0..WARMUP_FRAMES { + for fenced in [true, false] { + next_frame(&mut frame, &mut app, &mut highlighter, &mut history, fenced); + } + } + let (mut fenced_frames, mut prose_frames) = + (Samples::new(SAMPLED_FRAMES), Samples::new(SAMPLED_FRAMES)); + for _ in 0..SAMPLED_FRAMES { + for fenced in [true, false] { + let (wall, _) = + next_frame(&mut frame, &mut app, &mut highlighter, &mut history, fenced); + if fenced { + fenced_frames.push(wall); + } else { + prose_frames.push(wall); + } + } + } + (fenced_frames, prose_frames) + }); + + // One more arrival, read rather than timed: what it parsed and what it drew. + let before = highlighter.stats().quoted_lines; + app.set_notes(note(true, usize::MAX)); + let view = app + .view(&mut frame, &mut highlighter, &history, screen) + .expect("view"); + let parsed = highlighter.stats().quoted_lines - before; + let coloured = coloured_note_rows(&view); + Arrival { + timed: samples, + parsed, + rows: screen.diff, + coloured, + } +} + +/// I9 on the frame a long fenced answer arrives, timed on the ordinary pane and +/// read on the tall one: on either, the block is parsed no further than the +/// rows drawn. The tall pane's window is a hundred-odd lines of grammar on top +/// of its frame, a first touch the spec leaves outside the steady budget, so it +/// is recorded rather than budgeted. +#[test] +fn arrival_holds_the_budget() { + if !absolute_gates_apply("cargo test --release -p vigia --test budgets") { + return; + } + let _timed = exclusively_timed(); + + let ordinary = arrival_on("notes-arriving", area(), true); + let tall = arrival_on("notes-arriving-tall", NOTED_PANE, false); + let (fenced, prose) = ordinary + .timed + .as_ref() + .expect("the ordinary pane was timed"); + let prose_p50 = prose.percentile(0.5).expect("a sampled frame"); + println!( + "arriving on the ordinary {}x{} pane: frame p50 {:?} with a {ARRIVING_LINES}-line \ + answer fenced, {prose_p50:?} with the same bytes as prose", + area().width, + area().height, + fenced.percentile(0.5).expect("a sampled frame"), + ); + + for (name, arrival) in [("ordinary", &ordinary), ("tall", &tall)] { + println!( + "the {name} pane parsed {} lines of the block for {} rows", + arrival.parsed, arrival.rows + ); + // Every line of the block is a `fn`, so every parsed line drew a class. + assert!( + arrival.coloured as u64 >= arrival.parsed, + "{} rows on the {name} pane carry a class the grammar gave them for {} \ + lines parsed, so this gate read a screen whose answer was not highlighted", + arrival.coloured, + arrival.parsed + ); + assert!( + arrival.parsed >= 1, + "the arrival on the {name} pane parsed nothing, so the block drew plain" + ); + assert!( + arrival.parsed <= arrival.rows as u64, + "the arrival on the {name} pane parsed {} lines of a {ARRIVING_LINES}-line \ + block for a {}-row body, so the parse follows the block rather than the window", + arrival.parsed, + arrival.rows + ); + } + + holds_p99_rounds( + "I9: the frame a long fenced answer arrives on", + budget(I9_FRAME), + fenced, + || format!("(prose p50 {prose_p50:?})"), + // A second round is a second rig, built whole. + || { + let (fenced, _) = arrival_on("notes-arriving-again", area(), true) + .timed + .expect("the pane was timed"); + (fenced, None) + }, + ); +} diff --git a/crates/vigia/tests/legibility.rs b/crates/vigia/tests/legibility.rs index 03de93ac..421e752b 100644 --- a/crates/vigia/tests/legibility.rs +++ b/crates/vigia/tests/legibility.rs @@ -421,6 +421,7 @@ fn every_row_kind() -> View { landed: false, ended: false, recorded: 0, + built: 0, list_span: 1, grouped: false, list: Vec::new(), @@ -652,6 +653,7 @@ fn awkward() -> View { landed: false, ended: false, recorded: 0, + built: 0, list_span: 1, grouped: false, list: Vec::new(), @@ -692,6 +694,7 @@ fn empty() -> View { landed: false, ended: false, recorded: 0, + built: 0, list_span: 0, grouped: false, list: Vec::new(), @@ -719,6 +722,7 @@ fn numbered(n: usize, files: usize, listed: usize) -> View { landed: false, ended: false, recorded: 0, + built: 0, list_span: 0, grouped: false, list: (0..listed) @@ -936,6 +940,7 @@ fn glancing() -> View { landed: false, ended: false, recorded: 0, + built: 0, list_span: 3, grouped: false, list: Vec::new(), @@ -2398,6 +2403,7 @@ fn a_label_cut_at_the_right_edge_says_so() { landed: false, ended: false, recorded: 0, + built: 0, list_span: 1, grouped: false, list: Vec::new(), @@ -2535,6 +2541,7 @@ fn a_clipped_content_line_says_it_continues() { landed: false, ended: false, recorded: 0, + built: 0, list_span: 1, grouped: false, list: Vec::new(), @@ -3566,6 +3573,7 @@ fn overlong(rows: usize) -> View { landed: false, ended: false, recorded: 0, + built: 0, list_span: 1, grouped: false, list: vec![entry("src/f.rs").into()], diff --git a/crates/vigia/tests/marks.rs b/crates/vigia/tests/marks.rs index d0be0d2f..df430751 100644 --- a/crates/vigia/tests/marks.rs +++ b/crates/vigia/tests/marks.rs @@ -85,6 +85,7 @@ fn one_file(notes: FileNotes, newest: bool) -> View { landed: false, ended: false, recorded: 0, + built: 0, list_span: 1, grouped: false, list: Vec::new(), diff --git a/crates/vigia/tests/notes.rs b/crates/vigia/tests/notes.rs index c124978c..17551e6e 100644 --- a/crates/vigia/tests/notes.rs +++ b/crates/vigia/tests/notes.rs @@ -7382,3 +7382,289 @@ fn an_answer_with_carriage_returns_draws_no_stray_mark() { "the unprintable mark reached the pane: {under:?}" ); } + +/// The ordinary pane's body for one file. +fn one_file_screen(app: &App) -> vigia::Body { + body_layout( + PANE, + &app.chrome( + "fixture", + None, + vigia::Stood { + standing: &Standing::Current, + now: 0, + }, + Pointing::default(), + Default::default(), + "", + ), + 1, + 1, + ) +} + +#[test] +fn parse_follows_the_window() { + let scratch = fixture("notes-parse-window"); + let worktree = scratch.worktree(); + let mut frame = worktree.frame(); + frame.advance().expect("advance"); + let mut app = App::past_first_paint(); + let mut highlighter = Highlighter::eager(); + let history = History::new(); + let screen = one_file_screen(&app); + let rows = screen.diff as u64; + + let block = support::generated(2_000, "quoted"); + app.set_notes(vec![left_as( + "answered", + BODY, + Status::Seen, + Some(&format!("```rust\n{block}```")), + )]); + + let before = highlighter.stats().quoted_lines; + let view = app + .view(&mut frame, &mut highlighter, &history, screen) + .expect("view"); + let arrival = highlighter.stats().quoted_lines - before; + assert!( + view.rows.iter().any(|row| matches!(row, Row::Note { .. })), + "the screen drew no note row, so nothing here was parsed for a reason" + ); + assert!( + arrival >= 1, + "the arrival parsed nothing, so the block drew plain and this gate reads nothing" + ); + assert!( + arrival <= rows, + "the arrival parsed {arrival} lines of a 2,000-line block for a {rows}-row body, \ + so the parse follows the block rather than the window" + ); + + let before = highlighter.stats().quoted_lines; + app.view(&mut frame, &mut highlighter, &history, screen) + .expect("view"); + assert_eq!( + highlighter.stats().quoted_lines - before, + 0, + "a second screen over the same answer parsed again" + ); + + app.apply( + Action::Scroll(isize::try_from(screen.diff).expect("a sane depth")), + &mut frame, + screen.diff, + ) + .expect("scroll"); + let before = highlighter.stats().quoted_lines; + app.view(&mut frame, &mut highlighter, &history, screen) + .expect("view"); + let further = highlighter.stats().quoted_lines - before; + assert!( + further <= rows, + "a screenful down parsed {further} more lines for a {rows}-row body" + ); +} + +#[test] +fn return_costs_a_screenful() { + // A second hunk below the note, taller than the pane, so the pane can sit + // past the note's rows. + let scratch = Scratch::new("notes-parse-kept"); + scratch.write(PATH, numbered_lines(200)); + scratch.commit_all("baseline"); + scratch.edit_line(PATH, 4, EDITED); + for line in 150..190 { + scratch.edit_line(PATH, line, " changed below the note"); + } + let worktree = scratch.worktree(); + let mut frame = worktree.frame(); + frame.advance().expect("advance"); + let mut app = App::past_first_paint(); + let mut highlighter = Highlighter::eager(); + let history = History::new(); + let screen = one_file_screen(&app); + + let block = support::generated(300, "quoted"); + app.set_notes(vec![left_as( + "answered", + BODY, + Status::Seen, + Some(&format!("```rust\n{block}```")), + )]); + let view = app + .view(&mut frame, &mut highlighter, &history, screen) + .expect("view"); + assert!( + view.rows.iter().any(|row| matches!(row, Row::Note { .. })), + "the screen drew no note row" + ); + + // Past the block's rows, twice, which is the sweep's grace, then back. + app.apply(Action::Scroll(400), &mut frame, screen.diff) + .expect("scroll"); + for _ in 0..2 { + let view = app + .view(&mut frame, &mut highlighter, &history, screen) + .expect("view"); + assert!( + !view.rows.iter().any(|row| matches!(row, Row::Note { .. })), + "the screen still shows the note, so this scrolled nowhere" + ); + } + app.apply(Action::Scroll(-400), &mut frame, screen.diff) + .expect("scroll back"); + let before = highlighter.stats().quoted_lines; + let view = app + .view(&mut frame, &mut highlighter, &history, screen) + .expect("view"); + assert!( + view.rows.iter().any(|row| matches!(row, Row::Note { .. })), + "the screen drew no note row on the way back" + ); + // The note left the pane with its line, so the pass swept its block; the + // return parses the rows it draws and no more. + let again = highlighter.stats().quoted_lines - before; + assert!( + again >= 1, + "coming back parsed nothing, so the block was never swept and this reads nothing" + ); + assert!( + again <= screen.diff as u64, + "coming back to a swept block parsed {again} lines for a {}-row body, so the \ + return costs the block rather than the window", + screen.diff + ); +} + +#[test] +fn kept_while_pinned() { + // A diff tall enough to scroll, with the note deep inside its first hunk + // and a second hunk below, so the pane can hold the note's line as its + // last row with the whole answer behind the window. + let scratch = Scratch::new("notes-parse-held"); + scratch.write(PATH, numbered_lines(200)); + scratch.commit_all("baseline"); + for line in 4..60 { + scratch.edit_line(PATH, line, " changed above the note"); + } + scratch.edit_line(PATH, 49, EDITED); + for line in 150..190 { + scratch.edit_line(PATH, line, " changed below the note"); + } + let worktree = scratch.worktree(); + let mut frame = worktree.frame(); + frame.advance().expect("advance"); + let mut app = App::past_first_paint(); + let mut highlighter = Highlighter::eager(); + let history = History::new(); + let screen = one_file_screen(&app); + + let reply = format!("```rust\n{}```", support::generated(50, "quoted")); + let mut pinned = note("answered", 50, EDITED, BODY); + pinned.reply = Some(reply); + app.set_notes(vec![pinned]); + + // Scroll until the note's line is the last row drawn, which puts every row + // of the answer behind the window while the note stays pinned. + let mut landed = false; + for _ in 0..200 { + let view = app + .view(&mut frame, &mut highlighter, &history, screen) + .expect("view"); + if matches!(view.rows.last(), Some(Row::Line { text, .. }) if text == EDITED) { + landed = true; + break; + } + app.apply(Action::Scroll(1), &mut frame, screen.diff) + .expect("scroll"); + } + assert!( + landed, + "no scroll put the note's line on the pane's last row" + ); + + // Down, so the block's first rows are drawn and parsed once. + app.apply(Action::Scroll(10), &mut frame, screen.diff) + .expect("scroll on"); + let before = highlighter.stats().quoted_lines; + app.view(&mut frame, &mut highlighter, &history, screen) + .expect("view"); + assert!( + highlighter.stats().quoted_lines > before, + "the block's rows were drawn and nothing was parsed, so this reads nothing" + ); + + // Back up, so the line is the last row again and every row of the answer + // is behind the window, for two frames, which is the sweep's grace. + app.apply(Action::Scroll(-10), &mut frame, screen.diff) + .expect("scroll back"); + for _ in 0..2 { + let view = app + .view(&mut frame, &mut highlighter, &history, screen) + .expect("view"); + assert!( + !view.rows.iter().any(|row| matches!(row, Row::Note { .. })), + "a note row is drawn, so the answer is not behind the window" + ); + } + + // And on again: the same rows, from the parse the pin kept. + app.apply(Action::Scroll(10), &mut frame, screen.diff) + .expect("scroll on"); + let before = highlighter.stats().quoted_lines; + let view = app + .view(&mut frame, &mut highlighter, &history, screen) + .expect("view"); + assert!( + view.rows.iter().any(|row| { + matches!(row, Row::Note { runs, .. } if runs.iter().any(|run| run.class.is_some())) + }), + "no code row is drawn after scrolling on, so this reads nothing" + ); + assert_eq!( + highlighter.stats().quoted_lines - before, + 0, + "a pinned note's block was parsed again after two frames behind the window" + ); +} + +#[test] +fn rows_follow_the_window() { + let scratch = fixture("notes-rows-window"); + let worktree = scratch.worktree(); + let mut frame = worktree.frame(); + frame.advance().expect("advance"); + let mut app = App::past_first_paint(); + let mut highlighter = Highlighter::eager(); + let history = History::new(); + let screen = one_file_screen(&app); + let height = screen.diff; + + app.set_notes(vec![left_as( + "answered", + BODY, + Status::Seen, + Some(&support::generated(10_000, "prose")), + )]); + let view = app + .view(&mut frame, &mut highlighter, &history, screen) + .expect("view"); + assert_eq!(view.rows.len(), height, "the screen did not fill"); + assert!( + matches!(view.rows.last(), Some(Row::Note { .. })), + "the screen's last row is not the answer's, so the answer is not what fills it" + ); + let drawn = view + .rows + .iter() + .filter(|row| matches!(row, Row::Note { .. })) + .count(); + assert_eq!( + view.built, drawn, + "{} note rows were built and {drawn} drawn for a {height}-row body over a \ + 10,000-line answer, so the rows follow the answer rather than the window", + view.built + ); +} diff --git a/crates/vigia/tests/palette.rs b/crates/vigia/tests/palette.rs index 425ed16d..18d7468b 100644 --- a/crates/vigia/tests/palette.rs +++ b/crates/vigia/tests/palette.rs @@ -83,6 +83,7 @@ fn three_kinds() -> View { landed: false, ended: false, recorded: 0, + built: 0, list_span: 1, grouped: false, list: Vec::new(), @@ -593,6 +594,7 @@ fn graded_heat() -> View { landed: false, ended: false, recorded: 0, + built: 0, list_span: 1, grouped: false, list: Vec::new(), diff --git a/crates/vigia/tests/render.rs b/crates/vigia/tests/render.rs index f8e4628c..a82dcf51 100644 --- a/crates/vigia/tests/render.rs +++ b/crates/vigia/tests/render.rs @@ -270,6 +270,7 @@ fn highlighted(kind: LineKind, text: &str, spans: Vec) -> View { landed: false, ended: false, recorded: 0, + built: 0, list_span: 1, grouped: false, list: Vec::new(), @@ -351,6 +352,7 @@ fn one_file() -> View { landed: false, ended: false, recorded: 0, + built: 0, list_span: 3, grouped: false, list: Vec::new(), @@ -521,6 +523,7 @@ fn nothing_changed() -> View { landed: false, ended: false, recorded: 0, + built: 0, list_span: 0, grouped: false, list: Vec::new(), @@ -981,6 +984,7 @@ fn ragged_counts() -> View { landed: false, ended: false, recorded: 0, + built: 0, list_span: 3, grouped: false, list: vec![ @@ -1893,6 +1897,7 @@ fn a_file_with_no_line_diff_says_why() { landed: false, ended: false, recorded: 0, + built: 0, list_span: 3, grouped: false, list: Vec::new(), @@ -1962,6 +1967,7 @@ fn a_path_too_long_to_fit_keeps_the_end_that_names_the_file() { landed: false, ended: false, recorded: 0, + built: 0, list_span: 1, grouped: false, list: Vec::new(), @@ -1995,6 +2001,7 @@ fn a_hunk_covering_one_line_is_written_git_s_way() { landed: false, ended: false, recorded: 0, + built: 0, list_span: 1, grouped: false, list: Vec::new(), @@ -2230,6 +2237,7 @@ fn tabs_become_columns_and_control_characters_become_visible() { landed: false, ended: false, recorded: 0, + built: 0, list_span: 1, grouped: false, list: Vec::new(), @@ -2269,6 +2277,7 @@ fn a_double_width_character_is_never_cut_in_half() { landed: false, ended: false, recorded: 0, + built: 0, list_span: 1, grouped: false, list: Vec::new(), @@ -2329,6 +2338,7 @@ fn the_gutter_gives_way_before_the_text_does() { landed: false, ended: false, recorded: 0, + built: 0, list_span: 1, grouped: false, list: Vec::new(), @@ -2407,6 +2417,7 @@ fn hostile_content_never_panics_at_any_pane_size() { landed: false, ended: false, recorded: 0, + built: 0, list_span: 2, grouped: false, list: vec![saturated.clone().into(), listed("a.rs", 0, 0).into()], @@ -2801,6 +2812,7 @@ fn glancing() -> View { landed: false, ended: false, recorded: 0, + built: 0, list_span: 3, grouped: false, list: Vec::new(), @@ -3393,6 +3405,7 @@ fn two_regions_at(current: usize, row: usize) -> View { landed: false, ended: false, recorded: 0, + built: 0, list_span: 3, grouped: false, list: vec![ @@ -3787,6 +3800,7 @@ fn a_list_of(files: usize, shown: usize, top: usize) -> View { landed: false, ended: false, recorded: 0, + built: 0, // A screenful is `shown`, which is what this fixture's name says and what the // bar is measured in: `View::list_span` is the complement of the window's // ceiling, and for an ungrouped list of `shown` rows that is exactly `shown`.