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.
838 lines
29 KiB
Rust
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(),]
|
|
);
|
|
}
|
|
}
|