use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::app::{App, JumpState, Selection};
use crate::lsp::Severity;
use crate::syntax::{self, Capture};
use crate::vcs::LineStatus;
use std::collections::HashMap;
const SEL_BG: Color = Color::Rgb(58, 78, 122);
const SEARCH_HIT_BG: Color = Color::DarkGray;
const EXTRA_CURSOR_BG: Color = Color::Rgb(160, 110, 60);
const JUMP_LABEL_FG: Color = Color::Rgb(255, 100, 200);
const JUMP_LABEL_BG: Color = Color::Rgb(40, 0, 40);
const WHITESPACE_FG: Color = Color::DarkGray;
const GUTTER_SIGN_WIDTH: u16 = 1;
const GUTTER_VCS_WIDTH: u16 = 1;
pub(super) fn draw_buffer(f: &mut Frame, app: &App, area: Rect) {
let height = area.height as usize;
let row_diag = build_row_diag_summary(app, app.buffer.cursor.row);
let scroll = compute_scroll(app, height, &row_diag);
let sel = app.selection();
let last_visible = scroll + height;
let captures = app
.buffer
.highlighter
.as_ref()
.map(|h| h.captures_in_rows(scroll, last_visible))
.unwrap_or_default();
let row_severity = build_row_severity(app, scroll, last_visible);
let vcs_statuses = app.buffer.vcs_statuses();
let cursor_row = app.buffer.cursor.row;
let extras = &app.buffer.extra_cursors;
let search_query = &app.search.query;
let jump_overlay = build_jump_overlay(app.jump_state.as_ref());
let eff = app.effective_editor();
let tab_width = eff.tab_width.max(1);
let show_whitespace = eff.show_whitespace;
let mut visible: Vec<Line> = Vec::with_capacity(height);
let mut visual_y: u16 = 0;
let mut cursor_visual_y: u16 = 0;
let inner_text_width = area
.width
.saturating_sub(GUTTER_SIGN_WIDTH + 5 + GUTTER_VCS_WIDTH) as usize;
let col_scroll = compute_col_scroll(app, inner_text_width, tab_width);
for (i, line) in app.buffer.lines.iter().enumerate().skip(scroll) {
if visual_y as usize >= height {
break;
}
if i == cursor_row {
cursor_visual_y = visual_y;
}
let mut spans = vec![sign_span(row_severity.get(&i).copied())];
let num = format!("{:>4} ", i + 1);
let num_style = if i == cursor_row {
Style::default().fg(Color::Reset)
} else {
Style::default().fg(Color::DarkGray)
};
spans.push(Span::styled(num, num_style));
let vcs_status = vcs_statuses.get(i).copied().flatten();
spans.push(vcs_bar_span(vcs_status));
let extra_cols: Vec<usize> = extras
.iter()
.filter_map(|c| if c.row == i { Some(c.col) } else { None })
.collect();
let hits = find_matches_in_line(line, search_query);
let row_jumps: Vec<(usize, char)> = jump_overlay
.iter()
.filter_map(|(pos, ch)| if pos.0 == i { Some((pos.1, *ch)) } else { None })
.collect();
spans.extend(render_line(
i,
line,
sel.as_ref(),
&captures,
&extra_cols,
&hits,
&row_jumps,
tab_width,
col_scroll,
inner_text_width,
show_whitespace,
));
visible.push(Line::from(spans));
visual_y += 1;
if visual_y as usize >= height {
break;
}
if let Some(summary) = row_diag.get(&i) {
visible.push(diagnostic_line(summary, inner_text_width));
visual_y += 1;
}
}
app.buffer.cursor_visual_y.set(cursor_visual_y);
f.render_widget(Paragraph::new(visible), area);
}
pub(super) fn place_cursor(f: &mut Frame, app: &App, buf_area: Rect) {
if app.prompt.is_open() {
return;
}
let line_no_width: u16 = 5;
let tab_width = app.effective_editor().tab_width.max(1);
let line = &app.buffer.lines[app.buffer.cursor.row];
let visual_col = char_col_to_visual(line, app.buffer.cursor.col, tab_width);
let col_scroll = app.buffer.col_scroll.get();
let on_screen_col = visual_col.saturating_sub(col_scroll);
let x = buf_area.x
+ GUTTER_SIGN_WIDTH
+ line_no_width
+ GUTTER_VCS_WIDTH
+ on_screen_col as u16;
let y = buf_area.y + app.buffer.cursor_visual_y.get();
f.set_cursor_position((x, y));
}
fn char_col_to_visual(line: &str, char_col: usize, tab_width: usize) -> usize {
let mut v = 0usize;
for ch in line.chars().take(char_col) {
if ch == '\t' {
v += tab_width - (v % tab_width);
} else {
v += 1;
}
}
v
}
fn build_row_severity(
app: &App,
scroll: usize,
last: usize,
) -> std::collections::HashMap<usize, Severity> {
let mut map: std::collections::HashMap<usize, Severity> = std::collections::HashMap::new();
let diags = match app.current_diagnostics() {
Some(d) => d,
None => return map,
};
for d in diags {
let lo = d.range.start.line as usize;
let hi = d.range.end.line as usize;
for row in lo.max(scroll)..=hi.min(last.saturating_sub(1)) {
map.entry(row)
.and_modify(|s| {
if (d.severity as u8) < (*s as u8) {
*s = d.severity;
}
})
.or_insert(d.severity);
}
}
map
}
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(" "),
}
}
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)]
fn render_line(
row: usize,
line: &str,
sel: Option<&Selection>,
captures: &[Capture],
extra_cols: &[usize],
search_hits: &[(usize, usize)],
jump_labels: &[(usize, char)],
tab_width: usize,
col_scroll: usize,
viewport_width: usize,
show_whitespace: bool,
) -> Vec<Span<'static>> {
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 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);
if chars.is_empty() {
if col_scroll > 0 {
return Vec::new();
}
let mut style = Style::default();
if is_selected(0) {
style = style.bg(SEL_BG);
}
if is_extra_cursor(0) {
style = extra_cursor_style(style);
}
if style == Style::default() {
return Vec::new();
}
return vec![Span::styled(" ".to_string(), style)];
}
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 = style;
}
}
let style_at = |col: usize| -> Style {
let mut s = base[col];
if is_search_hit(col) {
s = s.bg(SEARCH_HIT_BG);
}
if is_selected(col) {
s = s.bg(SEL_BG);
}
if is_extra_cursor(col) {
s = extra_cursor_style(s);
}
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::new();
let mut buf = String::new();
let mut buf_style = Style::default();
let mut visual_col = 0usize;
let mut started = false;
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 {
1
};
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 left_skip = col_scroll.saturating_sub(cell_start);
let emit_width = width - left_skip;
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;
}
if original == '\t' {
if ch != '\t' && left_skip == 0 {
buf.push(ch);
for _ in 1..emit_width {
buf.push(' ');
}
} else {
for _ in 0..emit_width {
buf.push(' ');
}
}
} else {
buf.push(ch);
}
}
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
}
fn extra_cursor_style(base: Style) -> Style {
base.bg(EXTRA_CURSOR_BG)
}
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
}
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
}
fn compute_scroll(
app: &App,
height: usize,
row_diag: &HashMap<usize, RowDiag>,
) -> usize {
let cur = app.buffer.cursor.row;
let mut scroll = app.buffer.scroll.get();
if cur < scroll {
scroll = cur;
} else if height > 0 {
loop {
if scroll >= cur {
break;
}
let mut consumed: usize = 0;
let mut fits = false;
for row in scroll..cur {
consumed += 1 + row_diag.get(&row).map_or(0, |_| 1);
if consumed >= height {
break;
}
}
if consumed < height {
fits = true;
}
if fits {
break;
}
scroll += 1;
}
}
app.buffer.scroll.set(scroll);
app.buffer.viewport_height.set(height);
scroll
}
fn compute_col_scroll(app: &App, width: usize, tab_width: usize) -> usize {
if width == 0 {
app.buffer.col_scroll.set(0);
return 0;
}
let line = &app.buffer.lines[app.buffer.cursor.row];
let visual_col = char_col_to_visual(line, app.buffer.cursor.col, tab_width);
let mut col_scroll = app.buffer.col_scroll.get();
if visual_col < col_scroll {
col_scroll = visual_col;
} else if visual_col >= col_scroll + width {
col_scroll = visual_col + 1 - width;
}
app.buffer.col_scroll.set(col_scroll);
col_scroll
}
pub(super) struct RowDiag {
pub severity: Severity,
pub message: String,
pub extra: usize,
}
fn build_row_diag_summary(app: &App, cursor_row: usize) -> HashMap<usize, RowDiag> {
let mut out: HashMap<usize, RowDiag> = HashMap::new();
let Some(diags) = app.current_diagnostics() else {
return out;
};
for d in diags {
let row = d.range.start.line as usize;
if row != cursor_row && d.severity != Severity::Error {
continue;
}
let msg = d.message.lines().next().unwrap_or("").to_string();
match out.get_mut(&row) {
None => {
out.insert(
row,
RowDiag {
severity: d.severity,
message: msg,
extra: 0,
},
);
}
Some(existing) => {
if (d.severity as u8) < (existing.severity as u8) {
existing.severity = d.severity;
existing.message = msg;
}
existing.extra += 1;
}
}
}
out
}
fn diagnostic_line(diag: &RowDiag, inner_text_width: usize) -> Line<'static> {
let color = severity_color(diag.severity);
let gutter = " ".repeat((GUTTER_SIGN_WIDTH + 5 + GUTTER_VCS_WIDTH) as usize);
let mut text = String::from("↳ ");
text.push_str(&diag.message);
if diag.extra > 0 {
text.push_str(&format!(" (+{})", diag.extra));
}
if inner_text_width > 0 && text.chars().count() > inner_text_width {
let mut t: String = text.chars().take(inner_text_width.saturating_sub(1)).collect();
t.push('…');
text = t;
}
Line::from(vec![
Span::raw(gutter),
Span::styled(
text,
Style::default()
.fg(color)
.add_modifier(ratatui::style::Modifier::ITALIC),
),
])
}
fn severity_color(sev: Severity) -> Color {
match sev {
Severity::Error => Color::Red,
Severity::Warning => Color::Yellow,
Severity::Info => Color::LightBlue,
Severity::Hint => Color::DarkGray,
}
}