use std::collections::HashMap;
use ratatui::{
buffer::Buffer as TuiBuffer,
layout::Rect,
style::Color,
widgets::Widget,
};
use crate::editor::{
buffer::Buffer,
fold::FoldState,
highlight::{StyledSpan, find_matching_brace},
};
use super::text_area::{
self, RenderMode, TextContent,
CURRENT_LINE_BG, FOLD_CLOSED_FG, FOLD_OPEN_FG, FOLD_PLACEHOLDER,
GUTTER_CURRENT, GUTTER_NORMAL, MARKER_STYLE, TILDE_STYLE,
};
pub use super::text_area::GutterMarker;
pub use super::text_area::gutter_width;
pub struct BufferContent<'a> {
lines: Vec<String>,
highlight_cache: &'a HashMap<usize, Vec<StyledSpan>>,
}
impl<'a> BufferContent<'a> {
pub fn new(buffer: &'a Buffer, highlight_cache: &'a HashMap<usize, Vec<StyledSpan>>) -> Self {
Self { lines: buffer.lines(), highlight_cache }
}
}
impl TextContent for BufferContent<'_> {
fn line_count(&self) -> usize {
self.lines.len()
}
fn line_text(&self, idx: usize) -> &str {
&self.lines[idx]
}
fn line_highlights(&self, idx: usize) -> Option<&[StyledSpan]> {
self.highlight_cache.get(&idx).map(|v| v.as_slice())
}
}
pub struct EditorWidget<'a> {
pub buffer: &'a Buffer,
pub folds: &'a FoldState,
pub highlight_cache: &'a HashMap<usize, Vec<StyledSpan>>,
pub gutter_markers: &'a [GutterMarker],
pub git_changed_lines: &'a std::collections::HashSet<usize>,
pub search_matches: &'a [(usize, usize, usize)],
pub search_current: Option<usize>,
pub selection_spans: &'a [(usize, usize, usize)],
pub scroll_x: usize,
pub word_wrap: bool,
pub show_line_numbers: bool,
pub tab_width: usize,
pub gutter_bg: Color,
}
impl Widget for EditorWidget<'_> {
fn render(self, area: Rect, buf: &mut TuiBuffer) {
if area.height == 0 || area.width == 0 {
return;
}
let content = BufferContent::new(self.buffer, self.highlight_cache);
let lines = self.buffer.lines();
let cursor = self.buffer.cursor();
let scroll = self.buffer.scroll;
let total_lines = content.line_count();
let height = area.height as usize;
let gutter_w = text_area::gutter_width(self.show_line_numbers, total_lines);
let digit_w = if self.show_line_numbers {
total_lines.to_string().len().max(3)
} else {
0
};
if area.width as usize <= gutter_w + 4 {
return;
}
let content_x = area.x + gutter_w as u16;
let content_w = area.width as usize - gutter_w;
let brace_match = find_matching_brace(&lines, cursor.line, cursor.column);
let visible = self.folds.visible_lines(&lines);
let mut visual_row = 0usize;
for (logical, line_text) in &visible {
let logical = *logical;
if logical < scroll {
continue;
}
let screen_y = area.y + visual_row as u16;
if screen_y >= area.y + area.height {
break;
}
let is_current = logical == cursor.line;
let row_bg = if is_current {
CURRENT_LINE_BG
} else {
Color::Reset
};
let gutter_bg_color = if is_current { CURRENT_LINE_BG } else { self.gutter_bg };
for gx in area.x..content_x {
buf[(gx, screen_y)].set_bg(gutter_bg_color);
}
let (_fold_x, marker_x, _digits_x, _sep_x) = if self.show_line_numbers {
let digits_x = area.x; let marker_x = digits_x + digit_w as u16; let sep_x = marker_x + 1; let num_str = format!("{:>width$}", logical + 1, width = digit_w);
let num_style = if is_current {
GUTTER_CURRENT.bg(CURRENT_LINE_BG)
} else {
GUTTER_NORMAL.bg(self.gutter_bg)
};
for (i, ch) in num_str.chars().enumerate() {
buf[(digits_x + i as u16, screen_y)]
.set_char(ch)
.set_style(num_style);
}
buf[(sep_x, screen_y)].set_char(' ').set_style(
if is_current { GUTTER_CURRENT.bg(CURRENT_LINE_BG) } else { GUTTER_NORMAL.bg(self.gutter_bg) }
);
(marker_x, marker_x, digits_x, sep_x)
} else {
let marker_x = area.x; let sep_x = area.x + 1; let sp2_x = area.x + 2; buf[(sep_x, screen_y)].set_char(' ').set_style(
if is_current { GUTTER_CURRENT.bg(CURRENT_LINE_BG) } else { GUTTER_NORMAL.bg(self.gutter_bg) }
);
buf[(sp2_x, screen_y)].set_char(' ').set_bg(if is_current { CURRENT_LINE_BG } else { self.gutter_bg });
(marker_x, marker_x, area.x, sep_x)
};
let (fold_ch, fold_fg) = if self.folds.is_folded_header(logical) {
('›', FOLD_CLOSED_FG)
} else {
let t = line_text.trim_end();
let can_fold = t.ends_with('{')
|| t.ends_with('(')
|| t.ends_with('[')
|| (logical + 1 < total_lines
&& !line_text.trim().is_empty()
&& text_area::indent_level(&lines[logical + 1])
> text_area::indent_level(line_text));
if can_fold {
('⌄', FOLD_OPEN_FG)
} else {
(' ', Color::Reset)
}
};
let issue_marker = self
.gutter_markers
.iter()
.find(|m| m.line == logical)
.map(|m| (m.symbol, m.style))
.or_else(|| {
self.buffer
.markers
.iter()
.find(|m| m.line == logical)
.map(|m| (m.label.chars().next().unwrap_or('●'), MARKER_STYLE))
});
let git_changed = self.git_changed_lines.contains(&logical);
let cell_bg = if let Some((_, sty)) = issue_marker {
sty.bg.unwrap_or(Color::LightRed)
} else if git_changed {
Color::Rgb(45, 125, 220) } else {
gutter_bg_color
};
if let Some((sym, sty)) = issue_marker {
let (ch, fg) = if fold_ch != ' ' {
(fold_ch, fold_fg)
} else {
(sym, sty.fg.unwrap_or(Color::White))
};
buf[(marker_x, screen_y)].set_char(ch).set_fg(fg).set_bg(cell_bg);
} else {
buf[(marker_x, screen_y)]
.set_char(fold_ch)
.set_fg(fold_fg)
.set_bg(cell_bg);
}
if self.folds.is_folded_header(logical) {
let hidden = self
.folds
.folds()
.iter()
.find(|f| f.header == logical)
.map(|f| f.hidden_count())
.unwrap_or(0);
let text = format!("{} ··· {} lines folded", line_text, hidden);
text_area::render_plain(
buf,
content_x,
screen_y,
content_w,
&text,
FOLD_PLACEHOLDER,
row_bg,
);
visual_row += 1;
continue;
}
let spans: &[StyledSpan] = content.line_highlights(logical).unwrap_or(&[]);
let mode = if self.word_wrap {
RenderMode::Wrap { max_rows: (height - visual_row) as u16 }
} else {
RenderMode::Clip { scroll_x: self.scroll_x }
};
let rows_used = text_area::render_line_content(
buf,
content_x,
screen_y,
content_w,
content.line_text(logical),
spans,
row_bg,
logical,
cursor.line,
cursor.column,
brace_match,
self.search_matches,
self.search_current,
self.selection_spans,
mode,
self.tab_width,
);
for extra in 1..rows_used {
let sy = area.y + (visual_row + extra) as u16;
if sy < area.y + area.height {
let cont_bg = if is_current { CURRENT_LINE_BG } else { self.gutter_bg };
for gx in area.x..content_x {
buf[(gx, sy)].set_char(' ').set_bg(cont_bg);
}
}
}
visual_row += rows_used;
}
for vy in visual_row..height {
let screen_y = area.y + vy as u16;
for gx in area.x..content_x {
buf[(gx, screen_y)].set_char(' ').set_bg(self.gutter_bg);
}
buf[(area.x, screen_y)].set_char('~').set_style(TILDE_STYLE.bg(self.gutter_bg));
for gx in content_x..(area.x + area.width) {
buf[(gx, screen_y)].reset();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::buffer::Buffer as TuiBuffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use std::collections::{HashMap, HashSet};
fn make_widget<'a>(
ebuf: &'a crate::editor::buffer::Buffer,
folds: &'a crate::editor::fold::FoldState,
highlight_cache: &'a HashMap<usize, Vec<crate::editor::highlight::StyledSpan>>,
gutter_markers: &'a [GutterMarker],
git_changed: &'a HashSet<usize>,
) -> EditorWidget<'a> {
EditorWidget {
buffer: ebuf,
folds,
highlight_cache,
gutter_markers,
git_changed_lines: git_changed,
search_matches: &[],
search_current: None,
selection_spans: &[],
scroll_x: 0,
word_wrap: false,
show_line_numbers: true,
tab_width: 4,
gutter_bg: Color::Rgb(28, 28, 36),
}
}
fn render(
lines: Vec<&str>,
gutter_markers: &[GutterMarker],
git_changed: &HashSet<usize>,
) -> (TuiBuffer, u16, usize) {
let buf_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
let ebuf = crate::editor::buffer::Buffer::from_lines(buf_lines, None);
let folds = crate::editor::fold::FoldState::default();
let highlight_cache = HashMap::new();
let widget = make_widget(&ebuf, &folds, &highlight_cache, gutter_markers, git_changed);
let total_lines = ebuf.line_count();
let gutter_w = crate::widgets::text_area::gutter_width(true, total_lines);
let digit_w = total_lines.to_string().len().max(3);
let marker_x = digit_w as u16;
let area = Rect::new(0, 0, 80, lines.len() as u16 + 2);
let mut tui_buf = TuiBuffer::empty(area);
ratatui::widgets::Widget::render(widget, area, &mut tui_buf);
(tui_buf, marker_x, gutter_w)
}
fn cell_bg(tui_buf: &TuiBuffer, x: u16, y: u16) -> Color {
tui_buf[(x, y)].bg
}
#[test]
fn gutter_background_and_width_preserved() {
let buf_lines = vec![
"fn main() {".to_string(),
" println!(\"hello\");".to_string(),
"}".to_string(),
];
let ebuf = crate::editor::buffer::Buffer::from_lines(buf_lines, None);
let folds = crate::editor::fold::FoldState::default();
let highlight_cache = HashMap::new();
let git_changed = HashSet::new();
let widget = make_widget(&ebuf, &folds, &highlight_cache, &[], &git_changed);
let area = Rect::new(0, 0, 80, 10);
let mut tui_buf = TuiBuffer::empty(area);
ratatui::widgets::Widget::render(widget, area, &mut tui_buf);
let total_lines = ebuf.line_count();
let gutter_w = crate::widgets::text_area::gutter_width(true, total_lines);
let content_x = area.x + gutter_w as u16;
for row in 0..area.height {
let y = area.y + row;
let cell = &tui_buf[(content_x - 1, y)];
let ch = cell.symbol().chars().next().unwrap_or(' ');
assert_ne!(ch, '│', "Separator glyph still present at gutter boundary");
}
let first_line_y = area.y;
let num_pos = content_x - 1;
let cell = &tui_buf[(num_pos, first_line_y)];
let ch = cell.symbol().chars().next().unwrap_or(' ');
assert!(ch.is_ascii_digit() || ch == ' ', "Gutter rightmost cell should be a digit or space");
}
#[test]
fn git_changed_line_has_blue_marker_bg() {
let lines = vec!["unchanged", "changed", "also unchanged"];
let mut git_changed = HashSet::new();
git_changed.insert(1);
let (tui_buf, marker_x, _) = render(lines, &[], &git_changed);
let unchanged_bg = cell_bg(&tui_buf, marker_x, 0);
let changed_bg = cell_bg(&tui_buf, marker_x, 1);
let also_unchanged_bg = cell_bg(&tui_buf, marker_x, 2);
assert_eq!(changed_bg, Color::Rgb(45, 125, 220), "git-changed line should have blue bg");
assert_ne!(unchanged_bg, Color::Rgb(45, 125, 220), "unchanged line should not have git bg");
assert_ne!(also_unchanged_bg, Color::Rgb(45, 125, 220), "unchanged line should not have git bg");
}
#[test]
fn multiple_git_changed_lines() {
let lines = vec!["a", "b", "c", "d", "e"];
let mut git_changed = HashSet::new();
git_changed.insert(0);
git_changed.insert(2);
git_changed.insert(4);
let (tui_buf, marker_x, _) = render(lines, &[], &git_changed);
assert_eq!(cell_bg(&tui_buf, marker_x, 0), Color::Rgb(45, 125, 220));
assert_ne!(cell_bg(&tui_buf, marker_x, 1), Color::Rgb(45, 125, 220));
assert_eq!(cell_bg(&tui_buf, marker_x, 2), Color::Rgb(45, 125, 220));
assert_ne!(cell_bg(&tui_buf, marker_x, 3), Color::Rgb(45, 125, 220));
assert_eq!(cell_bg(&tui_buf, marker_x, 4), Color::Rgb(45, 125, 220));
}
#[test]
fn no_git_changes_no_blue_bg() {
let lines = vec!["line one", "line two"];
let git_changed = HashSet::new();
let (tui_buf, marker_x, _) = render(lines, &[], &git_changed);
for row in 0..2u16 {
assert_ne!(
cell_bg(&tui_buf, marker_x, row),
Color::Rgb(45, 125, 220),
"No git changes → no blue bg on row {row}"
);
}
}
#[test]
fn issue_marker_overrides_git_change_bg() {
let lines = vec!["error line", "normal"];
let mut git_changed = HashSet::new();
git_changed.insert(0);
let issue_bg = Color::Rgb(180, 30, 30);
let markers = vec![GutterMarker {
line: 0,
symbol: '!',
style: Style::new().fg(Color::White).bg(issue_bg),
}];
let (tui_buf, marker_x, _) = render(lines, &markers, &git_changed);
assert_eq!(
cell_bg(&tui_buf, marker_x, 0),
issue_bg,
"Issue marker bg should override git-change bg"
);
}
#[test]
fn git_changed_line_with_fold_indicator_has_blue_bg() {
let lines = vec!["fn foo() {", " x", "}"];
let mut git_changed = HashSet::new();
git_changed.insert(0);
let (tui_buf, marker_x, _) = render(lines, &[], &git_changed);
assert_eq!(
cell_bg(&tui_buf, marker_x, 0),
Color::Rgb(45, 125, 220),
"Git-changed line with fold indicator should still show blue bg"
);
}
#[test]
fn issue_marker_on_foldable_git_changed_line() {
let lines = vec!["fn bar() {", " y", "}"];
let mut git_changed = HashSet::new();
git_changed.insert(0);
let issue_bg = Color::Rgb(200, 80, 0);
let markers = vec![GutterMarker {
line: 0,
symbol: 'W',
style: Style::new().fg(Color::Black).bg(issue_bg),
}];
let (tui_buf, marker_x, _) = render(lines, &markers, &git_changed);
assert_eq!(
cell_bg(&tui_buf, marker_x, 0),
issue_bg,
"Issue marker bg should win over git bg even on foldable lines"
);
}
}