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 { 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], max_width: Option) -> Vec> { 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 = 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::() > 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> = 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> = 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::>() .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 { 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 { 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], max_width: usize) -> Vec> { 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> { 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> { 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> = 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> { 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>)> { 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> = 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> { 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, b: std::ops::Range) -> bool { a.start < b.end && b.start < a.end }