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]