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;
struct PaneView {
buffer: usize,
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 {
buffer: editor.current,
cursor: editor.cursor,
view_top: editor.view_top,
overlays: true,
}
} else {
let pane = &editor.panes[i];
PaneView {
buffer: pane.buffer.min(editor.buffers.len().saturating_sub(1)),
cursor: pane.cursor,
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.buffers[view.buffer];
let line = buf.line_of(view.cursor);
let row = line.saturating_sub(view.view_top) as u16;
let gutter = diff::left_inset(editor, view.buffer) as u16;
let col = gutter + buf.col_of(view.cursor) as u16;
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.buffer != editor.current || editor.extra_cursors.is_empty() {
return;
}
let buf = &editor.buffers[view.buffer];
let inset = diff::left_inset(editor, view.buffer) as u16;
for &c in &editor.extra_cursors {
let line = buf.line_of(c);
if line < view.view_top {
continue;
}
let row = (line - view.view_top) as u16;
let col = inset + buf.col_of(c) as u16;
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 buf = &editor.buffers[view.buffer];
let cur_line = buf.line_of(view.cursor);
let surface = editor.surfaces.get(view.buffer).and_then(|s| s.as_ref());
let first_byte = buf.line_start(view.view_top);
let last_line = (view.view_top + text_rows).min(buf.len_lines());
let last_byte = buf.line_end(last_line.saturating_sub(1));
let rope = editor.buffers[view.buffer].rope.clone();
let syn_spans: Vec<strop_syntax::Span> = match editor.highlighters.get_mut(view.buffer) {
Some(h) => h
.as_mut()
.map(|h| h.highlight(&rope, first_byte, last_byte))
.unwrap_or_default(),
None => Vec::new(),
};
let mut row_style = RowStyle {
syn_spans: &syn_spans,
preview: view
.overlays
.then(|| editor.preview())
.flatten()
.map(|r| r.range),
flash: view.overlays.then(|| editor.flash_range()).flatten(),
selection: view.overlays.then(|| editor.visual_range()).flatten(),
search_hits: &[],
find: view.overlays.then(|| editor.find_candidates()).flatten(),
diff_line: None,
};
let search_hits: Vec<usize> = if view.overlays {
editor
.search_pattern()
.map(|p| strop_grammar::search_all(editor.buf(), p))
.unwrap_or_default()
} 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 = match surface {
Some(crate::editor::Surface::Diff {
commit: Some(cf),
label,
..
}) => Some((cf.files.as_slice(), label.as_str())),
_ => None,
};
let sidebar_w = sidebar.map_or(0, |(files, _)| diff::sidebar_width(files) + 1);
let blame = editor.blame_gutter_for(view.buffer);
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);
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))
.unwrap_or_default();
if let Some(gutter) = blame {
left.push(match gutter.lines.get(line_idx) {
Some(bl) => 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(Line::from(left));
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(line);
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);
spans.extend(content_spans(
editor,
view,
start,
&text,
&row_style,
content_width,
));
left.extend(spans);
lines.push(Line::from(left));
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;
if let Some(content) = diff::surface_content_spans(surface, line_idx, content_width) {
left.extend(content);
} else if buf.name.as_deref() == Some("help") {
left.extend(super::help::row_spans(&text));
} 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.buffer, 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(Line::from(left));
}
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.buffer, line_idx + 1) {
let glyph = match sev {
1 => "E",
2 => "W",
3 => "I",
_ => "H",
};
return (glyph, severity_color(sev));
}
if view.buffer == editor.current {
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: Option<strop_core::Range>,
flash: Option<strop_core::Range>,
selection: Option<strop_core::Range>,
search_hits: &'a [usize],
find: Option<(u8, bool)>,
diff_line: Option<&'a strop_git::DiffLine>,
}
fn content_spans(
editor: &Editor,
view: &PaneView,
start: usize,
text: &str,
style: &RowStyle,
width: u16,
) -> Vec<Span<'static>> {
let buf = &editor.buffers[view.buffer];
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 mut spans = Vec::with_capacity(text.len() / 2 + 4);
let mut chars = 0usize;
for (i, ch) in text.chars().enumerate() {
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 let Some(bg) = diff::origin_bg(dl.origin) {
cell = cell.bg(bg);
}
} 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 style.selection.is_some_and(|r| in_range(r, pos)) {
cell = cell.bg(SELECT_BG);
}
if style
.search_hits
.iter()
.any(|&h| pos >= h && pos < h + editor.search_pattern().map_or(0, str::len))
{
cell = cell.fg(ACCENT).add_modifier(Modifier::BOLD);
}
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.is_whitespace() {
cell = cell.fg(ACCENT).add_modifier(Modifier::BOLD);
}
}
if style.preview.is_some_and(|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.surfaces.get(view.buffer).and_then(|s| s.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("E"), "gutter sign: {frame}");
assert!(frame.contains("▍ mismatched types"), "eol note: {frame}");
}
}