core-agent-ide/codex-rs/tui2/src/markdown_render.rs
Josh McKinney c92dbea7c1
tui2: stop baking streaming wraps; reflow agent markdown (#8761)
Background
Streaming assistant prose in tui2 was being rendered with viewport-width
wrapping during streaming, then stored in history cells as already split
`Line`s. Those width-derived breaks became indistinguishable from hard
newlines, so the transcript could not "un-split" on resize. This also
degraded copy/paste, since soft wraps looked like hard breaks.

What changed
- Introduce width-agnostic `MarkdownLogicalLine` output in
`tui2/src/markdown_render.rs`, preserving markdown wrap semantics:
initial/subsequent indents, per-line style, and a preformatted flag.
- Update the streaming collector (`tui2/src/markdown_stream.rs`) to emit
logical lines (newline-gated) and remove any captured viewport width.
- Update streaming orchestration (`tui2/src/streaming/*`) to queue and
emit logical lines, producing `AgentMessageCell::new_logical(...)`.
- Make `AgentMessageCell` store logical lines and wrap at render time in
`HistoryCell::transcript_lines_with_joiners(width)`, emitting joiners so
copy/paste can join soft-wrap continuations correctly.

Overlay deferral
When an overlay is active, defer *cells* (not rendered `Vec<Line>`) and
render them at overlay close time. This avoids baking width-derived
wraps based on a stale width.

Tests + docs
- Add resize/reflow regression tests + snapshots for streamed agent
output.
- Expand module/API docs for the new logical-line streaming pipeline and
clarify joiner semantics.
- Align scrollback-related docs/comments with current tui2 behavior
(main draw loop does not flush queued "history lines" to the terminal).

More details
See `codex-rs/tui2/docs/streaming_wrapping_design.md` for the full
problem statement and solution approach, and
`codex-rs/tui2/docs/tui_viewport_and_history.md` for viewport vs printed
output behavior.
2026-01-05 18:37:58 -08:00

838 lines
29 KiB
Rust

//! Markdown rendering for `tui2`.
//!
//! This module has two related but intentionally distinct responsibilities:
//!
//! 1. **Parse Markdown into styled text** (for display).
//! 2. **Preserve width-agnostic structure for reflow** (for streaming + resize).
//!
//! ## Why logical lines exist
//!
//! TUI2 supports viewport resize reflow and copy/paste that treats soft-wrapped prose as a single
//! logical line. If we apply wrapping while rendering and store the resulting `Vec<Line>`, those
//! width-derived breaks become indistinguishable from hard newlines and cannot be "unwrapped" when
//! the viewport gets wider.
//!
//! To avoid baking width, streaming uses [`MarkdownLogicalLine`] output:
//!
//! - `content` holds the styled spans for a single *logical* line (a hard break boundary).
//! - `initial_indent` / `subsequent_indent` encode markdown-aware indentation rules for wraps
//! (list markers, nested lists, blockquotes, etc.).
//! - `line_style` captures line-level styling (e.g., blockquote green) that must apply to all
//! wrapped segments.
//! - `is_preformatted` marks runs that should not be wrapped like prose (e.g., fenced code).
//!
//! History cells can then wrap `content` at the *current* width, applying indents appropriately and
//! returning soft-wrap joiners for correct copy/paste.
//!
//! ## Outputs
//!
//! - [`render_markdown_text_with_width`]: emits a `Text` suitable for immediate display and may
//! apply wrapping if a width is provided.
//! - [`render_markdown_logical_lines`]: emits width-agnostic logical lines (no wrapping).
//!
//! The underlying `Writer` can emit either (or both) depending on call site needs.
use crate::render::line_utils::line_to_static;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use pulldown_cmark::CodeBlockKind;
use pulldown_cmark::CowStr;
use pulldown_cmark::Event;
use pulldown_cmark::HeadingLevel;
use pulldown_cmark::Options;
use pulldown_cmark::Parser;
use pulldown_cmark::Tag;
use pulldown_cmark::TagEnd;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
/// A single width-agnostic markdown "logical line" plus the metadata required to wrap it later.
///
/// A logical line is a hard-break boundary produced by markdown parsing (explicit newlines,
/// paragraph boundaries, list item boundaries, etc.). It is not a viewport-derived wrap segment.
///
/// Wrapping is performed later (typically in `HistoryCell::transcript_lines_with_joiners(width)`),
/// where a cell can:
///
/// - prepend a transcript gutter prefix (`• ` / ` `),
/// - prepend markdown-specific indents (`initial_indent` / `subsequent_indent`), and
/// - wrap `content` to the current width while producing joiners for copy/paste.
#[derive(Clone, Debug)]
pub(crate) struct MarkdownLogicalLine {
/// The raw content for this logical line (does not include markdown prefix/indent spans).
pub(crate) content: Line<'static>,
/// Prefix/indent spans to apply to the first visual line when wrapping.
pub(crate) initial_indent: Line<'static>,
/// Prefix/indent spans to apply to wrapped continuation lines.
pub(crate) subsequent_indent: Line<'static>,
/// Line-level style to apply to all wrapped segments.
pub(crate) line_style: Style,
/// True when this line is preformatted and should not be wrapped like prose.
pub(crate) is_preformatted: bool,
}
struct MarkdownStyles {
h1: Style,
h2: Style,
h3: Style,
h4: Style,
h5: Style,
h6: Style,
code: Style,
emphasis: Style,
strong: Style,
strikethrough: Style,
ordered_list_marker: Style,
unordered_list_marker: Style,
link: Style,
blockquote: Style,
}
impl Default for MarkdownStyles {
fn default() -> Self {
use ratatui::style::Stylize;
Self {
h1: Style::new().bold().underlined(),
h2: Style::new().bold(),
h3: Style::new().bold().italic(),
h4: Style::new().italic(),
h5: Style::new().italic(),
h6: Style::new().italic(),
code: Style::new().cyan(),
emphasis: Style::new().italic(),
strong: Style::new().bold(),
strikethrough: Style::new().crossed_out(),
ordered_list_marker: Style::new().light_blue(),
unordered_list_marker: Style::new(),
link: Style::new().cyan().underlined(),
blockquote: Style::new().green(),
}
}
}
#[derive(Clone, Debug)]
struct IndentContext {
/// Prefix spans to apply for this nesting level (e.g., blockquote `> `, list indentation).
prefix: Vec<Span<'static>>,
/// Optional list marker spans (e.g., `- ` or `1. `) that apply only to the first visual line of
/// a list item.
marker: Option<Vec<Span<'static>>>,
/// True if this context represents a list indentation level.
is_list: bool,
}
impl IndentContext {
fn new(prefix: Vec<Span<'static>>, marker: Option<Vec<Span<'static>>>, is_list: bool) -> Self {
Self {
prefix,
marker,
is_list,
}
}
}
pub fn render_markdown_text(input: &str) -> Text<'static> {
render_markdown_text_with_width(input, None)
}
/// Render markdown into a ratatui `Text`, optionally wrapping to a specific width.
///
/// This is primarily used for non-streaming rendering where storing width-derived wrapping is
/// acceptable or where the caller immediately consumes the output.
pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>) -> Text<'static> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(input, options);
let mut w = Writer::new(parser, width, true, false);
w.run();
w.text
}
/// Render markdown into width-agnostic logical lines (no wrapping).
///
/// This is used by streaming so that the transcript can reflow on resize: wrapping is deferred to
/// the history cell at render time.
pub(crate) fn render_markdown_logical_lines(input: &str) -> Vec<MarkdownLogicalLine> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(input, options);
let mut w = Writer::new(parser, None, false, true);
w.run();
w.logical_lines
}
/// A markdown event sink that builds either:
/// - a wrapped `Text` (`emit_text = true`), and/or
/// - width-agnostic [`MarkdownLogicalLine`]s (`emit_logical_lines = true`).
///
/// The writer tracks markdown structure (paragraphs, lists, blockquotes, code blocks) and builds up
/// a "current logical line". `flush_current_line` commits it to the selected output(s).
struct Writer<'a, I>
where
I: Iterator<Item = Event<'a>>,
{
iter: I,
text: Text<'static>,
logical_lines: Vec<MarkdownLogicalLine>,
styles: MarkdownStyles,
inline_styles: Vec<Style>,
indent_stack: Vec<IndentContext>,
list_indices: Vec<Option<u64>>,
link: Option<String>,
needs_newline: bool,
pending_marker_line: bool,
in_paragraph: bool,
in_code_block: bool,
wrap_width: Option<usize>,
current_line_content: Option<Line<'static>>,
current_initial_indent: Vec<Span<'static>>,
current_subsequent_indent: Vec<Span<'static>>,
current_line_style: Style,
current_line_in_code_block: bool,
emit_text: bool,
emit_logical_lines: bool,
has_output_lines: bool,
}
impl<'a, I> Writer<'a, I>
where
I: Iterator<Item = Event<'a>>,
{
fn new(iter: I, wrap_width: Option<usize>, emit_text: bool, emit_logical_lines: bool) -> Self {
Self {
iter,
text: Text::default(),
logical_lines: Vec::new(),
styles: MarkdownStyles::default(),
inline_styles: Vec::new(),
indent_stack: Vec::new(),
list_indices: Vec::new(),
link: None,
needs_newline: false,
pending_marker_line: false,
in_paragraph: false,
in_code_block: false,
wrap_width,
current_line_content: None,
current_initial_indent: Vec::new(),
current_subsequent_indent: Vec::new(),
current_line_style: Style::default(),
current_line_in_code_block: false,
emit_text,
emit_logical_lines,
has_output_lines: false,
}
}
fn run(&mut self) {
while let Some(ev) = self.iter.next() {
self.handle_event(ev);
}
self.flush_current_line();
}
fn handle_event(&mut self, event: Event<'a>) {
match event {
Event::Start(tag) => self.start_tag(tag),
Event::End(tag) => self.end_tag(tag),
Event::Text(text) => self.text(text),
Event::Code(code) => self.code(code),
Event::SoftBreak => self.soft_break(),
Event::HardBreak => self.hard_break(),
Event::Rule => {
self.flush_current_line();
if self.has_output_lines {
self.push_blank_line();
}
self.push_line(Line::from("———"));
self.needs_newline = true;
}
Event::Html(html) => self.html(html, false),
Event::InlineHtml(html) => self.html(html, true),
Event::FootnoteReference(_) => {}
Event::TaskListMarker(_) => {}
}
}
fn start_tag(&mut self, tag: Tag<'a>) {
match tag {
Tag::Paragraph => self.start_paragraph(),
Tag::Heading { level, .. } => self.start_heading(level),
Tag::BlockQuote => self.start_blockquote(),
Tag::CodeBlock(kind) => {
let indent = match kind {
CodeBlockKind::Fenced(_) => None,
CodeBlockKind::Indented => Some(Span::from(" ".repeat(4))),
};
let lang = match kind {
CodeBlockKind::Fenced(lang) => Some(lang.to_string()),
CodeBlockKind::Indented => None,
};
self.start_codeblock(lang, indent)
}
Tag::List(start) => self.start_list(start),
Tag::Item => self.start_item(),
Tag::Emphasis => self.push_inline_style(self.styles.emphasis),
Tag::Strong => self.push_inline_style(self.styles.strong),
Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough),
Tag::Link { dest_url, .. } => self.push_link(dest_url.to_string()),
Tag::HtmlBlock
| Tag::FootnoteDefinition(_)
| Tag::Table(_)
| Tag::TableHead
| Tag::TableRow
| Tag::TableCell
| Tag::Image { .. }
| Tag::MetadataBlock(_) => {}
}
}
fn end_tag(&mut self, tag: TagEnd) {
match tag {
TagEnd::Paragraph => self.end_paragraph(),
TagEnd::Heading(_) => self.end_heading(),
TagEnd::BlockQuote => self.end_blockquote(),
TagEnd::CodeBlock => self.end_codeblock(),
TagEnd::List(_) => self.end_list(),
TagEnd::Item => {
self.indent_stack.pop();
self.pending_marker_line = false;
}
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => self.pop_inline_style(),
TagEnd::Link => self.pop_link(),
TagEnd::HtmlBlock
| TagEnd::FootnoteDefinition
| TagEnd::Table
| TagEnd::TableHead
| TagEnd::TableRow
| TagEnd::TableCell
| TagEnd::Image
| TagEnd::MetadataBlock(_) => {}
}
}
fn start_paragraph(&mut self) {
if self.needs_newline {
self.push_blank_line();
}
self.push_line(Line::default());
self.needs_newline = false;
self.in_paragraph = true;
}
fn end_paragraph(&mut self) {
self.needs_newline = true;
self.in_paragraph = false;
self.pending_marker_line = false;
}
fn start_heading(&mut self, level: HeadingLevel) {
if self.needs_newline {
self.push_line(Line::default());
self.needs_newline = false;
}
let heading_style = match level {
HeadingLevel::H1 => self.styles.h1,
HeadingLevel::H2 => self.styles.h2,
HeadingLevel::H3 => self.styles.h3,
HeadingLevel::H4 => self.styles.h4,
HeadingLevel::H5 => self.styles.h5,
HeadingLevel::H6 => self.styles.h6,
};
let content = format!("{} ", "#".repeat(level as usize));
self.push_line(Line::from(vec![Span::styled(content, heading_style)]));
self.push_inline_style(heading_style);
self.needs_newline = false;
}
fn end_heading(&mut self) {
self.needs_newline = true;
self.pop_inline_style();
}
fn start_blockquote(&mut self) {
if self.needs_newline {
self.push_blank_line();
self.needs_newline = false;
}
self.indent_stack
.push(IndentContext::new(vec![Span::from("> ")], None, false));
}
fn end_blockquote(&mut self) {
self.indent_stack.pop();
self.needs_newline = true;
}
fn text(&mut self, text: CowStr<'a>) {
if self.pending_marker_line {
self.push_line(Line::default());
}
self.pending_marker_line = false;
if self.in_code_block && !self.needs_newline {
let has_content = self
.current_line_content
.as_ref()
.map(|line| !line.spans.is_empty())
.unwrap_or_else(|| {
self.text
.lines
.last()
.map(|line| !line.spans.is_empty())
.unwrap_or(false)
});
if has_content {
self.push_line(Line::default());
}
}
for (i, line) in text.lines().enumerate() {
if self.needs_newline {
self.push_line(Line::default());
self.needs_newline = false;
}
if i > 0 {
self.push_line(Line::default());
}
let content = line.to_string();
let span = Span::styled(
content,
self.inline_styles.last().copied().unwrap_or_default(),
);
self.push_span(span);
}
self.needs_newline = false;
}
fn code(&mut self, code: CowStr<'a>) {
if self.pending_marker_line {
self.push_line(Line::default());
self.pending_marker_line = false;
}
let span = Span::from(code.into_string()).style(self.styles.code);
self.push_span(span);
}
fn html(&mut self, html: CowStr<'a>, inline: bool) {
self.pending_marker_line = false;
for (i, line) in html.lines().enumerate() {
if self.needs_newline {
self.push_line(Line::default());
self.needs_newline = false;
}
if i > 0 {
self.push_line(Line::default());
}
let style = self.inline_styles.last().copied().unwrap_or_default();
self.push_span(Span::styled(line.to_string(), style));
}
self.needs_newline = !inline;
}
fn hard_break(&mut self) {
self.push_line(Line::default());
}
fn soft_break(&mut self) {
self.push_line(Line::default());
}
fn start_list(&mut self, index: Option<u64>) {
if self.list_indices.is_empty() && self.needs_newline {
self.push_line(Line::default());
}
self.list_indices.push(index);
}
fn end_list(&mut self) {
self.list_indices.pop();
self.needs_newline = true;
}
fn start_item(&mut self) {
self.pending_marker_line = true;
let depth = self.list_indices.len();
let is_ordered = self
.list_indices
.last()
.map(Option::is_some)
.unwrap_or(false);
let width = depth * 4 - 3;
let marker = if let Some(last_index) = self.list_indices.last_mut() {
match last_index {
None => Some(vec![Span::styled(
" ".repeat(width - 1) + "- ",
self.styles.unordered_list_marker,
)]),
Some(index) => {
*index += 1;
Some(vec![Span::styled(
format!("{:width$}. ", *index - 1),
self.styles.ordered_list_marker,
)])
}
}
} else {
None
};
let indent_prefix = if depth == 0 {
Vec::new()
} else {
let indent_len = if is_ordered { width + 2 } else { width + 1 };
vec![Span::from(" ".repeat(indent_len))]
};
self.indent_stack
.push(IndentContext::new(indent_prefix, marker, true));
self.needs_newline = false;
}
fn start_codeblock(&mut self, _lang: Option<String>, indent: Option<Span<'static>>) {
self.flush_current_line();
if self.has_output_lines {
self.push_blank_line();
}
self.in_code_block = true;
self.indent_stack.push(IndentContext::new(
vec![indent.unwrap_or_default()],
None,
false,
));
self.needs_newline = true;
}
fn end_codeblock(&mut self) {
self.needs_newline = true;
self.in_code_block = false;
self.indent_stack.pop();
}
fn push_inline_style(&mut self, style: Style) {
let current = self.inline_styles.last().copied().unwrap_or_default();
let merged = current.patch(style);
self.inline_styles.push(merged);
}
fn pop_inline_style(&mut self) {
self.inline_styles.pop();
}
fn push_link(&mut self, dest_url: String) {
self.link = Some(dest_url);
}
fn pop_link(&mut self) {
if let Some(link) = self.link.take() {
self.push_span(" (".into());
self.push_span(Span::styled(link, self.styles.link));
self.push_span(")".into());
}
}
/// Commit the current logical line to configured outputs.
///
/// - When emitting logical lines, this records `content` plus indent metadata so callers can
/// wrap later at the current viewport width.
/// - When emitting `Text`, wrapping may be applied immediately if `wrap_width` is set.
fn flush_current_line(&mut self) {
let Some(line) = self.current_line_content.take() else {
return;
};
let initial_indent: Line<'static> =
Line::from(std::mem::take(&mut self.current_initial_indent));
let subsequent_indent: Line<'static> =
Line::from(std::mem::take(&mut self.current_subsequent_indent));
let line_style = self.current_line_style;
let is_preformatted = self.current_line_in_code_block;
if self.emit_logical_lines {
if self.emit_text {
self.logical_lines.push(MarkdownLogicalLine {
content: line.clone(),
initial_indent: initial_indent.clone(),
subsequent_indent: subsequent_indent.clone(),
line_style,
is_preformatted,
});
} else {
self.logical_lines.push(MarkdownLogicalLine {
content: line,
initial_indent,
subsequent_indent,
line_style,
is_preformatted,
});
self.has_output_lines = true;
self.current_line_in_code_block = false;
return;
}
self.has_output_lines = true;
}
if self.emit_text {
// NB we don't wrap code in code blocks, in order to preserve whitespace for copy/paste.
if !is_preformatted && let Some(width) = self.wrap_width {
let opts = RtOptions::new(width)
.initial_indent(initial_indent)
.subsequent_indent(subsequent_indent);
for wrapped in word_wrap_line(&line, opts) {
let owned = line_to_static(&wrapped).style(line_style);
self.text.lines.push(owned);
}
} else {
let mut spans = initial_indent.spans;
let mut line = line;
spans.append(&mut line.spans);
self.text
.lines
.push(Line::from_iter(spans).style(line_style));
}
self.has_output_lines = true;
}
self.current_line_in_code_block = false;
}
fn push_line(&mut self, line: Line<'static>) {
self.flush_current_line();
let blockquote_active = self
.indent_stack
.iter()
.any(|ctx| ctx.prefix.iter().any(|s| s.content.contains('>')));
let mut style = if blockquote_active {
self.styles.blockquote
} else {
line.style
};
// Code blocks are "preformatted": we want them to keep code styling even when they appear
// within other structures like blockquotes (which otherwise apply a line-level style).
//
// This matters for copy fidelity: downstream copy logic uses code styling as a cue to
// preserve indentation and to fence code runs with Markdown markers.
if self.in_code_block {
style = style.patch(self.styles.code);
}
let was_pending = self.pending_marker_line;
self.current_initial_indent = self.prefix_spans(was_pending);
self.current_subsequent_indent = self.prefix_spans(false);
self.current_line_style = style;
self.current_line_content = Some(line);
self.current_line_in_code_block = self.in_code_block;
self.pending_marker_line = false;
}
fn push_span(&mut self, span: Span<'static>) {
if let Some(line) = self.current_line_content.as_mut() {
line.push_span(span);
} else {
self.push_line(Line::from(vec![span]));
}
}
fn push_blank_line(&mut self) {
self.flush_current_line();
if self.indent_stack.iter().all(|ctx| ctx.is_list) {
if self.emit_text {
self.text.lines.push(Line::default());
self.has_output_lines = true;
}
if self.emit_logical_lines {
self.logical_lines.push(MarkdownLogicalLine {
content: Line::default(),
initial_indent: Line::default(),
subsequent_indent: Line::default(),
line_style: Style::default(),
is_preformatted: false,
});
self.has_output_lines = true;
}
} else {
self.push_line(Line::default());
self.flush_current_line();
}
}
/// Compute the indentation spans for the current nesting stack.
///
/// `pending_marker_line` controls whether we are about to emit a list item's marker line
/// (e.g., `- ` or `1. `). For marker lines, we include exactly one marker (the most recent) and
/// suppress earlier list-level prefixes so nested list markers align correctly.
fn prefix_spans(&self, pending_marker_line: bool) -> Vec<Span<'static>> {
let mut prefix: Vec<Span<'static>> = Vec::new();
let last_marker_index = if pending_marker_line {
self.indent_stack
.iter()
.enumerate()
.rev()
.find_map(|(i, ctx)| if ctx.marker.is_some() { Some(i) } else { None })
} else {
None
};
let last_list_index = self.indent_stack.iter().rposition(|ctx| ctx.is_list);
for (i, ctx) in self.indent_stack.iter().enumerate() {
if pending_marker_line {
if Some(i) == last_marker_index
&& let Some(marker) = &ctx.marker
{
prefix.extend(marker.iter().cloned());
continue;
}
if ctx.is_list && last_marker_index.is_some_and(|idx| idx > i) {
continue;
}
} else if ctx.is_list && Some(i) != last_list_index {
continue;
}
prefix.extend(ctx.prefix.iter().cloned());
}
prefix
}
}
#[cfg(test)]
mod markdown_render_tests {
include!("markdown_render_tests.rs");
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::text::Text;
fn lines_to_strings(text: &Text<'_>) -> Vec<String> {
text.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect()
}
#[test]
fn wraps_plain_text_when_width_provided() {
let markdown = "This is a simple sentence that should wrap.";
let rendered = render_markdown_text_with_width(markdown, Some(16));
let lines = lines_to_strings(&rendered);
assert_eq!(
lines,
vec![
"This is a simple".to_string(),
"sentence that".to_string(),
"should wrap.".to_string(),
]
);
}
#[test]
fn wraps_list_items_preserving_indent() {
let markdown = "- first second third fourth";
let rendered = render_markdown_text_with_width(markdown, Some(14));
let lines = lines_to_strings(&rendered);
assert_eq!(
lines,
vec!["- first second".to_string(), " third fourth".to_string(),]
);
}
#[test]
fn wraps_nested_lists() {
let markdown =
"- outer item with several words to wrap\n - inner item that also needs wrapping";
let rendered = render_markdown_text_with_width(markdown, Some(20));
let lines = lines_to_strings(&rendered);
assert_eq!(
lines,
vec![
"- outer item with".to_string(),
" several words to".to_string(),
" wrap".to_string(),
" - inner item".to_string(),
" that also".to_string(),
" needs wrapping".to_string(),
]
);
}
#[test]
fn wraps_ordered_lists() {
let markdown = "1. ordered item contains many words for wrapping";
let rendered = render_markdown_text_with_width(markdown, Some(18));
let lines = lines_to_strings(&rendered);
assert_eq!(
lines,
vec![
"1. ordered item".to_string(),
" contains many".to_string(),
" words for".to_string(),
" wrapping".to_string(),
]
);
}
#[test]
fn wraps_blockquotes() {
let markdown = "> block quote with content that should wrap nicely";
let rendered = render_markdown_text_with_width(markdown, Some(22));
let lines = lines_to_strings(&rendered);
assert_eq!(
lines,
vec![
"> block quote with".to_string(),
"> content that should".to_string(),
"> wrap nicely".to_string(),
]
);
}
#[test]
fn wraps_blockquotes_inside_lists() {
let markdown = "- list item\n > block quote inside list that wraps";
let rendered = render_markdown_text_with_width(markdown, Some(24));
let lines = lines_to_strings(&rendered);
assert_eq!(
lines,
vec![
"- list item".to_string(),
" > block quote inside".to_string(),
" > list that wraps".to_string(),
]
);
}
#[test]
fn wraps_list_items_containing_blockquotes() {
let markdown = "1. item with quote\n > quoted text that should wrap";
let rendered = render_markdown_text_with_width(markdown, Some(24));
let lines = lines_to_strings(&rendered);
assert_eq!(
lines,
vec![
"1. item with quote".to_string(),
" > quoted text that".to_string(),
" > should wrap".to_string(),
]
);
}
#[test]
fn does_not_wrap_code_blocks() {
let markdown = "````\nfn main() { println!(\"hi from a long line\"); }\n````";
let rendered = render_markdown_text_with_width(markdown, Some(10));
let lines = lines_to_strings(&rendered);
assert_eq!(
lines,
vec!["fn main() { println!(\"hi from a long line\"); }".to_string(),]
);
}
}