Files
1jehuang--jcode/crates/jcode-tui-markdown/src/markdown_render_support.rs
T
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

467 lines
16 KiB
Rust

use super::*;
pub(super) fn line_plain_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
pub fn extract_copy_targets_from_rendered_lines(lines: &[Line<'static>]) -> Vec<RawCopyTarget> {
let mut targets = Vec::new();
let mut idx = 0usize;
while idx < lines.len() {
let text = line_plain_text(&lines[idx]);
let trimmed = text.trim_start();
if let Some(rest) = trimmed.strip_prefix("┌─ ") {
let label = rest.trim();
let language = if label.is_empty() || label == "code" {
None
} else {
Some(label.to_string())
};
let start = idx;
let badge_line = idx;
idx += 1;
let mut content_lines = Vec::new();
while idx < lines.len() {
let line_text = line_plain_text(&lines[idx]);
let line_trimmed = line_text.trim_start();
if line_trimmed.starts_with("└─") {
idx += 1;
break;
}
if let Some(code) = line_trimmed.strip_prefix("│ ") {
content_lines.push(code.to_string());
}
idx += 1;
}
targets.push(RawCopyTarget {
kind: CopyTargetKind::CodeBlock { language },
content: content_lines.join("\n"),
start_raw_line: start,
end_raw_line: idx,
badge_raw_line: badge_line,
});
continue;
}
// Blockquote lines render flush-left with a `│ ` gutter (repeated when
// nested). Code/math frame bodies also use the gutter, but those are
// consumed by the `┌─` frame branch above, so any gutter line reached
// here belongs to a blockquote.
if is_blockquote_gutter_line(&text) {
let start = idx;
let mut content_lines = Vec::new();
while idx < lines.len() {
let line_text = line_plain_text(&lines[idx]);
if is_blockquote_gutter_line(&line_text) {
content_lines.push(strip_blockquote_gutter(&line_text).to_string());
idx += 1;
continue;
}
// Nested quotes (and multi-paragraph quotes) render a blank
// separator line between gutter runs. Bridge blank lines when
// the run resumes with another gutter line so the whole quote
// gets a single badge.
if line_text.trim().is_empty() {
let mut probe = idx + 1;
while probe < lines.len() && line_plain_text(&lines[probe]).trim().is_empty() {
probe += 1;
}
if probe < lines.len()
&& is_blockquote_gutter_line(&line_plain_text(&lines[probe]))
{
for _ in idx..probe {
content_lines.push(String::new());
}
idx = probe;
continue;
}
}
break;
}
targets.push(RawCopyTarget {
kind: CopyTargetKind::Blockquote,
content: content_lines.join("\n"),
start_raw_line: start,
end_raw_line: idx,
badge_raw_line: start,
});
continue;
}
idx += 1;
}
targets
}
/// A rendered blockquote line starts (without indentation) with the `│ `
/// gutter or is a bare `│` continuation. Table rows use ` │ ` separators
/// mid-line and pad the first cell, so requiring a flush-left gutter avoids
/// misclassifying them.
fn is_blockquote_gutter_line(text: &str) -> bool {
text.starts_with("│ ") || text.trim_end() == "│"
}
/// Strip every leading `│ ` gutter level (nested quotes repeat it) from a
/// rendered blockquote line.
fn strip_blockquote_gutter(text: &str) -> &str {
let mut rest = text;
loop {
if let Some(next) = rest.strip_prefix("│ ") {
rest = next;
} else if rest.trim_end() == "│" {
return "";
} else {
return rest;
}
}
}
/// Render a table as ASCII-style lines
/// max_width: Optional maximum width for the entire table
pub(super) fn render_table(rows: &[Vec<String>], max_width: Option<usize>) -> Vec<Line<'static>> {
if rows.is_empty() {
return vec![];
}
let mut lines = Vec::new();
// Calculate column widths
let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
let mut col_widths: Vec<usize> = vec![0; num_cols];
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < col_widths.len() {
col_widths[i] = col_widths[i].max(UnicodeWidthStr::width(cell.as_str()));
}
}
}
// Apply max width constraint if specified
if let Some(max_w) = max_width {
// Account for separators: " │ " = 3 chars between each column
let separator_space = if num_cols > 1 { (num_cols - 1) * 3 } else { 0 };
let available = max_w.saturating_sub(separator_space);
if available > 0 && num_cols > 0 {
let total_width: usize = col_widths.iter().sum();
if total_width > available {
let min_col_width = (available / num_cols).clamp(1, 5);
for width in &mut col_widths {
*width = (*width).max(min_col_width);
}
while col_widths.iter().sum::<usize>() > available {
if let Some((idx, _)) = col_widths
.iter()
.enumerate()
.filter(|(_, width)| **width > min_col_width)
.max_by_key(|(_, width)| **width)
{
col_widths[idx] -= 1;
} else {
break;
}
}
}
}
}
// Render each row. Cells that exceed their allocated column width are
// wrapped into multiple physical lines instead of being truncated, so table
// output remains bounded to the terminal width without hiding content.
for (row_idx, row) in rows.iter().enumerate() {
let wrapped_cells: Vec<Vec<String>> = row
.iter()
.enumerate()
.map(|(i, cell)| {
let col_width = col_widths
.get(i)
.copied()
.unwrap_or_else(|| UnicodeWidthStr::width(cell.as_str()));
wrap_table_cell(cell, col_width)
})
.collect();
let physical_rows = wrapped_cells
.iter()
.map(|cell_lines| cell_lines.len())
.max()
.unwrap_or(1);
for physical_idx in 0..physical_rows {
let mut spans: Vec<Span<'static>> = Vec::new();
for (i, cell_lines) in wrapped_cells.iter().enumerate() {
let display_text = cell_lines
.get(physical_idx)
.map(String::as_str)
.unwrap_or("");
let col_width = col_widths
.get(i)
.copied()
.unwrap_or_else(|| UnicodeWidthStr::width(display_text));
let text_width = UnicodeWidthStr::width(display_text);
let pad = col_width.saturating_sub(text_width);
let padded = format!("{}{}", display_text, " ".repeat(pad));
// Header row gets bold styling
let style = if row_idx == 0 {
Style::default().fg(bold_color()).bold()
} else {
Style::default().fg(text_color())
};
if i > 0 {
spans.push(Span::styled(" │ ", Style::default().fg(table_color())));
}
spans.push(Span::styled(padded, style));
}
lines.push(Line::from(spans).left_aligned());
}
// Add separator after header row
if row_idx == 0 {
let separator: String = col_widths
.iter()
.map(|&w| "─".repeat(w))
.collect::<Vec<_>>()
.join("─┼─");
lines.push(
Line::from(Span::styled(separator, Style::default().fg(table_color())))
.left_aligned(),
);
}
}
lines
}
fn wrap_table_cell(cell: &str, width: usize) -> Vec<String> {
if width == 0 || cell.is_empty() {
return vec![String::new()];
}
let mut lines = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
for word in cell.split(' ') {
let word_width = UnicodeWidthStr::width(word);
let space_width = usize::from(!current.is_empty());
if !current.is_empty() && current_width + space_width + word_width <= width {
current.push(' ');
current.push_str(word);
current_width += space_width + word_width;
continue;
}
if !current.is_empty() {
lines.push(std::mem::take(&mut current));
current_width = 0;
}
if word_width <= width {
current.push_str(word);
current_width = word_width;
} else {
for chunk in wrap_long_table_word(word, width) {
if UnicodeWidthStr::width(chunk.as_str()) == width {
lines.push(chunk);
} else {
current = chunk;
current_width = UnicodeWidthStr::width(current.as_str());
}
}
}
}
if !current.is_empty() || lines.is_empty() {
lines.push(current);
}
lines
}
fn wrap_long_table_word(word: &str, width: usize) -> Vec<String> {
let mut chunks = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
for ch in word.chars() {
let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if current_width + ch_width > width && !current.is_empty() {
chunks.push(std::mem::take(&mut current));
current_width = 0;
}
current.push(ch);
current_width += ch_width;
}
if !current.is_empty() {
chunks.push(current);
}
chunks
}
/// Render a table with a specific max width constraint
pub fn render_table_with_width(rows: &[Vec<String>], max_width: usize) -> Vec<Line<'static>> {
render_table(rows, Some(max_width))
}
/// Highlight a code block with syntax highlighting (cached)
/// This is the primary entry point for code highlighting - uses a cache
/// to avoid re-highlighting the same code multiple times during streaming.
pub(super) fn highlight_code_cached(code: &str, lang: Option<&str>) -> Vec<Line<'static>> {
let hash = hash_code(code, lang);
// Check cache first
if let Ok(cache) = HIGHLIGHT_CACHE.lock()
&& let Some(lines) = cache.get(hash)
{
if let Ok(mut state) = MARKDOWN_DEBUG.lock() {
state.stats.highlight_cache_hits += 1;
}
return lines;
}
// Cache miss - do the highlighting
if let Ok(mut state) = MARKDOWN_DEBUG.lock() {
state.stats.highlight_cache_misses += 1;
}
let lines = highlight_code(code, lang);
// Store in cache
if let Ok(mut cache) = HIGHLIGHT_CACHE.lock() {
cache.insert(hash, lines.clone());
}
lines
}
/// Highlight a code block with syntax highlighting
pub(super) fn highlight_code(code: &str, lang: Option<&str>) -> Vec<Line<'static>> {
let mut lines = Vec::new();
// Try to find syntax for the language
let syntax = lang
.and_then(|l| SYNTAX_SET.find_syntax_by_token(l))
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
let theme = &THEME_SET.themes["base16-ocean.dark"];
let mut highlighter = HighlightLines::new(syntax, theme);
for line in code.lines() {
let highlighted = highlighter.highlight_line(line, &SYNTAX_SET);
match highlighted {
Ok(ranges) => {
let spans: Vec<Span<'static>> = ranges
.into_iter()
.map(|(style, text)| {
Span::styled(text.to_string(), syntect_to_ratatui_style(style))
})
.collect();
lines.push(Line::from(spans));
}
Err(_) => {
// Fallback to plain text
lines.push(Line::from(Span::styled(
line.to_string(),
Style::default().fg(code_fg()),
)));
}
}
}
lines
}
/// Convert syntect style to ratatui style
fn syntect_to_ratatui_style(style: SynStyle) -> Style {
let fg = rgb(style.foreground.r, style.foreground.g, style.foreground.b);
Style::default().fg(fg)
}
/// Highlight a single line of code (for diff display)
/// Returns styled spans for the line, or None if highlighting fails
/// `ext` is the file extension (e.g., "rs", "py", "js")
pub fn highlight_line(code: &str, ext: Option<&str>) -> Vec<Span<'static>> {
let syntax = ext
.and_then(|e| SYNTAX_SET.find_syntax_by_extension(e))
.or_else(|| ext.and_then(|e| SYNTAX_SET.find_syntax_by_token(e)))
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
let theme = &THEME_SET.themes["base16-ocean.dark"];
let mut highlighter = HighlightLines::new(syntax, theme);
match highlighter.highlight_line(code, &SYNTAX_SET) {
Ok(ranges) => ranges
.into_iter()
.map(|(style, text)| Span::styled(text.to_string(), syntect_to_ratatui_style(style)))
.collect(),
Err(_) => {
vec![Span::raw(code.to_string())]
}
}
}
/// Highlight a full file and return spans for specific line numbers (1-indexed)
/// Used for comparison logging with single-line approach
pub fn highlight_file_lines(
content: &str,
ext: Option<&str>,
line_numbers: &[usize],
) -> Vec<(usize, Vec<Span<'static>>)> {
let syntax = ext
.and_then(|e| SYNTAX_SET.find_syntax_by_extension(e))
.or_else(|| ext.and_then(|e| SYNTAX_SET.find_syntax_by_token(e)))
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
let theme = &THEME_SET.themes["base16-ocean.dark"];
let mut highlighter = HighlightLines::new(syntax, theme);
let mut results = Vec::new();
let lines: Vec<&str> = content.lines().collect();
for (i, line) in lines.iter().enumerate() {
let line_num = i + 1; // 1-indexed
if let Ok(ranges) = highlighter.highlight_line(line, &SYNTAX_SET)
&& line_numbers.contains(&line_num)
{
let spans: Vec<Span<'static>> = ranges
.into_iter()
.map(|(style, text)| {
Span::styled(text.to_string(), syntect_to_ratatui_style(style))
})
.collect();
results.push((line_num, spans));
}
}
results
}
/// Placeholder for code blocks that are not visible
/// Used by lazy rendering to avoid highlighting off-screen code
pub(super) fn placeholder_code_block(code: &str, lang: Option<&str>) -> Vec<Line<'static>> {
let line_count = code.lines().count();
let lang_str = lang.unwrap_or("code");
// Return placeholder lines that will be replaced when visible
vec![Line::from(Span::styled(
format!(" [{} block: {} lines]", lang_str, line_count),
Style::default().fg(md_dim_color()).italic(),
))]
}
/// Check if two ranges overlap
pub(super) fn ranges_overlap(a: std::ops::Range<usize>, b: std::ops::Range<usize>) -> bool {
a.start < b.end && b.start < a.end
}