use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::editor::{Editor, LayoutDir};
use super::diff;
use super::{class_color, dim_color, in_range, severity_color};
use super::{ACCENT, BASE, FLASH_BG, MUTED, PREVIEW_BG, SELECT_BG, TEXT};
pub(crate) const GUTTER: u16 = 5;
fn pad_row(mut line: Line<'static>, width: u16) -> Line<'static> {
let used = line.width() as u16;
if used < width {
line.spans
.push(Span::raw(" ".repeat((width - used) as usize)));
}
line
}
struct PaneView {
doc: strop_core::id::DocumentId,
cursor: usize,
view_top: usize,
overlays: bool,
}
pub(crate) fn render_panes(editor: &mut Editor, frame: &mut Frame, area: Rect) -> Rect {
let n = editor.panes.len();
let is_row = editor.layout == LayoutDir::Row;
let total_w = area.width as usize;
let total_h = area.height as usize - 1; let dividers = n - 1;
let (mut x, mut y) = (area.x, area.y);
let mut active_rect = Rect {
x,
y,
width: total_w as u16,
height: total_h as u16,
};
for i in 0..n {
let (w, h): (u16, u16) = if is_row {
let w = ((total_w - dividers) / n) as u16;
let w = if i == n - 1 {
(total_w - dividers) as u16 - w * (n as u16 - 1)
} else {
w
};
(w, total_h as u16)
} else {
let h = ((total_h - dividers) / n) as u16;
let h = if i == n - 1 {
(total_h - dividers) as u16 - h * (n as u16 - 1)
} else {
h
};
(total_w as u16, h)
};
let rect = Rect {
x,
y,
width: w,
height: h,
};
let view = if i == editor.active_pane {
PaneView {
doc: editor.current(),
cursor: editor.head(),
view_top: editor.view_top(),
overlays: true,
}
} else {
let pane = &editor.panes[i];
PaneView {
doc: if editor.docs.get(pane.doc).is_some() {
pane.doc
} else {
editor.current()
},
cursor: pane.sels.primary().head,
view_top: pane.view_top,
overlays: false,
}
};
render_pane(editor, frame, rect, &view);
if i == editor.active_pane {
active_rect = rect;
render_extra_cursors(editor, frame, rect, &view);
} else {
render_static_caret(editor, frame, rect, &view);
}
if i < n - 1 {
if is_row {
let dx = x + w;
for dy in y..y + h {
let cell = &mut frame.buffer_mut()[(dx, dy)];
cell.set_symbol("│");
cell.set_fg(Color::Rgb(0x3a, 0x3d, 0x4d));
}
x = dx + 1;
} else {
let dy = y + h;
for dx in x..x + w {
let cell = &mut frame.buffer_mut()[(dx, dy)];
cell.set_symbol("─");
cell.set_fg(Color::Rgb(0x3a, 0x3d, 0x4d));
}
y = dy + 1;
}
}
}
active_rect
}
fn render_static_caret(editor: &Editor, frame: &mut Frame, area: Rect, view: &PaneView) {
let buf = &editor.doc(view.doc).buf;
let line = buf.line_of(view.cursor);
let row = line.saturating_sub(view.view_top) as u16;
let gutter = diff::left_inset(editor, view.doc) as u16;
let col = gutter + buf.cell_col_of(view.cursor);
if row < area.height && col < area.width {
let cell = &mut frame.buffer_mut()[(area.x + col, area.y + row)];
cell.set_bg(Color::Rgb(0x3a, 0x3d, 0x4d));
}
}
fn render_extra_cursors(editor: &Editor, frame: &mut Frame, area: Rect, view: &PaneView) {
if view.doc != editor.current() || editor.extra_selections().is_empty() {
return;
}
let buf = &editor.doc(view.doc).buf;
let inset = diff::left_inset(editor, view.doc) as u16;
for c in editor.extra_selections().iter().map(|s| s.head) {
let line = buf.line_of(c);
if line < view.view_top {
continue;
}
let row = (line - view.view_top) as u16;
let col = inset + buf.cell_col_of(c);
if row < area.height && col < area.width {
let cell = &mut frame.buffer_mut()[(area.x + col, area.y + row)];
cell.set_bg(TEXT);
cell.set_fg(BASE);
}
}
}
fn render_pane(editor: &mut Editor, frame: &mut Frame, area: Rect, view: &PaneView) {
let text_rows = area.height as usize;
let (cur_line, first_byte, last_byte, rope) = {
let buf = &editor.doc(view.doc).buf;
let last_line = (view.view_top + text_rows).min(buf.len_lines());
(
buf.line_of(view.cursor),
buf.line_start(view.view_top),
buf.line_end(last_line.saturating_sub(1)),
buf.rope.clone(),
)
};
let syn_spans: Vec<strop_syntax::Span> =
match editor.docs.get_mut(view.doc).map(|d| &mut d.highlighter) {
Some(h) => h
.as_mut()
.map(|h| h.highlight(&rope, first_byte, last_byte))
.unwrap_or_default(),
None => Vec::new(),
};
let buf = &editor.doc(view.doc).buf;
let surface = editor.doc(view.doc).surface.as_ref();
let block = if view.overlays {
editor.block_rect_pub()
} else {
None
};
let mut row_style = RowStyle {
syn_spans: &syn_spans,
preview: if view.overlays {
editor
.preview()
.map(|(ranges, _)| ranges)
.unwrap_or_default()
} else {
Vec::new()
},
flash: view.overlays.then(|| editor.flash_range()).flatten(),
selection: view.overlays.then(|| editor.visual_range()).flatten(),
block,
search_hits: &[],
find: view.overlays.then(|| editor.find_candidates()).flatten(),
diags: Vec::new(),
emphasis: None,
diff_line: None,
};
let search_hits: Vec<usize> = if view.overlays {
if let Some(p) = editor.search_pattern() {
strop_grammar::search_all(editor.buf(), p)
} else if let Some(ls) = &editor.last_search {
let mut hits = strop_grammar::search_all(editor.buf(), &ls.pattern);
if ls.whole_word {
let word_char = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
let len = ls.pattern.len();
let buf = editor.buf();
hits.retain(|&h| {
let before_ok = h == 0 || !word_char(buf.byte(h - 1));
let after_ok = h + len >= buf.len_bytes() || !word_char(buf.byte(h + len));
before_ok && after_ok
});
}
hits
} else {
Vec::new()
}
} else {
Vec::new()
};
row_style.search_hits = &search_hits;
let mut lines: Vec<Line> = Vec::with_capacity(text_rows);
let diff_digits = diff_digits(surface);
let (sidebar, sidebar_focused) = match surface {
Some(crate::editor::Surface::Diff {
commit: Some(cf),
label,
sidebar_focus,
..
}) => (Some((cf.files.as_slice(), label.as_str())), *sidebar_focus),
_ => (None, false),
};
let sidebar_w = sidebar.map_or(0, |(files, _)| diff::sidebar_width(files) + 1);
let blame = editor.blame_gutter_for(view.doc);
let blame_w = if blame.is_some() { diff::BLAME_W } else { 0 };
let content_width = area.width.saturating_sub((sidebar_w + blame_w) as u16);
let mut help_section = String::new();
for row in 0..text_rows {
let line_idx = view.view_top + row;
let mut left: Vec<Span> = sidebar
.map(|(files, label)| diff::sidebar_spans(files, label, line_idx, sidebar_focused))
.unwrap_or_default();
if let Some(gutter) = blame {
left.push(match gutter.lines.get(line_idx) {
Some(bl) => {
let repeats_prev = line_idx > 0
&& gutter
.lines
.get(line_idx - 1)
.is_some_and(|p| p.sha == bl.sha && p.author == bl.author);
if repeats_prev {
diff::blame_blank()
} else {
diff::blame_spans(bl)
}
}
None => diff::blame_blank(),
});
}
if line_idx > buf.last_content_line() {
left.push(Span::styled("~", Style::default().fg(MUTED)));
lines.push(pad_row(Line::from(left), area.width));
continue;
}
let start = buf.line_start(line_idx);
let text = buf.line_text(line_idx);
match diff::diff_row(surface, line_idx) {
Some(diff::DiffRow::Stats | diff::DiffRow::HunkHeader) => {
let mut line = diff::structural_row(surface.unwrap(), line_idx, content_width);
line.spans.splice(0..0, left);
lines.push(pad_row(line, area.width));
continue;
}
Some(diff::DiffRow::Line(dl)) => {
let mut spans = diff::diff_gutter(dl, line_idx == cur_line, diff_digits);
row_style.diff_line = Some(dl);
row_style.emphasis = diff::emphasis_span(surface, line_idx);
spans.extend(content_spans(
editor,
view,
start,
&text,
&row_style,
content_width,
));
left.extend(spans);
lines.push(pad_row(Line::from(left), area.width));
continue;
}
None => {}
}
let num_style = if line_idx == cur_line {
Style::default().fg(ACCENT)
} else {
Style::default().fg(MUTED)
};
let (bar, bar_color) = gutter_mark(editor, view, line_idx);
left.push(Span::styled(
bar,
Style::default().fg(bar_color).add_modifier(Modifier::BOLD),
));
left.push(Span::styled(format!("{:>3} ", line_idx + 1), num_style));
row_style.diff_line = None;
row_style.emphasis = None;
row_style.diags = if view.overlays {
editor.diag_ranges_at(view.doc, line_idx + 1)
} else {
Vec::new()
};
if let Some(content) = diff::surface_content_spans(surface, line_idx, content_width) {
left.extend(content);
} else if buf.name.as_deref() == Some("help") {
if text.starts_with('[') && text.ends_with(']') {
help_section = text.trim_matches(['[', ']']).to_string();
}
left.extend(super::help::row_spans(&text, &help_section, content_width));
} else {
left.extend(content_spans(
editor,
view,
start,
&text,
&row_style,
content_width,
));
}
if view.overlays && line_idx == cur_line {
if let Some((sev, msg)) = editor.diag_message_at(view.doc, line_idx + 1) {
let shown: String = msg.replace('\n', " · ").chars().take(80).collect();
left.push(Span::styled(
format!(" ▍ {shown}"),
Style::default()
.fg(dim_color(severity_color(sev)))
.add_modifier(Modifier::ITALIC),
));
}
}
lines.push(pad_row(Line::from(left), area.width));
}
frame.render_widget(
Paragraph::new(lines).style(Style::default().bg(BASE)),
Rect {
height: text_rows as u16,
..area
},
);
}
fn diff_digits(surface: Option<&crate::editor::Surface>) -> usize {
let width = diff::gutter_width(surface);
if width == super::buffer::GUTTER as usize {
3
} else {
(width - 3) / 2
}
}
fn gutter_mark(editor: &Editor, view: &PaneView, line_idx: usize) -> (&'static str, Color) {
if let Some(sev) = editor.diag_severity_at(view.doc, line_idx + 1) {
return ("●", severity_color(sev));
}
if view.doc == editor.current() {
if editor.sign_at(line_idx + 1).is_none() && editor.sign_at_staged(line_idx + 1) {
return ("▎", dim_color(Color::Rgb(0xa9, 0xc4, 0x7c)));
}
match editor.sign_at(line_idx + 1) {
Some('+') => return ("▎", Color::Rgb(0xa9, 0xc4, 0x7c)),
Some('~') => return ("▎", ACCENT),
Some('-') => return ("▎", Color::Rgb(0xe8, 0x67, 0x7a)),
_ => {}
}
}
(" ", MUTED)
}
#[derive(Default)]
struct RowStyle<'a> {
syn_spans: &'a [strop_syntax::Span],
preview: Vec<strop_core::Range>,
flash: Option<strop_core::Range>,
selection: Option<strop_core::Range>,
block: Option<(usize, usize, u16, u16)>,
search_hits: &'a [usize],
find: Option<(u8, bool)>,
diags: Vec<(usize, usize, u8)>,
diff_line: Option<&'a strop_git::DiffLine>,
emphasis: Option<(usize, usize)>,
}
fn content_spans(
editor: &Editor,
view: &PaneView,
start: usize,
text: &str,
style: &RowStyle,
width: u16,
) -> Vec<Span<'static>> {
let buf = &editor.doc(view.doc).buf;
let cur_line = buf.line_of(view.cursor);
let lead_ws = if editor.config.indent_guides {
text.chars().take_while(|c| *c == ' ').count()
} else {
0
};
let tab = editor.config.tab_size.max(1);
let syn_spans = style.syn_spans;
let mut syn_idx = syn_spans.partition_point(|s| s.end <= start);
let pat_len = editor
.search_pattern()
.map(str::len)
.or_else(|| editor.last_search.as_ref().map(|ls| ls.pattern.len()))
.unwrap_or(0);
let mut spans = Vec::with_capacity(text.len() / 2 + 4);
let mut chars = 0usize;
let trimmed = text.strip_suffix('\n').unwrap_or(text);
for (i, ch) in unicode_segmentation::UnicodeSegmentation::grapheme_indices(trimmed, true) {
let pos = start + i;
while syn_idx < syn_spans.len() && syn_spans[syn_idx].end <= pos {
syn_idx += 1;
}
let mut cell = Style::default().fg(TEXT);
if let Some(dl) = style.diff_line {
cell = cell.fg(diff::origin_fg(dl.origin));
if syn_idx < syn_spans.len() && syn_spans[syn_idx].start <= pos {
cell = cell.fg(class_color(syn_spans[syn_idx].class));
}
if let Some(bg) = diff::origin_bg(dl.origin) {
cell = cell.bg(bg);
}
if let Some((s, e)) = style.emphasis {
if s <= i && i < e {
let strong = match dl.origin {
strop_git::LineOrigin::Addition => diff::ADD_STRONG_BG,
_ => diff::DEL_STRONG_BG,
};
cell = cell.bg(strong).add_modifier(Modifier::BOLD);
}
}
} else if syn_idx < syn_spans.len() && syn_spans[syn_idx].start <= pos {
let class = syn_spans[syn_idx].class;
cell = cell.fg(class_color(class));
if class == strop_syntax::Class::Comment {
cell = cell.add_modifier(Modifier::ITALIC);
}
}
if let Some((_, _, sev)) = style.diags.iter().find(|(c, e, _)| *c <= i && i < *e) {
cell = cell
.add_modifier(Modifier::UNDERLINED)
.underline_color(severity_color(*sev));
}
let selected = if let Some((la, lh, cl, cr)) = style.block {
let line_idx = buf.line_of(pos);
(la..=lh).contains(&line_idx) && {
let layout = strop_core::layout::LineLayout::build(trimmed, 8);
let cell = layout.cell_at_byte(pos - start);
cl <= cell && cell <= cr
}
} else {
style.selection.is_some_and(|r| in_range(r, pos))
};
if selected {
cell = cell.bg(SELECT_BG);
}
if pat_len > 0 {
if let Some(h) = style
.search_hits
.iter()
.find(|&&h| pos >= h && pos < h + pat_len)
{
cell = cell.fg(ACCENT).add_modifier(Modifier::BOLD);
let is_current = view.cursor >= *h && view.cursor < h + pat_len;
if is_current {
cell = cell.add_modifier(Modifier::UNDERLINED);
}
}
}
if let Some((_, backward)) = style.find {
let on_line = buf.line_of(pos) == cur_line;
let ahead = if backward {
pos < view.cursor
} else {
pos > view.cursor
};
if on_line && ahead && !ch.chars().all(|c| c.is_whitespace()) {
cell = cell.fg(ACCENT).add_modifier(Modifier::BOLD);
}
}
if style.preview.iter().any(|&r| in_range(r, pos)) {
cell = cell.fg(ACCENT).bg(PREVIEW_BG);
}
if style.flash.is_some_and(|r| in_range(r, pos)) {
cell = cell.bg(FLASH_BG);
}
let is_guide = i < lead_ws && (i + 1) % tab == 0;
if is_guide {
spans.push(Span::styled("│", cell.fg(Color::Rgb(0x2e, 0x30, 0x42))));
} else {
spans.push(Span::styled(ch.to_string(), cell));
}
chars += 1;
}
if let Some(dl) = style.diff_line {
if let Some(bg) = diff::origin_bg(dl.origin) {
let used = diff::gutter_width(editor.doc(view.doc).surface.as_ref()) + chars;
let pad = (width as usize).saturating_sub(used);
if pad > 0 {
spans.push(Span::styled(
" ".repeat(pad),
Style::default().fg(bg).bg(bg),
));
}
}
}
spans
}
#[cfg(test)]
mod tests {
use crate::editor::Editor;
use strop_core::Buffer;
#[test]
fn cursor_line_shows_eol_diagnostic() {
let mut e = Editor::new(Buffer::from_text("let x = 1;\n"));
let rel = "strop-eol-diag-test.rs";
e.buf_mut().path = Some(rel.into());
let abs = e.cwd.join(rel);
e.diags.insert(
abs,
vec![strop_lsp::Diag {
line: 0,
col: 4,
severity: 1,
end_line: 0,
end_col: 8,
message: "mismatched types".into(),
}],
);
let frame = crate::headless::frame_string(&mut e, 60, 10);
assert!(frame.contains("●"), "gutter sign: {frame}");
assert!(frame.contains("▍ mismatched types"), "eol note: {frame}");
}
#[test]
fn switching_buffers_leaves_no_stale_cells() {
let dir = tempfile::tempdir().unwrap();
let wide = dir.path().join("wide.txt");
let narrow = dir.path().join("narrow.txt");
let junk = format!("{}\n", ">".repeat(60)).repeat(30);
std::fs::write(&wide, &junk).unwrap();
std::fs::write(&narrow, "hi\n").unwrap();
let mut e = Editor::new(Buffer::from_text(""));
e.open_buffer(wide.to_str().unwrap()).unwrap();
e.open_buffer(narrow.to_str().unwrap()).unwrap();
let backend = ratatui::backend::TestBackend::new(40, 12);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let draw =
|e: &mut Editor, terminal: &mut ratatui::Terminal<ratatui::backend::TestBackend>| {
terminal.draw(|f| crate::render::render(e, f)).unwrap();
let buf = terminal.backend().buffer();
(0..12)
.map(|y| {
(0..40)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
};
let wide_id = e
.mru
.iter()
.copied()
.find(|&id| {
e.doc(id)
.buf
.path
.as_deref()
.is_some_and(|p| p.ends_with("wide.txt"))
})
.unwrap();
e.view_mut().doc = wide_id;
let wide_frame = draw(&mut e, &mut terminal);
assert!(
wide_frame[0].contains(">>>"),
"wide buffer rendered: {}",
wide_frame[0]
);
let narrow_id = e
.mru
.iter()
.copied()
.find(|&id| {
e.doc(id)
.buf
.path
.as_deref()
.is_some_and(|p| p.ends_with("narrow.txt"))
})
.unwrap();
e.view_mut().doc = narrow_id; let _ = draw(&mut e, &mut terminal);
let narrow_frame = draw(&mut e, &mut terminal);
let leftover = narrow_frame.iter().filter(|row| row.contains('>')).count();
assert_eq!(leftover, 0, "stale cells: {}", narrow_frame.join("\n"));
}
#[test]
fn block_mode_highlights_the_rectangle() {
let mut e = Editor::new(Buffer::from_text("aabb\nccdd\n"));
e.feed_text("<c-v>lj");
let backend = ratatui::backend::TestBackend::new(30, 6);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| crate::render::render(&mut e, f)).unwrap();
let buf = terminal.backend().buffer();
let bg = |x: u16, y: u16| buf[(x, y)].bg;
let sel = crate::render::SELECT_BG;
assert_eq!(bg(5, 0), sel, "block corner");
assert_eq!(bg(6, 0), sel, "block col 2 row 0");
assert_eq!(bg(5, 1), sel, "block row 1");
assert_ne!(bg(7, 0), sel, "outside the rectangle");
assert_ne!(bg(5, 2), sel, "past the rectangle's last row");
}
#[test]
fn cursor_cell_tracks_wide_chars() {
let mut e = Editor::new(Buffer::from_text("a界b\n"));
e.feed_text("l"); assert_eq!(e.buf().cell_col_of(e.head()), 1); e.feed_text("l"); assert_eq!(e.buf().cell_col_of(e.head()), 3); }
}