From 062b2ed34dfb7d7d2181d7c907cbb0acf07ff192 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Sat, 1 Aug 2026 00:02:48 -0400 Subject: [PATCH] Hold back a list until the block after it settles A blank line does not end a CommonMark list, because a later item reopens it. Cutting the settled text after a list therefore commits an ordered list whose markers are sized to the items seen so far, and the finished document sizes them wider once the tenth item arrives. The cut now refuses a candidate that follows a list and falls back to the previous one, the same way the blank-line rule does, so a list commits one block later than other constructs. A loose ordered list crossing ten items joins the streaming corpus, where it failed both properties before this change. The docs for `forming` and `spill` now say they act on every row past the emitted prefix, not only on the forming block. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CUXjWo1zGFhdJUig1hcQGf --- src/markdown/mod.rs | 34 +++++++++++++++++++---------- src/markdown/stream.rs | 18 ++++++++++----- src/markdown/stream_corpus_tests.rs | 14 ++++++++++++ src/markdown/stream_tests.rs | 23 ++++++++++++++++++- 4 files changed, 70 insertions(+), 19 deletions(-) diff --git a/src/markdown/mod.rs b/src/markdown/mod.rs index b7ebab7..f2d4930 100644 --- a/src/markdown/mod.rs +++ b/src/markdown/mod.rs @@ -16,7 +16,7 @@ mod table; pub use stream::MarkdownStream; -use pulldown_cmark::{Event, Options, Parser}; +use pulldown_cmark::{Event, Options, Parser, Tag}; use ratatui::text::Line; /// Renders a complete markdown document to styled rows at `width`. @@ -33,24 +33,34 @@ pub fn rows(markdown: &str, width: u16) -> Vec> { /// pulldown-cmark's [`Parser::into_offset_iter`] pairs every event with /// its byte range in the source, and an event at nesting depth zero /// starts a top-level block at its range's start. Those starts are the -/// candidate cuts, and the cut is the last one that follows a blank -/// line. +/// candidate cuts. A candidate has to pass two rules, and the cut is the +/// last candidate that does. /// -/// A block start alone is not enough. Text arrives a character at a -/// time, and the character that starts a new block can turn out to -/// belong to the block before it: `|` on its own line after a table -/// parses as a paragraph until the rest of the row arrives, at which -/// point it is another row of the table. A blank line settles that. It -/// closes every block above it, and nothing appended later can reopen -/// one, so the text before it parses to the same blocks forever. +/// The first rule is a blank line before the cut. Text arrives one +/// character at a time, and the character that starts a new block can +/// turn out to belong to the block before it: `|` on its own line after +/// a table reads as a paragraph until the rest of the row arrives, at +/// which point it is another row of the table. A blank line ends a +/// paragraph, a table, and an HTML block that started on a `
` for +/// good. +/// +/// The second rule is that the block before the cut is not a list. A +/// blank line does not end a list. A later item reopens it, and an +/// ordered list that reaches ten items widens every one of its markers +/// to line the numbers up, so a cut after a list can commit markers one +/// column too narrow. A list waits for the block after it instead. fn settled(markdown: &str) -> &str { let mut depth = 0usize; let mut settled = 0usize; + let mut after_list = false; for (event, range) in Parser::new_ext(markdown, options()).into_offset_iter() { // Depth zero holds only the events that open a top-level block: // a `Start` tag, or a rule, which opens no tag of its own. - if depth == 0 && after_blank_line(&markdown[..range.start]) { - settled = range.start; + if depth == 0 { + if !after_list && after_blank_line(&markdown[..range.start]) { + settled = range.start; + } + after_list = matches!(&event, Event::Start(Tag::List(_))); } match event { Event::Start(_) => depth += 1, diff --git a/src/markdown/stream.rs b/src/markdown/stream.rs index 3a0fdce..7a47abf 100644 --- a/src/markdown/stream.rs +++ b/src/markdown/stream.rs @@ -9,9 +9,9 @@ //! re-renders the text and slices from there. A round of narration is a //! few KB, which costs microseconds. //! -//! A block is final once a blank line follows it and a later top-level -//! block starts, which is what [`super::settled`] finds. The rows of a -//! final block never change after that, because no CommonMark block +//! [`super::settled`] finds the text that has stopped changing: the +//! blocks up to the last one that a blank line closed for good. The rows +//! of that text never change after that, because no CommonMark block //! reads the text that follows it. There is one exception. A link //! reference definition that arrives after a paragraph that uses it //! cannot restyle that paragraph, because the paragraph has already @@ -59,13 +59,19 @@ impl MarkdownStream { committable } - /// The forming block as it stands right now, minus spilled rows. + /// Every row that has not gone out yet, rendered from the text as it + /// stands. That is the forming block, minus spilled rows, and ahead + /// of it any settled row the caller has not taken with + /// [`MarkdownStream::committable`]. Calling `committable` first + /// leaves only the forming block. This emits nothing itself. pub fn forming(&self, width: u16) -> Vec> { self.past_emitted(super::rows(&self.text, width)) } - /// Commits the next `rows` rows of the forming block early, for a - /// block taller than the viewport's share. They freeze as-is. + /// Commits the next `rows` rows that have not gone out yet, for a + /// block taller than the viewport's share. They freeze as-is. This + /// takes from the front of [`MarkdownStream::forming`], so it starts + /// on settled rows when the caller has not committed them. pub fn spill(&mut self, width: u16, rows: usize) -> Vec> { let spilled: Vec> = self.forming(width).into_iter().take(rows).collect(); self.emitted += spilled.len(); diff --git a/src/markdown/stream_corpus_tests.rs b/src/markdown/stream_corpus_tests.rs index b6d231e..a0742a2 100644 --- a/src/markdown/stream_corpus_tests.rs +++ b/src/markdown/stream_corpus_tests.rs @@ -30,6 +30,10 @@ const ORDERED_LIST: &str = const LOOSE_LIST: &str = "Rules:\n\n- Hold the lamp high.\n\n- Count the steps down.\n\nThen go."; +/// A loose ordered list that crosses ten items, where every marker +/// widens by one column as the tenth item arrives. +const WIDENING_ORDERED_LIST: &str = "8. rope\n\n9. lamp\n\n10. key\n\n11. chalk\n\nThat is all."; + const QUOTE: &str = "She warns you:\n\n> Do not go down there.\n>\n> > The stairs are rotten.\n\nYou go down."; @@ -162,6 +166,16 @@ fn an_ordered_list_commits_stably() { commit_stability(ORDERED_LIST); } +#[test] +fn an_ordered_list_that_widens_its_markers_survives_any_chunking() { + chunk_invariance(WIDENING_ORDERED_LIST); +} + +#[test] +fn an_ordered_list_that_widens_its_markers_commits_stably() { + commit_stability(WIDENING_ORDERED_LIST); +} + #[test] fn a_loose_list_survives_any_chunking() { chunk_invariance(LOOSE_LIST); diff --git a/src/markdown/stream_tests.rs b/src/markdown/stream_tests.rs index 96d81be..8b4e2f4 100644 --- a/src/markdown/stream_tests.rs +++ b/src/markdown/stream_tests.rs @@ -2,7 +2,7 @@ //! keeps back, and how a round starts and ends. The chunking properties //! are `stream_corpus_tests.rs`'s job. -use ratatui::text::Line; +use ratatui::text::{Line, Span}; use super::MarkdownStream; @@ -74,6 +74,27 @@ fn a_block_with_no_blank_line_after_it_holds_back() { assert!(stream.committable(WIDTH).is_empty()); } +#[test] +fn a_list_waits_for_the_block_after_it() { + let mut stream = streaming("- a\n\npara\n\n"); + + assert!(stream.committable(WIDTH).is_empty()); +} + +#[test] +fn a_list_commits_once_the_block_after_it_settles() { + let mut stream = streaming("- a\n\npara\n\nmore"); + + assert_eq!( + stream.committable(WIDTH), + [ + Line::from(vec![Span::raw("• "), Span::raw("a")]), + Line::default(), + Line::raw("para") + ] + ); +} + #[test] fn whitespace_commits_nothing() { assert!(streaming(" \n\n ").committable(WIDTH).is_empty()); -- 2.51.2