use ratatui::style::{Color, Style};
use ratatui::text::Span;
use std::collections::HashMap;
use crate::app::{JumpState, Selection};
use crate::lsp::Severity;
use crate::syntax::{self, Capture};
use crate::text_width::char_cell_width;
use crate::vcs::LineStatus;
use super::indent_guides::IndentGuide;
use super::{
EXTRA_CURSOR_BG, EXTRA_CURSOR_FG, INDENT_GUIDE_FG, JUMP_LABEL_BG, JUMP_LABEL_FG,
MATCH_BRACKET_FG, WHITESPACE_FG, search_style, sel_style,
};
pub(super) fn vcs_bar_span(status: Option<LineStatus>) -> Span<'static> {
match status {
Some(LineStatus::Added) => Span::styled("▎", Style::default().fg(Color::Green)),
Some(LineStatus::Modified) => Span::styled("▎", Style::default().fg(Color::Yellow)),
Some(LineStatus::DeletedAbove) => Span::styled("▁", Style::default().fg(Color::Red)),
None => Span::raw(" "),
}
}
pub(super) fn bookmark_sign_span() -> Span<'static> {
Span::styled("●", Style::default().fg(Color::LightMagenta))
}
pub(super) fn sign_span(sev: Option<Severity>) -> Span<'static> {
match sev {
Some(Severity::Error) => Span::styled("E", Style::default().fg(Color::Red)),
Some(Severity::Warning) => Span::styled("W", Style::default().fg(Color::Yellow)),
Some(Severity::Info) => Span::styled("I", Style::default().fg(Color::LightBlue)),
Some(Severity::Hint) => Span::styled("H", Style::default().fg(Color::DarkGray)),
None => Span::raw(" "),
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_line(
row: usize,
line: &str,
sel: Option<&Selection>,
captures: &[Capture],
extra_cols: &[usize],
search_hits: &[(usize, usize)],
jump_labels: &[(usize, char)],
bracket_cols: &[usize],
indent_guides: &[IndentGuide],
tab_width: usize,
col_scroll: usize,
viewport_width: usize,
show_whitespace: bool,
) -> Vec<Span<'static>> {
let guide_at =
|vc: usize| -> Option<IndentGuide> { indent_guides.iter().find(|g| g.col == vc).copied() };
let guide_style = |g: IndentGuide| -> Style {
if g.active {
Style::default()
.fg(Color::Reset)
.add_modifier(ratatui::style::Modifier::BOLD)
} else {
Style::default().fg(INDENT_GUIDE_FG)
}
};
let is_extra_cursor = |col: usize| -> bool { extra_cols.contains(&col) };
let is_search_hit =
|col: usize| -> bool { search_hits.iter().any(|(lo, hi)| col >= *lo && col < *hi) };
let is_match_bracket = |col: usize| -> bool { bracket_cols.contains(&col) };
let jump_label_at = |col: usize| -> Option<char> {
jump_labels
.iter()
.find_map(|(c, ch)| if *c == col { Some(*ch) } else { None })
};
let is_selected = |col: usize| -> bool {
let Some(sel) = sel else { return false };
match *sel {
Selection::Char { from, to } => {
if row < from.row || row > to.row {
return false;
}
let lo = if row == from.row { from.col } else { 0 };
if row < to.row {
col >= lo
} else {
col >= lo && col <= to.col
}
}
Selection::Line { from_row, to_row } => row >= from_row && row <= to_row,
Selection::Block { r0, c0, r1, c1 } => row >= r0 && row <= r1 && col >= c0 && col <= c1,
}
};
let chars: Vec<char> = line.chars().collect();
let viewport_right = col_scroll.saturating_add(viewport_width);
let max_guide_col = indent_guides.iter().map(|g| g.col).max();
if chars.is_empty() {
let cursor_cell_style = {
let mut style = Style::default();
if is_selected(0) {
style = style.patch(sel_style());
}
if is_extra_cursor(0) {
style = extra_cursor_style(style);
}
style
};
let emit_until = max_guide_col.map(|m| m + 1).unwrap_or(0).max(
if cursor_cell_style != Style::default() {
1
} else {
0
},
);
if emit_until == 0 || col_scroll >= emit_until {
return Vec::new();
}
let mut spans: Vec<Span<'static>> = Vec::new();
let mut buf = String::new();
let mut buf_style = Style::default();
let mut started = false;
for vc in col_scroll..emit_until {
if viewport_width > 0 && vc >= viewport_right {
break;
}
let base_style = if vc == 0 {
cursor_cell_style
} else {
Style::default()
};
let (ch, style) = if let Some(g) = guide_at(vc) {
(g.glyph, base_style.patch(guide_style(g)))
} else {
(' ', base_style)
};
if !started {
buf_style = style;
started = true;
} else if style != buf_style {
if !buf.is_empty() {
spans.push(Span::styled(std::mem::take(&mut buf), buf_style));
}
buf_style = style;
}
buf.push(ch);
}
if !buf.is_empty() {
spans.push(Span::styled(buf, buf_style));
}
return spans;
}
let mut base: Vec<Style> = vec![Style::default(); chars.len()];
for cap in captures {
if cap.end_row < row || cap.start_row > row {
continue;
}
let lo = if cap.start_row == row {
cap.start_col
} else {
0
};
let hi = if cap.end_row == row {
cap.end_col.min(chars.len())
} else {
chars.len()
};
if lo >= hi {
continue;
}
let style = syntax::style_for(&cap.name);
for slot in base.iter_mut().take(hi).skip(lo) {
*slot = slot.patch(style);
}
}
let sel = sel_style();
let search = search_style();
let style_at = |col: usize| -> Style {
let mut s = base[col];
if is_search_hit(col) {
s = s.patch(search);
}
if is_selected(col) {
s = s.patch(sel);
}
if is_extra_cursor(col) {
s = extra_cursor_style(s);
}
if is_match_bracket(col) {
s = s
.fg(MATCH_BRACKET_FG)
.add_modifier(ratatui::style::Modifier::BOLD);
}
s
};
let cell_at = |col: usize| -> (char, Style) {
if let Some(label) = jump_label_at(col) {
return (
label,
Style::default()
.fg(JUMP_LABEL_FG)
.bg(JUMP_LABEL_BG)
.add_modifier(ratatui::style::Modifier::BOLD),
);
}
let original = chars[col];
let style = style_at(col);
if show_whitespace {
match original {
' ' => return ('·', style.fg(WHITESPACE_FG)),
'\t' => return ('→', style.fg(WHITESPACE_FG)),
_ => {}
}
}
(original, style)
};
let mut spans: Vec<Span<'static>> = Vec::new();
let mut buf = String::new();
let mut buf_style = Style::default();
let mut visual_col = 0usize;
let mut started = false;
let push_cell = |spans: &mut Vec<Span<'static>>,
buf: &mut String,
buf_style: &mut Style,
started: &mut bool,
ch: char,
style: Style| {
if !*started {
*buf_style = style;
*started = true;
} else if style != *buf_style {
if !buf.is_empty() {
spans.push(Span::styled(std::mem::take(buf), *buf_style));
}
*buf_style = style;
}
buf.push(ch);
};
for (col, &original) in chars.iter().enumerate() {
let (ch, style) = cell_at(col);
let width = if original == '\t' {
tab_width - (visual_col % tab_width)
} else {
char_cell_width(original)
};
let cell_start = visual_col;
let cell_end = visual_col + width;
visual_col = cell_end;
if viewport_width > 0 && cell_start >= viewport_right {
break;
}
if cell_end <= col_scroll {
continue;
}
let is_ws = original == ' ' || original == '\t';
let mut displaced_arrow: Option<Style> = None;
for k in 0..width {
let vc = cell_start + k;
if vc < col_scroll {
continue;
}
if viewport_width > 0 && vc >= viewport_right {
break;
}
let jump_lead =
original == '\t' && k == 0 && ch != '\t' && jump_label_at(col).is_some();
let guide = if is_ws && !jump_lead {
guide_at(vc)
} else {
None
};
let (out_ch, out_style) = if let Some(g) = guide {
if original == '\t' && k == 0 && ch != '\t' {
displaced_arrow = Some(style);
}
(g.glyph, style.patch(guide_style(g)))
} else if original == '\t' {
if k == 0 && ch != '\t' {
(ch, style)
} else if let Some(arrow_style) = displaced_arrow.take() {
('→', arrow_style)
} else {
(' ', style)
}
} else if k == 0 {
(ch, style)
} else {
if cell_start >= col_scroll {
continue;
}
(' ', style)
};
push_cell(
&mut spans,
&mut buf,
&mut buf_style,
&mut started,
out_ch,
out_style,
);
}
}
if let Some(m) = max_guide_col
&& visual_col <= m
{
for vc in visual_col.max(col_scroll)..=m {
if viewport_width > 0 && vc >= viewport_right {
break;
}
let (ch, style) = match guide_at(vc) {
Some(g) => (g.glyph, guide_style(g)),
None => (' ', Style::default()),
};
push_cell(
&mut spans,
&mut buf,
&mut buf_style,
&mut started,
ch,
style,
);
}
visual_col = visual_col.max(m + 1);
}
if !buf.is_empty() {
spans.push(Span::styled(buf, buf_style));
}
if is_extra_cursor(chars.len())
&& visual_col >= col_scroll
&& (viewport_width == 0 || visual_col < viewport_right)
{
spans.push(Span::styled(
" ".to_string(),
extra_cursor_style(Style::default()),
));
}
spans
}
pub(super) fn extra_cursor_style(base: Style) -> Style {
base.bg(EXTRA_CURSOR_BG).fg(EXTRA_CURSOR_FG)
}
pub(super) fn build_jump_overlay(state: Option<&JumpState>) -> HashMap<(usize, usize), char> {
let mut out = HashMap::new();
let Some(s) = state else { return out };
match s.typed_first {
None => {
for label in &s.labels {
out.insert((label.pos.row, label.pos.col), label.first);
if let Some(c2) = label.second {
out.insert((label.pos.row, label.pos.col + 1), c2);
}
}
}
Some(first) => {
for label in &s.labels {
if label.first != first {
continue;
}
if let Some(c2) = label.second {
out.insert((label.pos.row, label.pos.col), c2);
}
}
}
}
out
}
pub(super) fn conflict_captures(
lines: &[String],
hunks: &[crate::editor::conflict::Hunk],
lo: usize,
hi: usize,
) -> Vec<Capture> {
fn push(
caps: &mut Vec<Capture>,
lines: &[String],
row: usize,
lo: usize,
hi: usize,
name: &str,
) {
if row < lo || row >= hi {
return;
}
caps.push(Capture {
start_row: row,
start_col: 0,
end_row: row,
end_col: lines[row].chars().count(),
name: name.to_string(),
});
}
let mut caps = Vec::new();
for h in hunks {
if h.end < lo || h.start >= hi {
continue;
}
push(&mut caps, lines, h.start, lo, hi, "conflict.marker.ours");
for r in h.ours() {
push(&mut caps, lines, r, lo, hi, "conflict.ours");
}
if let Some(base_region) = h.base_region() {
push(
&mut caps,
lines,
base_region.start - 1,
lo,
hi,
"conflict.marker",
);
for r in base_region {
push(&mut caps, lines, r, lo, hi, "conflict.base");
}
}
push(&mut caps, lines, h.sep, lo, hi, "conflict.marker");
for r in h.theirs() {
push(&mut caps, lines, r, lo, hi, "conflict.theirs");
}
push(&mut caps, lines, h.end, lo, hi, "conflict.marker.theirs");
}
caps
}
pub(super) fn find_matches_in_line(line: &str, query: &str) -> Vec<(usize, usize)> {
if query.is_empty() {
return Vec::new();
}
let q_chars = query.chars().count();
let mut hits = Vec::new();
let mut search_from = 0;
while let Some(byte_idx) = line[search_from..].find(query) {
let abs_byte = search_from + byte_idx;
let start_col = line[..abs_byte].chars().count();
hits.push((start_col, start_col + q_chars));
search_from = abs_byte + query.len();
if search_from >= line.len() {
break;
}
}
hits
}
#[cfg(test)]
mod tests {
use super::*;
fn lines(s: &str) -> Vec<String> {
s.split('\n').map(str::to_string).collect()
}
#[test]
fn conflict_captures_map_each_row_to_its_scope() {
let l = lines(
"before\n\
<<<<<<< ours\n\
mine\n\
=======\n\
yours\n\
>>>>>>> theirs\n\
after",
);
let hunks = crate::editor::conflict::hunks(&l);
let caps = conflict_captures(&l, &hunks, 0, l.len());
let by_row: Vec<(usize, &str)> = caps
.iter()
.map(|c| (c.start_row, c.name.as_str()))
.collect();
assert_eq!(
by_row,
vec![
(1, "conflict.marker.ours"),
(2, "conflict.ours"),
(3, "conflict.marker"),
(4, "conflict.theirs"),
(5, "conflict.marker.theirs"),
]
);
let sep = caps.iter().find(|c| c.start_row == 3).unwrap();
assert_eq!((sep.start_col, sep.end_col), (0, "=======".chars().count()));
}
#[test]
fn conflict_captures_clip_to_the_visible_window() {
let l = lines("<<<<<<<\nmine\n=======\nyours\n>>>>>>>");
let hunks = crate::editor::conflict::hunks(&l);
let caps = conflict_captures(&l, &hunks, 0, 2);
let rows: Vec<usize> = caps.iter().map(|c| c.start_row).collect();
assert_eq!(rows, vec![0, 1]);
}
}