diff --git a/plans/0000-roadmap.md b/plans/0000-roadmap.md index 54b0ebe..c9ce37f 100644 --- a/plans/0000-roadmap.md +++ b/plans/0000-roadmap.md @@ -84,10 +84,11 @@ the real design happens. The blurbs here stay deliberately loose. layer by case-insensitive keywords, nothing smarter. *You can now: watch the DM quote the actual rule.* ([0007](completed/0007-knowledge-mount.md)) -- [x] **Phase 6: Streaming markdown.** The DM speaks markdown and the - transcript prints it raw. A renderer that styles the text as it - streams, token by token, instead of waiting for a finished block. - *You can now: read the DM's words styled, not asterisked.* ([0008](0008-streaming-markdown.md)) +- [x] **Phase 6: Streaming markdown.** A renderer that turns the DM's + markdown into styled rows as it streams, committing each block as + soon as it settles and holding back only the one still forming. + *You can now: read the DM's words styled, not asterisked.* + ([0008](0008-streaming-markdown.md)) - [ ] **Phase 7: DM prompting.** The first real tuning pass against live sessions, and the DM-versus-player knowledge line: secrets from lore stay behind the screen until the player earns them, and the DM diff --git a/plans/0008-streaming-markdown.md b/plans/0008-streaming-markdown.md index dbd9bcf..f2dfcb9 100644 --- a/plans/0008-streaming-markdown.md +++ b/plans/0008-streaming-markdown.md @@ -83,8 +83,9 @@ lives in the viewport, where it repaints freely on every delta. Code blocks indent two spaces. No syntax highlighting; that is a `syntect`-sized dependency and it has not earned a seat. - Headings: bold, and H1 and H2 also underlined. No `#` shown. - - Lists: `•` for bullets, real numbers for ordered items, two-space - hanging indent per nesting level. + - Lists: `•` for bullets, real numbers for ordered items, a + continuation row aligns under the item text, at the marker's own + width. - Blockquotes: a dim `│ ` gutter, dim text. - Tables: light box-drawing borders in the dim aside style, bold header row. Columns size to their widest cell, and when the total @@ -184,9 +185,10 @@ block starts. The two properties that make it a streaming renderer, as tests: - **Chunk invariance.** For a corpus of documents covering every - construct, feed the document in chunks split at every byte boundary - and in random chunkings: the concatenation of every `committable` - call plus the final `finish` equals `rows()` of the whole document. + construct, feed the document in chunks split at every byte boundary, + and again one character at a time: the concatenation of every + `committable` call plus the final `finish` equals `rows()` of the + whole document. - **Commit stability.** Once a row comes out of `committable`, no later delta produces a different version of it. diff --git a/src/dm/mod.rs b/src/dm/mod.rs index 83a4f4f..c29cb59 100644 --- a/src/dm/mod.rs +++ b/src/dm/mod.rs @@ -18,7 +18,7 @@ use tools::read::ReadTool; pub mod tools; -// TODO: Phase 6, the context stack (plans/0000-roadmap.md), replaces this +// TODO: Phase 8, the context stack (plans/0000-roadmap.md), replaces this // fixed prompt file with rules, world, and player knowledge layered per turn. const SYSTEM_PROMPT: &str = include_str!("system-prompt.md"); diff --git a/src/markdown/block.rs b/src/markdown/block.rs index 2b5762d..e45829f 100644 --- a/src/markdown/block.rs +++ b/src/markdown/block.rs @@ -81,7 +81,7 @@ fn render(tag: Tag<'static>, events: &mut Events, width: usize, base: Style) -> Tag::List(start) => list(events, end, start, width, base), Tag::BlockQuote(_) => quote(events, end, width, base), Tag::CodeBlock(_) => code_block(events, end, width), - Tag::HtmlBlock => html_block(events, end, width), + Tag::HtmlBlock => html_block(events, end, width, base), Tag::Table(alignments) => table::render(events, alignments, width, base), // A paragraph, and every other tag that gets this far, holds // inline content and nothing else. @@ -117,14 +117,14 @@ fn list( base: Style, ) -> Vec> { let digits = start.map(|first| digit_width(first, item_count(events.remaining()))); + let mut counter = start.zip(digits); let mut lines = Vec::new(); - let mut number = start; while let Some(event) = events.next() { if event == Event::End(end) { break; } - let marker = marker(number, digits); - number = number.map(|n| n + 1); + let marker = marker(counter); + counter = counter.map(|(n, digits)| (n + 1, digits)); lines.extend(item(events, width, &marker, base)); } lines @@ -158,11 +158,12 @@ fn digit_width(start: u64, item_count: u64) -> usize { last.to_string().len() } -/// A bullet marker, or an ordered marker right-aligned to `digits` -/// columns of number, e.g. `" 9. "` and `"10. "` in the same list. -fn marker(number: Option, digits: Option) -> String { - match number { - Some(n) => format!("{n:>width$}. ", width = digits.unwrap_or(1)), +/// A bullet marker, or an ordered marker and its number, right-aligned to +/// the number's own digit width, e.g. `" 9. "` and `"10. "` in the same +/// list. +fn marker(counter: Option<(u64, usize)>) -> String { + match counter { + Some((n, digits)) => format!("{n:>digits$}. "), None => style::bullet().to_string(), } } @@ -249,9 +250,10 @@ fn code_block(events: &mut Events, end: TagEnd, width: usize) -> Vec Vec> { +/// An HTML block: its source lines, verbatim, one row per line, +/// hard-wrapped at `width`, in `base`: `Style::default` at the top +/// level, or a block quote's `style::quote` inside one. +fn html_block(events: &mut Events, end: TagEnd, width: usize, base: Style) -> Vec> { let mut source = String::new(); while let Some(event) = events.next() { if event == Event::End(end) { @@ -264,7 +266,7 @@ fn html_block(events: &mut Events, end: TagEnd, width: usize) -> Vec\n

hi

\n", 40), vec![ @@ -314,6 +314,40 @@ fn an_html_block_is_verbatim_and_unstyled() { ); } +#[test] +fn an_html_block_in_a_quote_carries_the_quote_style() { + assert_eq!( + rows(">
\n> hi\n>
", 40), + vec![ + Line::from(vec![ + Span::styled("│ ", style::border()), + Span::styled("
", style::quote()), + ]), + Line::from(vec![ + Span::styled("│ ", style::border()), + Span::styled("hi", style::quote()), + ]), + Line::from(vec![ + Span::styled("│ ", style::border()), + Span::styled("
", style::quote()), + ]), + ] + ); +} + +#[test] +fn inline_html_in_a_quote_carries_the_quote_style() { + // The html and the text around it share the quote style, so wrapping + // merges them into one span. + assert_eq!( + rows("> some
text", 40), + vec![Line::from(vec![ + Span::styled("│ ", style::border()), + Span::styled("some
text", style::quote()), + ])] + ); +} + #[test] fn blocks_of_different_kinds_still_get_exactly_one_empty_row() { assert_eq!( diff --git a/src/markdown/inline.rs b/src/markdown/inline.rs index b5ee370..d344385 100644 --- a/src/markdown/inline.rs +++ b/src/markdown/inline.rs @@ -121,7 +121,7 @@ fn styled( } Event::Text(text) => spans.push(Span::styled(text.into_string(), active)), Event::Html(html) | Event::InlineHtml(html) => { - spans.push(Span::raw(html.into_string())); + spans.push(Span::styled(html.into_string(), active)); } Event::SoftBreak => spans.push(Span::styled(" ", active)), Event::HardBreak => lines.push(Line::from(std::mem::take(&mut spans))), diff --git a/src/play/transcript.rs b/src/play/transcript.rs index 715fffb..1eaa68a 100644 --- a/src/play/transcript.rs +++ b/src/play/transcript.rs @@ -192,7 +192,10 @@ impl Transcript { /// edge on purpose, and every row of it renders past the same edge, /// so clipping cuts them all at one column and what is left still /// lines up. Wrapping those rows instead would break each one at a - /// different word and leave nothing to read. + /// different word and leave nothing to read. A resize mid-round + /// still catches one of these rows in the general rewrap above, + /// though, and garbles its columns; that case is accepted, since a + /// resize has already cost the round its layout. /// /// The extra rows this makes are invisible to /// [`crate::markdown::MarkdownStream`], which counts the rows it diff --git a/src/srd/verify/mod.rs b/src/srd/verify/mod.rs index b26110b..1bcc1dc 100644 --- a/src/srd/verify/mod.rs +++ b/src/srd/verify/mod.rs @@ -8,8 +8,8 @@ //! exactly one corpus entry, no other vendored file is claimed more than //! once, every kind meets its entry-count floor, and the layer root's //! README.md and meta.yaml carry what CC-BY-4.0 and the pinned sources -//! require. This is the complete verifier: `verify` is what both the CLI -//! and task 6's corpus build run. +//! require. This is the complete verifier: `verify` is what the `srd +//! verify` CLI command runs. mod coverage; mod diff;