use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
use super::{
cursor_bar,
text_cells::{eof_no_newline_span, wrap_at_chars},
};
use crate::git::LineKind;
#[derive(Clone, Copy, PartialEq, Eq)]
enum SearchHl {
None,
Other,
Current,
}
fn classify_chars_by_match(content: &str, matches: &[(usize, usize, bool)]) -> Vec<SearchHl> {
let n = content.chars().count();
let mut out = vec![SearchHl::None; n];
if matches.is_empty() {
return out;
}
for (char_idx, (byte_pos, _)) in content.char_indices().enumerate() {
for &(bs, be, is_current) in matches {
if byte_pos >= bs && byte_pos < be {
let new_kind = if is_current {
SearchHl::Current
} else {
SearchHl::Other
};
if out[char_idx] != SearchHl::Current || is_current {
out[char_idx] = new_kind;
}
}
}
}
out
}
fn apply_search_overlay(base: Style, fg: Color, hl: SearchHl) -> Style {
match hl {
SearchHl::None => base.fg(fg),
SearchHl::Other => base
.fg(fg)
.remove_modifier(Modifier::DIM)
.add_modifier(Modifier::UNDERLINED | Modifier::BOLD),
SearchHl::Current => Style::default()
.bg(Color::Yellow)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
}
}
#[allow(clippy::too_many_arguments)]
#[cfg(test)]
pub(super) fn render_diff_line_wrapped(
line: &crate::git::DiffLine,
is_selected: bool,
cursor_sub: Option<usize>,
body_width: usize,
hl: Option<&crate::highlight::Highlighter>,
file_path: Option<&std::path::Path>,
bg_added: Color,
bg_deleted: Color,
search_matches: &[(usize, usize, bool)],
) -> Vec<Line<'static>> {
render_diff_line_wrapped_with_tokens(
line,
is_selected,
cursor_sub,
body_width,
hl,
file_path,
bg_added,
bg_deleted,
search_matches,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_diff_line_wrapped_with_tokens(
line: &crate::git::DiffLine,
is_selected: bool,
cursor_sub: Option<usize>,
body_width: usize,
hl: Option<&crate::highlight::Highlighter>,
file_path: Option<&std::path::Path>,
bg_added: Color,
bg_deleted: Color,
search_matches: &[(usize, usize, bool)],
document_tokens: Option<&[crate::highlight::HlToken]>,
) -> Vec<Line<'static>> {
use unicode_width::UnicodeWidthStr;
let bg = match line.kind {
LineKind::Added => Some(bg_added),
LineKind::Deleted => Some(bg_deleted),
LineKind::Context => None,
};
let base_style = match (bg, is_selected) {
(Some(b), true) => Style::default().bg(b),
(Some(b), false) => Style::default().bg(b).add_modifier(Modifier::DIM),
(None, true) => Style::default(),
(None, false) => Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
};
let char_fgs = per_char_fg(&line.content, hl, file_path, document_tokens);
let char_hls = classify_chars_by_match(&line.content, search_matches);
let chunks = wrap_at_chars(&line.content, body_width.max(1));
let last_idx = chunks.len().saturating_sub(1);
let mut char_offset = 0usize;
let cursor_line = cursor_sub.map(|s| s.min(last_idx));
chunks
.into_iter()
.enumerate()
.map(|(i, chunk)| {
let is_last = i == last_idx;
let bar = cursor_bar(cursor_line == Some(i), is_selected);
let marker_reserve = if is_last && !line.has_trailing_newline {
1
} else {
0
};
let chunk_char_count = chunk.chars().count();
let chunk_cell_count = UnicodeWidthStr::width(chunk);
let pad = body_width.saturating_sub(chunk_cell_count + marker_reserve);
let mut spans = vec![bar];
let chunk_fgs = &char_fgs[char_offset..char_offset + chunk_char_count];
let chunk_hls = &char_hls[char_offset..char_offset + chunk_char_count];
let chunk_chars: Vec<char> = chunk.chars().collect();
let mut run_start = 0usize;
while run_start < chunk_chars.len() {
let run_attr = (chunk_fgs[run_start], chunk_hls[run_start]);
let run_end = (run_start + 1..chunk_chars.len())
.find(|&j| (chunk_fgs[j], chunk_hls[j]) != run_attr)
.unwrap_or(chunk_chars.len());
let text: String = chunk_chars[run_start..run_end].iter().collect();
let style = apply_search_overlay(base_style, run_attr.0, run_attr.1);
spans.push(Span::styled(text, style));
run_start = run_end;
}
if pad > 0 {
spans.push(Span::styled(" ".repeat(pad), base_style));
}
if is_last && !line.has_trailing_newline {
spans.push(eof_no_newline_span(bg));
}
char_offset += chunk_char_count;
Line::from(spans)
})
.collect()
}
#[allow(clippy::too_many_arguments)]
#[cfg(test)]
pub(super) fn render_diff_line(
line: &crate::git::DiffLine,
is_selected: bool,
is_cursor: bool,
body_width: usize,
hl: Option<&crate::highlight::Highlighter>,
file_path: Option<&std::path::Path>,
bg_added: Color,
bg_deleted: Color,
search_matches: &[(usize, usize, bool)],
) -> Line<'static> {
render_diff_line_with_tokens(
line,
is_selected,
is_cursor,
body_width,
hl,
file_path,
bg_added,
bg_deleted,
search_matches,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_diff_line_with_tokens(
line: &crate::git::DiffLine,
is_selected: bool,
is_cursor: bool,
body_width: usize,
hl: Option<&crate::highlight::Highlighter>,
file_path: Option<&std::path::Path>,
bg_added: Color,
bg_deleted: Color,
search_matches: &[(usize, usize, bool)],
document_tokens: Option<&[crate::highlight::HlToken]>,
) -> Line<'static> {
let bg = match line.kind {
LineKind::Added => Some(bg_added),
LineKind::Deleted => Some(bg_deleted),
LineKind::Context => None,
};
let bar = cursor_bar(is_cursor, is_selected);
let base_style = match (bg, is_selected) {
(Some(b), true) => Style::default().bg(b),
(Some(b), false) => Style::default().bg(b).add_modifier(Modifier::DIM),
(None, true) => Style::default(),
(None, false) => Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
};
let char_fgs = per_char_fg(&line.content, hl, file_path, document_tokens);
let char_hls = classify_chars_by_match(&line.content, search_matches);
let eof_marker = !line.has_trailing_newline;
let body_budget = if eof_marker {
body_width.saturating_sub(1)
} else {
body_width
};
use unicode_width::UnicodeWidthChar;
let mut spans = vec![bar];
let mut cells_emitted = 0usize;
let chars: Vec<char> = line.content.chars().collect();
let mut run_start = 0usize;
while run_start < chars.len() {
let run_attr = (char_fgs[run_start], char_hls[run_start]);
let mut run_end = run_start + 1;
let mut run_cells = chars[run_start].width().unwrap_or(0);
if cells_emitted + run_cells > body_budget {
break;
}
while run_end < chars.len() {
let candidate_attr = (char_fgs[run_end], char_hls[run_end]);
if candidate_attr != run_attr {
break;
}
let w = chars[run_end].width().unwrap_or(0);
if cells_emitted + run_cells + w > body_budget {
break;
}
run_cells += w;
run_end += 1;
}
let text: String = chars[run_start..run_end].iter().collect();
let style = apply_search_overlay(base_style, run_attr.0, run_attr.1);
spans.push(Span::styled(text, style));
cells_emitted += run_cells;
run_start = run_end;
if cells_emitted >= body_budget {
break;
}
}
if cells_emitted < body_budget {
spans.push(Span::styled(
" ".repeat(body_budget - cells_emitted),
base_style,
));
}
if eof_marker {
spans.push(eof_no_newline_span(bg));
}
Line::from(spans)
}
fn per_char_fg(
content: &str,
hl: Option<&crate::highlight::Highlighter>,
file_path: Option<&std::path::Path>,
document_tokens: Option<&[crate::highlight::HlToken]>,
) -> Vec<Color> {
let n = content.chars().count();
if let Some(tokens) = document_tokens
&& let Some(colors) = token_char_colors(tokens, n)
{
return colors;
}
if let (Some(hl), Some(path)) = (hl, file_path) {
let tokens = hl.highlight_line(content, path);
if (tokens.len() > 1 || tokens.first().is_some_and(|t| t.fg != Color::Reset))
&& let Some(colors) = token_char_colors(&tokens, n)
{
return colors;
}
}
vec![Color::Reset; n]
}
fn token_char_colors(
tokens: &[crate::highlight::HlToken],
expected_chars: usize,
) -> Option<Vec<Color>> {
let mut out = Vec::with_capacity(expected_chars);
for tok in tokens {
for _ in tok.text.chars() {
out.push(tok.fg);
}
}
(out.len() == expected_chars).then_some(out)
}