2025-09-30 16:13:55 -07:00
|
|
|
use crate::render::line_utils::line_to_static;
|
|
|
|
|
use crate::wrapping::RtOptions;
|
|
|
|
|
use crate::wrapping::word_wrap_line;
|
2025-09-10 12:13:53 -07:00
|
|
|
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::style::Stylize;
|
|
|
|
|
use ratatui::text::Line;
|
|
|
|
|
use ratatui::text::Span;
|
|
|
|
|
use ratatui::text::Text;
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
struct IndentContext {
|
|
|
|
|
prefix: Vec<Span<'static>>,
|
|
|
|
|
marker: Option<Vec<Span<'static>>>,
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-30 03:10:33 -07:00
|
|
|
pub fn render_markdown_text(input: &str) -> Text<'static> {
|
2025-10-20 14:08:19 -07:00
|
|
|
render_markdown_text_with_width(input, None)
|
2025-09-10 12:13:53 -07:00
|
|
|
}
|
|
|
|
|
|
2025-10-20 14:08:19 -07:00
|
|
|
pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>) -> Text<'static> {
|
2025-09-10 12:13:53 -07:00
|
|
|
let mut options = Options::empty();
|
|
|
|
|
options.insert(Options::ENABLE_STRIKETHROUGH);
|
|
|
|
|
let parser = Parser::new_ext(input, options);
|
2025-10-20 14:08:19 -07:00
|
|
|
let mut w = Writer::new(parser, width);
|
2025-09-10 12:13:53 -07:00
|
|
|
w.run();
|
|
|
|
|
w.text
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Writer<'a, I>
|
|
|
|
|
where
|
|
|
|
|
I: Iterator<Item = Event<'a>>,
|
|
|
|
|
{
|
|
|
|
|
iter: I,
|
|
|
|
|
text: Text<'static>,
|
|
|
|
|
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,
|
2025-09-30 16:13:55 -07:00
|
|
|
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,
|
2025-09-10 12:13:53 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<'a, I> Writer<'a, I>
|
|
|
|
|
where
|
|
|
|
|
I: Iterator<Item = Event<'a>>,
|
|
|
|
|
{
|
2025-10-20 14:08:19 -07:00
|
|
|
fn new(iter: I, wrap_width: Option<usize>) -> Self {
|
2025-09-10 12:13:53 -07:00
|
|
|
Self {
|
|
|
|
|
iter,
|
|
|
|
|
text: Text::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,
|
2025-09-30 16:13:55 -07:00
|
|
|
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,
|
2025-09-10 12:13:53 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run(&mut self) {
|
|
|
|
|
while let Some(ev) = self.iter.next() {
|
|
|
|
|
self.handle_event(ev);
|
|
|
|
|
}
|
2025-09-30 16:13:55 -07:00
|
|
|
self.flush_current_line();
|
2025-09-10 12:13:53 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 => {
|
2025-09-30 16:13:55 -07:00
|
|
|
self.flush_current_line();
|
2025-09-10 12:13:53 -07:00
|
|
|
if !self.text.lines.is_empty() {
|
|
|
|
|
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(Style::new().italic()),
|
|
|
|
|
Tag::Strong => self.push_inline_style(Style::new().bold()),
|
|
|
|
|
Tag::Strikethrough => self.push_inline_style(Style::new().crossed_out()),
|
|
|
|
|
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 => Style::new().bold().underlined(),
|
|
|
|
|
HeadingLevel::H2 => Style::new().bold(),
|
|
|
|
|
HeadingLevel::H3 => Style::new().bold().italic(),
|
|
|
|
|
HeadingLevel::H4 => Style::new().italic(),
|
|
|
|
|
HeadingLevel::H5 => Style::new().italic(),
|
|
|
|
|
HeadingLevel::H6 => Style::new().italic(),
|
|
|
|
|
};
|
|
|
|
|
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;
|
2025-09-30 16:13:55 -07:00
|
|
|
if self.in_code_block && !self.needs_newline {
|
|
|
|
|
let has_content = self
|
|
|
|
|
.current_line_content
|
|
|
|
|
.as_ref()
|
2025-09-10 12:13:53 -07:00
|
|
|
.map(|line| !line.spans.is_empty())
|
2025-09-30 16:13:55 -07:00
|
|
|
.unwrap_or_else(|| {
|
|
|
|
|
self.text
|
|
|
|
|
.lines
|
|
|
|
|
.last()
|
|
|
|
|
.map(|line| !line.spans.is_empty())
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
});
|
|
|
|
|
if has_content {
|
|
|
|
|
self.push_line(Line::default());
|
|
|
|
|
}
|
2025-09-10 12:13:53 -07:00
|
|
|
}
|
|
|
|
|
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());
|
|
|
|
|
}
|
2025-10-20 14:08:19 -07:00
|
|
|
let content = line.to_string();
|
2025-09-10 12:13:53 -07:00
|
|
|
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()).dim();
|
|
|
|
|
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()
|
2025-09-22 20:30:16 +01:00
|
|
|
.map(Option::is_some)
|
2025-09-10 12:13:53 -07:00
|
|
|
.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::from(" ".repeat(width - 1) + "- ")]),
|
|
|
|
|
Some(index) => {
|
|
|
|
|
*index += 1;
|
|
|
|
|
Some(vec![format!("{:width$}. ", *index - 1).light_blue()])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} 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>>) {
|
2025-09-30 16:13:55 -07:00
|
|
|
self.flush_current_line();
|
2025-09-10 12:13:53 -07:00
|
|
|
if !self.text.lines.is_empty() {
|
|
|
|
|
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(link.cyan().underlined());
|
|
|
|
|
self.push_span(")".into());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-30 16:13:55 -07:00
|
|
|
fn flush_current_line(&mut self) {
|
|
|
|
|
if let Some(line) = self.current_line_content.take() {
|
|
|
|
|
let style = self.current_line_style;
|
|
|
|
|
// NB we don't wrap code in code blocks, in order to preserve whitespace for copy/paste.
|
|
|
|
|
if !self.current_line_in_code_block
|
|
|
|
|
&& let Some(width) = self.wrap_width
|
|
|
|
|
{
|
|
|
|
|
let opts = RtOptions::new(width)
|
|
|
|
|
.initial_indent(self.current_initial_indent.clone().into())
|
|
|
|
|
.subsequent_indent(self.current_subsequent_indent.clone().into());
|
|
|
|
|
for wrapped in word_wrap_line(&line, opts) {
|
|
|
|
|
let owned = line_to_static(&wrapped).style(style);
|
|
|
|
|
self.text.lines.push(owned);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
let mut spans = self.current_initial_indent.clone();
|
|
|
|
|
let mut line = line;
|
|
|
|
|
spans.append(&mut line.spans);
|
|
|
|
|
self.text.lines.push(Line::from_iter(spans).style(style));
|
|
|
|
|
}
|
|
|
|
|
self.current_initial_indent.clear();
|
|
|
|
|
self.current_subsequent_indent.clear();
|
|
|
|
|
self.current_line_in_code_block = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-10 12:13:53 -07:00
|
|
|
fn push_line(&mut self, line: Line<'static>) {
|
2025-09-30 16:13:55 -07:00
|
|
|
self.flush_current_line();
|
2025-09-10 12:13:53 -07:00
|
|
|
let blockquote_active = self
|
|
|
|
|
.indent_stack
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|ctx| ctx.prefix.iter().any(|s| s.content.contains('>')));
|
|
|
|
|
let style = if blockquote_active {
|
|
|
|
|
Style::new().green()
|
|
|
|
|
} else {
|
|
|
|
|
line.style
|
|
|
|
|
};
|
2025-09-30 16:13:55 -07:00
|
|
|
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;
|
2025-09-10 12:13:53 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn push_span(&mut self, span: Span<'static>) {
|
2025-09-30 16:13:55 -07:00
|
|
|
if let Some(line) = self.current_line_content.as_mut() {
|
|
|
|
|
line.push_span(span);
|
2025-09-10 12:13:53 -07:00
|
|
|
} else {
|
|
|
|
|
self.push_line(Line::from(vec![span]));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn push_blank_line(&mut self) {
|
2025-09-30 16:13:55 -07:00
|
|
|
self.flush_current_line();
|
2025-09-10 12:13:53 -07:00
|
|
|
if self.indent_stack.iter().all(|ctx| ctx.is_list) {
|
|
|
|
|
self.text.lines.push(Line::default());
|
|
|
|
|
} else {
|
|
|
|
|
self.push_line(Line::default());
|
2025-09-30 16:13:55 -07:00
|
|
|
self.flush_current_line();
|
2025-09-10 12:13:53 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-30 16:13:55 -07:00
|
|
|
fn prefix_spans(&self, pending_marker_line: bool) -> Vec<Span<'static>> {
|
2025-09-10 12:13:53 -07:00
|
|
|
let mut prefix: Vec<Span<'static>> = Vec::new();
|
2025-09-30 16:13:55 -07:00
|
|
|
let last_marker_index = if pending_marker_line {
|
2025-09-10 12:13:53 -07:00
|
|
|
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() {
|
2025-09-30 16:13:55 -07:00
|
|
|
if pending_marker_line {
|
2025-09-10 12:13:53 -07:00
|
|
|
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;
|
2025-09-30 16:13:55 -07:00
|
|
|
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()
|
|
|
|
|
}
|
2025-09-10 12:13:53 -07:00
|
|
|
|
2025-09-30 16:13:55 -07:00
|
|
|
#[test]
|
|
|
|
|
fn wraps_plain_text_when_width_provided() {
|
|
|
|
|
let markdown = "This is a simple sentence that should wrap.";
|
2025-10-20 14:08:19 -07:00
|
|
|
let rendered = render_markdown_text_with_width(markdown, Some(16));
|
2025-09-30 16:13:55 -07:00
|
|
|
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";
|
2025-10-20 14:08:19 -07:00
|
|
|
let rendered = render_markdown_text_with_width(markdown, Some(14));
|
2025-09-30 16:13:55 -07:00
|
|
|
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";
|
2025-10-20 14:08:19 -07:00
|
|
|
let rendered = render_markdown_text_with_width(markdown, Some(20));
|
2025-09-30 16:13:55 -07:00
|
|
|
let lines = lines_to_strings(&rendered);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
lines,
|
|
|
|
|
vec![
|
|
|
|
|
"- outer item with".to_string(),
|
|
|
|
|
" several words".to_string(),
|
|
|
|
|
" to 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";
|
2025-10-20 14:08:19 -07:00
|
|
|
let rendered = render_markdown_text_with_width(markdown, Some(18));
|
2025-09-30 16:13:55 -07:00
|
|
|
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";
|
2025-10-20 14:08:19 -07:00
|
|
|
let rendered = render_markdown_text_with_width(markdown, Some(22));
|
2025-09-30 16:13:55 -07:00
|
|
|
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";
|
2025-10-20 14:08:19 -07:00
|
|
|
let rendered = render_markdown_text_with_width(markdown, Some(24));
|
2025-09-30 16:13:55 -07:00
|
|
|
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";
|
2025-10-20 14:08:19 -07:00
|
|
|
let rendered = render_markdown_text_with_width(markdown, Some(24));
|
2025-09-30 16:13:55 -07:00
|
|
|
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````";
|
2025-10-20 14:08:19 -07:00
|
|
|
let rendered = render_markdown_text_with_width(markdown, Some(10));
|
2025-09-30 16:13:55 -07:00
|
|
|
let lines = lines_to_strings(&rendered);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
lines,
|
|
|
|
|
vec!["fn main() { println!(\"hi from a long line\"); }".to_string(),]
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-09-10 12:13:53 -07:00
|
|
|
}
|