feat(view): Tambah parameter dim dan pewarnaan baris diff di markdown renderer
Tambahkan helper apply_dim dan diff_line_style, ubah signature render_markdown untuk menerima flag dim, serta deteksi fence bahasa diff sehingga baris +/-/@@ tetap berwarna meskipun pesan sedang dirender dim (tampilan tool-output).
This commit is contained in:
+114
-16
@@ -19,21 +19,54 @@ use ratatui::style::{Modifier, Style};
|
|||||||
use ratatui::text::Span;
|
use ratatui::text::Span;
|
||||||
use super::theme::Theme;
|
use super::theme::Theme;
|
||||||
|
|
||||||
|
/// Apply the "tool output" dim/italic style, or pass `style` through
|
||||||
|
/// unchanged, depending on `dim`.
|
||||||
|
fn apply_dim(style: Style, dim: bool) -> Style {
|
||||||
|
if dim {
|
||||||
|
Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC)
|
||||||
|
} else {
|
||||||
|
style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a single line inside a ` ```diff ` fenced block by its unified-diff
|
||||||
|
/// prefix, returning the color it should always render with (even when the
|
||||||
|
/// surrounding tool output is dimmed) — or `None` for context lines and the
|
||||||
|
/// `+++`/`---` file-header lines, which use the normal code-block color.
|
||||||
|
fn diff_line_style(line: &str) -> Option<Style> {
|
||||||
|
if line.starts_with("@@") {
|
||||||
|
Some(Style::default().fg(Theme::INFO).bg(Theme::CODE_BG))
|
||||||
|
} else if line.starts_with('+') && !line.starts_with("+++") {
|
||||||
|
Some(Style::default().fg(Theme::SUCCESS).bg(Theme::CODE_BG))
|
||||||
|
} else if line.starts_with('-') && !line.starts_with("---") {
|
||||||
|
Some(Style::default().fg(Theme::ERROR).bg(Theme::CODE_BG))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Render a markdown string into styled terminal spans, word-wrapped to `width`.
|
/// Render a markdown string into styled terminal spans, word-wrapped to `width`.
|
||||||
///
|
///
|
||||||
/// Flow: `pulldown_cmark` parses `text` into an event stream → each
|
/// Flow: `pulldown_cmark` parses `text` into an event stream → each
|
||||||
/// Start/End/Text/Code/Break event is translated into styled `Span`s →
|
/// Start/End/Text/Code/Break event is translated into styled `Span`s →
|
||||||
/// if `width > 0`, a second pass wraps long lines.
|
/// if `width > 0`, a second pass wraps long lines.
|
||||||
///
|
///
|
||||||
|
/// `dim`: when `true`, every span falls back to `Theme::TEXT_DIM` + italic
|
||||||
|
/// (the "tool output" look) *except* lines inside a ` ```diff ` fenced
|
||||||
|
/// block, which always keep their +/-/@@ diff color regardless of `dim` —
|
||||||
|
/// this is what lets diff output stay colored inside otherwise-dimmed
|
||||||
|
/// `Role::Tool` chat messages.
|
||||||
|
///
|
||||||
/// Return: a flat vec of styled spans; `chat::split_spans_into_lines`
|
/// Return: a flat vec of styled spans; `chat::split_spans_into_lines`
|
||||||
/// turns it back into `Line`s for the Paragraph widget.
|
/// turns it back into `Line`s for the Paragraph widget.
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>> {
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
let mut options = pulldown_cmark::Options::empty();
|
let mut options = pulldown_cmark::Options::empty();
|
||||||
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
|
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
|
||||||
let parser = pulldown_cmark::Parser::new_ext(text, options);
|
let parser = pulldown_cmark::Parser::new_ext(text, options);
|
||||||
let mut in_code_block = false;
|
let mut in_code_block = false;
|
||||||
|
let mut in_diff_block = false;
|
||||||
let mut in_heading = false;
|
let mut in_heading = false;
|
||||||
let mut heading_level = 0;
|
let mut heading_level = 0;
|
||||||
|
|
||||||
@@ -46,8 +79,12 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
match event {
|
match event {
|
||||||
pulldown_cmark::Event::Start(tag) => {
|
pulldown_cmark::Event::Start(tag) => {
|
||||||
match tag {
|
match tag {
|
||||||
pulldown_cmark::Tag::CodeBlock(_) => {
|
pulldown_cmark::Tag::CodeBlock(kind) => {
|
||||||
in_code_block = true;
|
in_code_block = true;
|
||||||
|
in_diff_block = matches!(
|
||||||
|
&kind,
|
||||||
|
pulldown_cmark::CodeBlockKind::Fenced(lang) if lang.as_ref() == "diff"
|
||||||
|
);
|
||||||
// Code block top bar
|
// Code block top bar
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
"\n",
|
"\n",
|
||||||
@@ -55,7 +92,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
));
|
));
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
" ┌─ code ",
|
" ┌─ code ",
|
||||||
Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG),
|
apply_dim(Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), dim),
|
||||||
));
|
));
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
"\n",
|
"\n",
|
||||||
@@ -76,25 +113,25 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
// List item bullet
|
// List item bullet
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
"• ",
|
"• ",
|
||||||
Style::default().fg(Theme::PRIMARY),
|
apply_dim(Style::default().fg(Theme::PRIMARY), dim),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
pulldown_cmark::Tag::Link { dest_url, .. } => {
|
pulldown_cmark::Tag::Link { dest_url, .. } => {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
"[",
|
"[",
|
||||||
Style::default().fg(Theme::INFO),
|
apply_dim(Style::default().fg(Theme::INFO), dim),
|
||||||
));
|
));
|
||||||
// We push the URL as a tooltip-like suffix
|
// We push the URL as a tooltip-like suffix
|
||||||
// After the link text ends, we'll add the URL
|
// After the link text ends, we'll add the URL
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
format!("]({dest_url})"),
|
format!("]({dest_url})"),
|
||||||
Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC),
|
apply_dim(Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC), dim),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
pulldown_cmark::Tag::BlockQuote(_) => {
|
pulldown_cmark::Tag::BlockQuote(_) => {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
"▎",
|
"▎",
|
||||||
Style::default().fg(Theme::BLOCKQUOTE_BAR),
|
apply_dim(Style::default().fg(Theme::BLOCKQUOTE_BAR), dim),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
pulldown_cmark::Tag::Table(_) => {
|
pulldown_cmark::Tag::Table(_) => {
|
||||||
@@ -114,10 +151,11 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
match tag {
|
match tag {
|
||||||
pulldown_cmark::TagEnd::CodeBlock => {
|
pulldown_cmark::TagEnd::CodeBlock => {
|
||||||
in_code_block = false;
|
in_code_block = false;
|
||||||
|
in_diff_block = false;
|
||||||
// Code block bottom bar
|
// Code block bottom bar
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
"\n └─\n",
|
"\n └─\n",
|
||||||
Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG),
|
apply_dim(Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), dim),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
pulldown_cmark::TagEnd::Heading(_) => {
|
pulldown_cmark::TagEnd::Heading(_) => {
|
||||||
@@ -182,7 +220,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
let max_height = cell_lines.iter().map(|cl| cl.len()).max().unwrap_or(1);
|
let max_height = cell_lines.iter().map(|cl| cl.len()).max().unwrap_or(1);
|
||||||
|
|
||||||
for y in 0..max_height {
|
for y in 0..max_height {
|
||||||
spans.push(Span::styled(" | ", Style::default().fg(Theme::BORDER)));
|
spans.push(Span::styled(" | ", apply_dim(Style::default().fg(Theme::BORDER), dim)));
|
||||||
for (i, cl) in cell_lines.iter().enumerate() {
|
for (i, cl) in cell_lines.iter().enumerate() {
|
||||||
let line_spans = if y < cl.len() { &cl[y] } else { [].as_slice() };
|
let line_spans = if y < cl.len() { &cl[y] } else { [].as_slice() };
|
||||||
let mut line_width = 0;
|
let mut line_width = 0;
|
||||||
@@ -192,15 +230,15 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
}
|
}
|
||||||
let pad = col_widths[i].saturating_sub(line_width);
|
let pad = col_widths[i].saturating_sub(line_width);
|
||||||
spans.push(Span::raw(" ".repeat(pad)));
|
spans.push(Span::raw(" ".repeat(pad)));
|
||||||
spans.push(Span::styled(" | ", Style::default().fg(Theme::BORDER)));
|
spans.push(Span::styled(" | ", apply_dim(Style::default().fg(Theme::BORDER), dim)));
|
||||||
}
|
}
|
||||||
spans.push(Span::raw("\n"));
|
spans.push(Span::raw("\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if r == 0 {
|
if r == 0 {
|
||||||
spans.push(Span::styled(" |", Style::default().fg(Theme::BORDER)));
|
spans.push(Span::styled(" |", apply_dim(Style::default().fg(Theme::BORDER), dim)));
|
||||||
for w in &col_widths {
|
for w in &col_widths {
|
||||||
spans.push(Span::styled(format!("{}-|", "-".repeat(*w + 2)), Style::default().fg(Theme::BORDER)));
|
spans.push(Span::styled(format!("{}-|", "-".repeat(*w + 2)), apply_dim(Style::default().fg(Theme::BORDER), dim)));
|
||||||
}
|
}
|
||||||
spans.push(Span::raw("\n"));
|
spans.push(Span::raw("\n"));
|
||||||
}
|
}
|
||||||
@@ -213,11 +251,25 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
pulldown_cmark::Event::Text(text) => {
|
pulldown_cmark::Event::Text(text) => {
|
||||||
let s = text.to_string();
|
let s = text.to_string();
|
||||||
if in_code_block {
|
if in_code_block {
|
||||||
|
if in_diff_block {
|
||||||
|
for (i, line) in s.split('\n').enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
spans.push(Span::raw("\n"));
|
||||||
|
}
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let style = diff_line_style(line)
|
||||||
|
.unwrap_or_else(|| Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG));
|
||||||
|
spans.push(Span::styled(format!(" {line}"), style));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
let indented = format!(" {}", s.replace('\n', "\n "));
|
let indented = format!(" {}", s.replace('\n', "\n "));
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
indented,
|
indented,
|
||||||
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG),
|
apply_dim(Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG), dim),
|
||||||
));
|
));
|
||||||
|
}
|
||||||
} else if in_heading {
|
} else if in_heading {
|
||||||
let color = match heading_level {
|
let color = match heading_level {
|
||||||
1 => Theme::PRIMARY,
|
1 => Theme::PRIMARY,
|
||||||
@@ -227,21 +279,24 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
};
|
};
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
s,
|
s,
|
||||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
apply_dim(Style::default().fg(color).add_modifier(Modifier::BOLD), dim),
|
||||||
));
|
));
|
||||||
} else if in_table_cell {
|
} else if in_table_cell {
|
||||||
current_cell.push(Span::raw(s));
|
current_cell.push(Span::styled(s, apply_dim(Style::default(), dim)));
|
||||||
} else {
|
} else {
|
||||||
spans.push(Span::raw(s));
|
spans.push(Span::styled(s, apply_dim(Style::default(), dim)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pulldown_cmark::Event::Code(text) => {
|
pulldown_cmark::Event::Code(text) => {
|
||||||
let span = Span::styled(
|
let span = Span::styled(
|
||||||
format!(" {text} "),
|
format!(" {text} "),
|
||||||
|
apply_dim(
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Theme::ACCENT_TEAL)
|
.fg(Theme::ACCENT_TEAL)
|
||||||
.bg(Theme::CODE_BAR)
|
.bg(Theme::CODE_BAR)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
|
dim,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if in_table_cell {
|
if in_table_cell {
|
||||||
current_cell.push(span);
|
current_cell.push(span);
|
||||||
@@ -377,3 +432,46 @@ fn wrap_spans_to_lines(spans: &[Span<'static>], target_width: usize) -> Vec<Vec<
|
|||||||
}
|
}
|
||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn span_text(spans: &[Span<'static>]) -> String {
|
||||||
|
spans.iter().map(|s| s.content.as_ref()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dim_false_plain_text_has_no_color() {
|
||||||
|
let spans = render_markdown("hello world", 0, false);
|
||||||
|
assert_eq!(span_text(&spans), "hello world");
|
||||||
|
assert_eq!(spans[0].style, Style::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dim_true_plain_text_is_dim_italic() {
|
||||||
|
let spans = render_markdown("hello", 0, true);
|
||||||
|
let expected = Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC);
|
||||||
|
assert_eq!(spans[0].style, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dim_true_diff_lines_keep_their_own_color() {
|
||||||
|
let md = "```diff\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context line\n```";
|
||||||
|
let spans = render_markdown(md, 0, true);
|
||||||
|
let plus_span = spans.iter().find(|s| s.content.contains("+new line")).expect("plus span present");
|
||||||
|
assert_eq!(plus_span.style.fg, Some(Theme::SUCCESS));
|
||||||
|
let minus_span = spans.iter().find(|s| s.content.contains("-old line")).expect("minus span present");
|
||||||
|
assert_eq!(minus_span.style.fg, Some(Theme::ERROR));
|
||||||
|
let hunk_span = spans.iter().find(|s| s.content.contains("@@")).expect("hunk header span present");
|
||||||
|
assert_eq!(hunk_span.style.fg, Some(Theme::INFO));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dim_true_non_diff_code_block_is_dimmed() {
|
||||||
|
let md = "```rust\nfn main() {}\n```";
|
||||||
|
let spans = render_markdown(md, 0, true);
|
||||||
|
let code_span = spans.iter().find(|s| s.content.contains("fn main")).expect("code span present");
|
||||||
|
assert_eq!(code_span.style, Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user