diff --git a/src/markdown/mod.rs b/src/markdown/mod.rs --- 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 @@ /// /// 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 --- a/src/markdown/stream.rs +++ b/src/markdown/stream.rs @@ -9,9 +9,9 @@ //! handed out is the only state it keeps, so every call re-parses and //! 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 @@ self.emitted += committable.len(); 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 --- a/src/markdown/stream_corpus_tests.rs +++ b/src/markdown/stream_corpus_tests.rs @@ -30,6 +30,10 @@ "Do this:\n\n1. Open the door.\n2. Step inside.\n3. Close it behind you."; 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."; @@ -160,6 +164,16 @@ #[test] 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] diff --git a/src/markdown/stream_tests.rs b/src/markdown/stream_tests.rs --- a/src/markdown/stream_tests.rs +++ b/src/markdown/stream_tests.rs @@ -2,7 +2,7 @@ //! Unit tests for [`MarkdownStream`]: what each call hands out, what it //! 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; @@ -72,6 +72,27 @@ fn a_block_with_no_blank_line_after_it_holds_back() { let mut stream = streaming("| a | b |\n|---|---|\n|"); 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]