use ratatui::{
style::{Color, Style},
text::{Line, Span},
};
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
use std::sync::LazyLock;
use super::theme::THEME;
use unicode_width::UnicodeWidthStr;
pub(crate) fn clamp_line(line: Line<'static>, width: usize) -> Line<'static> {
if width == 0 {
return Line::from("");
}
let mut remaining = width;
let mut clamped: Vec<Span<'static>> = Vec::new();
for span in line.spans {
let span_width = UnicodeWidthStr::width(span.content.as_ref());
if remaining == 0 {
break;
}
if span_width <= remaining {
remaining -= span_width;
clamped.push(span);
} else {
let mut used = 0;
let mut truncated = String::new();
for ch in span.content.chars() {
let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if used + ch_width > remaining {
break;
}
used += ch_width;
truncated.push(ch);
}
clamped.push(Span::styled(truncated, span.style));
remaining = 0;
}
}
Line::from(clamped)
}
static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
pub(crate) fn highlight_code_block(code: &str, lang: &str, prefix: &str) -> Vec<Line<'static>> {
let ss = &*SYNTAX_SET;
let ts = &*THEME_SET;
let theme = &ts.themes["base16-ocean.dark"];
let syntax = ss.find_syntax_by_token(lang)
.unwrap_or_else(|| ss.find_syntax_plain_text());
let mut h = HighlightLines::new(syntax, theme);
let mut lines: Vec<Line> = Vec::new();
for line in LinesWithEndings::from(code) {
let ranges = h.highlight_line(line, ss).unwrap_or_default();
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(
format!("{} \u{2502} ", prefix),
Style::default().fg(THEME.load().muted),
));
for (style, text) in ranges {
let fg = Color::Rgb(style.foreground.r, style.foreground.g, style.foreground.b);
let content = text.trim_end_matches('\n').to_string();
if !content.is_empty() {
spans.push(Span::styled(content, Style::default().fg(fg).bg(THEME.load().code_bg)));
}
}
lines.push(Line::from(spans));
}
lines
}
pub(crate) fn highlight_tool_code(lines: &[&str], ext: &str, margin: &str, marker: &str, marker_color: Color) -> Vec<Line<'static>> {
let ss = &*SYNTAX_SET;
let ts = &*THEME_SET;
let theme = &ts.themes["base16-ocean.dark"];
let syntax = ss.find_syntax_by_extension(ext)
.unwrap_or_else(|| ss.find_syntax_plain_text());
let mut h = HighlightLines::new(syntax, theme);
let mut result = Vec::new();
let tint = match marker {
"−" => (40i16, -60i16, -60i16), "+" => (-15i16, 10i16, -15i16), _ => (0i16, 0i16, 0i16), };
for (i, line) in lines.iter().enumerate() {
let code_with_nl = format!("{}\n", line);
let ranges = h.highlight_line(&code_with_nl, ss).unwrap_or_default();
let mut spans = vec![
Span::styled(
format!("{} {:>3} {} ", margin, i + 1, marker),
Style::default().fg(marker_color),
),
];
for (sty, text) in ranges {
let r = (sty.foreground.r as i16 + tint.0).clamp(0, 255) as u8;
let g = (sty.foreground.g as i16 + tint.1).clamp(0, 255) as u8;
let b = (sty.foreground.b as i16 + tint.2).clamp(0, 255) as u8;
let fg = Color::Rgb(r, g, b);
let content = text.trim_end_matches('\n').to_string();
if !content.is_empty() {
spans.push(Span::styled(content, Style::default().fg(fg)));
}
}
result.push(Line::from(spans));
}
result
}
pub(crate) fn highlight_bash_output(lines: &[&str], margin: &str) -> Vec<Line<'static>> {
let mut result = Vec::new();
for raw_line in lines {
let line = raw_line.replace('\t', " ");
let trimmed = line.trim();
let mut spans = vec![
Span::styled(format!("{} ", margin), Style::default().fg(THEME.load().tool_result_color)),
];
if trimmed.is_empty() {
result.push(Line::from(spans));
continue;
}
let lc = trimmed.to_ascii_lowercase();
if lc.starts_with("error") || lc.starts_with("fatal") {
spans.push(Span::styled(line.to_string(), Style::default().fg(THEME.load().error_color)));
} else if lc.starts_with("warning") || lc.starts_with("warn") {
spans.push(Span::styled(line.to_string(), Style::default().fg(THEME.load().warning_color)));
} else if trimmed.starts_with("✅") || trimmed.starts_with("ok") || trimmed.starts_with("OK")
|| trimmed.starts_with("done") || trimmed.starts_with("Done") || trimmed.starts_with("success") {
spans.push(Span::styled(line.to_string(), Style::default().fg(THEME.load().tool_result_ok)));
} else {
let mut remaining = line.as_str();
while !remaining.is_empty() {
if let Some(slash_pos) = remaining.find('/') {
if slash_pos > 0 {
let before = &remaining[..slash_pos];
let path_start = before.rfind(|c: char| c.is_whitespace())
.map(|p| p + before[p..].chars().next().unwrap().len_utf8())
.unwrap_or(0);
if path_start > 0 {
spans.push(Span::styled(
remaining[..path_start].to_string(),
Style::default().fg(THEME.load().tool_result_color),
));
}
let after_slash = &remaining[path_start..];
let path_end = after_slash.find(|c: char| c.is_whitespace() || c == ':' || c == ')' || c == ']')
.unwrap_or(after_slash.len());
if path_end == 0 {
let first_char_len = after_slash.chars().next().map(|c| c.len_utf8()).unwrap_or(1);
spans.push(Span::styled(
after_slash[..first_char_len].to_string(),
Style::default().fg(THEME.load().tool_result_color),
));
remaining = &after_slash[first_char_len..];
} else {
spans.push(Span::styled(
after_slash[..path_end].to_string(),
Style::default().fg(THEME.load().tool_label),
));
remaining = &after_slash[path_end..];
}
} else {
let path_end = remaining.find(|c: char| c.is_whitespace() || c == ':' || c == ')' || c == ']')
.unwrap_or(remaining.len());
if path_end == 0 {
let first_char_len = remaining.chars().next().map(|c| c.len_utf8()).unwrap_or(1);
spans.push(Span::styled(
remaining[..first_char_len].to_string(),
Style::default().fg(THEME.load().tool_result_color),
));
remaining = &remaining[first_char_len..];
} else {
spans.push(Span::styled(
remaining[..path_end].to_string(),
Style::default().fg(THEME.load().tool_label),
));
remaining = &remaining[path_end..];
}
}
} else {
spans.push(Span::styled(
remaining.to_string(),
Style::default().fg(THEME.load().tool_result_color),
));
break;
}
}
}
result.push(Line::from(spans));
}
result
}
pub(crate) fn try_highlight_grep_line(line: &str, margin: &str) -> Option<Vec<Span<'static>>> {
let first_colon = line.find(':')?;
let filepath = &line[..first_colon];
if !filepath.contains('/') && !filepath.contains('.') {
return None;
}
let rest = &line[first_colon + 1..];
let second_sep = rest.find([':', '-'])?;
let linenum = &rest[..second_sep];
if !linenum.chars().all(|c| c.is_ascii_digit()) || linenum.is_empty() {
return None;
}
let sep_char = rest.as_bytes()[second_sep] as char;
let content = if second_sep + 1 < rest.len() { &rest[second_sep + 1..] } else { "" };
let is_context = sep_char == '-';
Some(vec![
Span::styled(format!("{} ", margin), Style::default().fg(THEME.load().tool_result_color)),
Span::styled(filepath.to_string(), Style::default().fg(THEME.load().tool_label)),
Span::styled(":", Style::default().fg(THEME.load().muted)),
Span::styled(linenum.to_string(), Style::default().fg(THEME.load().list_bullet_color)),
Span::styled(format!("{}", sep_char), Style::default().fg(THEME.load().muted)),
Span::styled(
content.to_string(),
if is_context {
Style::default().fg(THEME.load().muted)
} else {
Style::default().fg(THEME.load().tool_result_color)
},
),
])
}
pub(crate) fn is_read_tool_output(lines: &[&str]) -> bool {
if lines.is_empty() { return false; }
let mut checked = 0;
let mut matches = 0;
for line in lines.iter().take(10) {
if line.trim().is_empty() { continue; }
checked += 1;
if let Some(tab_idx) = line.find('\t') {
if line[..tab_idx].trim().chars().all(|c| c.is_ascii_digit()) && !line[..tab_idx].trim().is_empty() {
matches += 1;
}
}
}
checked > 0 && matches * 2 >= checked }
pub(crate) fn highlight_read_output(lines: &[&str], ext: &str, margin: &str) -> Option<Vec<Line<'static>>> {
let ss = &*SYNTAX_SET;
let ts = &*THEME_SET;
let theme = &ts.themes["base16-ocean.dark"];
let syntax = if !ext.is_empty() {
ss.find_syntax_by_extension(ext).unwrap_or_else(|| ss.find_syntax_plain_text())
} else {
ss.find_syntax_plain_text()
};
if syntax.name == "Plain Text" && ext.is_empty() {
return None;
}
let mut h = HighlightLines::new(syntax, theme);
let mut result = Vec::new();
for line in lines {
let (line_num, code) = if let Some(tab_idx) = line.find('\t') {
let num = line[..tab_idx].trim();
let code = &line[tab_idx + 1..];
(num.to_string(), code)
} else {
(String::new(), *line)
};
let code_with_nl = format!("{}\n", code);
let ranges = h.highlight_line(&code_with_nl, ss).unwrap_or_default();
let mut spans = vec![
Span::styled(
format!("{} {:>4} \u{2502} ", margin, line_num),
Style::default().fg(THEME.load().muted),
),
];
for (sty, text) in ranges {
let r = (sty.foreground.r as i16 - 5).clamp(0, 255) as u8;
let g = (sty.foreground.g as i16).clamp(0, 255) as u8;
let b = (sty.foreground.b as i16 + 10).clamp(0, 255) as u8;
let fg = Color::Rgb(r, g, b);
let content = text.trim_end_matches('\n').to_string();
if !content.is_empty() {
spans.push(Span::styled(content, Style::default().fg(fg)));
}
}
result.push(Line::from(spans));
}
Some(result)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{style::Style, text::{Line, Span}};
use unicode_width::UnicodeWidthStr;
#[test]
fn clamp_line_truncates_by_display_width_not_char_count() {
let line = Line::from(vec![Span::styled("ab漢字c", Style::default())]);
let clamped = clamp_line(line, 5);
let rendered: String = clamped
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert_eq!(rendered, "ab漢");
assert_eq!(UnicodeWidthStr::width(rendered.as_str()), 4);
}
#[test]
fn clamp_line_preserves_spans_within_display_width_budget() {
let line = Line::from(vec![
Span::styled("ab", Style::default()),
Span::styled("漢", Style::default()),
Span::styled("cd", Style::default()),
]);
let clamped = clamp_line(line, 4);
let rendered: String = clamped
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert_eq!(rendered, "ab漢");
assert_eq!(UnicodeWidthStr::width(rendered.as_str()), 4);
}
#[test]
fn highlight_bash_output_handles_nbsp_in_path_context() {
let lines = vec |"];
let result = highlight_bash_output(&lines, "");
assert!(!result.is_empty());
}
#[test]
fn highlight_bash_output_handles_nbsp_before_slash() {
let line_with_nbsp = "text\u{00a0}/some/path here";
let lines = vec![line_with_nbsp];
let result = highlight_bash_output(&lines, "");
assert!(!result.is_empty());
}
#[test]
fn highlight_bash_output_handles_multibyte_at_path_boundary() {
let lines = vec!["café/menu"];
let result = highlight_bash_output(&lines, "");
assert!(!result.is_empty());
}
}