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::config::{EditorConfig, IndentGuideStyle};
use crate::editor::{Buffer, IndentAnimState};
use crate::lsp::Severity;
use crate::syntax::{self, Capture};
use crate::text_width::{char_cell_width, visual_col_of};
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 MATCH_BRACKET_FG: Color = Color::Yellow;
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 INDENT_GUIDE_FG: Color = Color::Rgb(60, 60, 70);
const INDENT_GUIDE_CHAR: char = '│';
const GUTTER_SIGN_WIDTH: u16 = 1;
const GUTTER_VCS_WIDTH: u16 = 1;
const SCROLL_OFF: usize = 5;
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 cursor_col = app.buffer.cursor.col;
let extras = &app.buffer.extra_cursors;
let search_query = &app.search.query;
let jump_overlay = build_jump_overlay(app.jump_state.as_ref());
let bracket_pair: Vec<(usize, usize)> = app
.buffer
.highlighter
.as_ref()
.and_then(|h| h.matching_bracket(cursor_row, cursor_col))
.map(|mate| vec![(cursor_row, cursor_col), mate])
.unwrap_or_default();
let eff = app.effective_editor();
let tab_width = eff.tab_width.max(1);
let show_whitespace = eff.show_whitespace;
let indent_guides = if eff.indent_guides {
compute_indent_guides(
app,
scroll,
last_visible,
tab_width,
eff.indent_width,
eff.indent_guides_skip_levels,
eff.indent_guide_style,
eff.indent_animation,
eff.indent_animation_ms,
)
} else {
GuideMap::new()
};
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();
let row_bracket_cols: Vec<usize> = bracket_pair
.iter()
.filter_map(|(r, c)| if *r == i { Some(*c) } else { None })
.collect();
let row_guides: &[IndentGuide] = indent_guides.get(&i).map(Vec::as_slice).unwrap_or(&[]);
spans.extend(render_line(
i,
line,
sel.as_ref(),
&captures,
&extra_cols,
&hits,
&row_jumps,
&row_bracket_cols,
row_guides,
tab_width,
col_scroll,
inner_text_width,
show_whitespace,
));
if i == cursor_row
&& app.completion.is_none()
&& let Some(s) = app.inline_suggestion.showing()
&& s.is_anchored_at(app.buffer.cursor)
{
let style = Style::default()
.fg(Color::DarkGray)
.add_modifier(ratatui::style::Modifier::ITALIC);
if let Some(first) = s.text.lines().next() {
spans.push(Span::styled(first.to_string(), style));
}
}
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 = visual_col_of(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 leading_indent_visual(line: &str, tab_width: usize) -> Option<usize> {
let mut v = 0usize;
for ch in line.chars() {
if ch == ' ' {
v += 1;
} else if ch == '\t' {
v += tab_width - (v % tab_width);
} else {
return Some(v);
}
}
None
}
#[derive(Debug, Clone, Copy)]
struct IndentGuide {
col: usize,
glyph: char,
active: bool,
}
type GuideMap = HashMap<usize, Vec<IndentGuide>>;
#[allow(clippy::too_many_arguments)]
fn compute_indent_guides(
app: &App,
scroll: usize,
last_visible: usize,
tab_width: usize,
indent_width: usize,
skip_levels: usize,
style: IndentGuideStyle,
animation: bool,
animation_ms: u64,
) -> GuideMap {
let mut map: GuideMap = HashMap::new();
if last_visible <= scroll || indent_width == 0 {
return map;
}
let cursor_row = app.buffer.cursor.row;
let lines = &app.buffer.lines;
let line_count = lines.len();
if line_count == 0 {
return map;
}
let resolve_indent = |row: usize| -> usize {
let line = &lines[row];
let mut ws = 0usize;
let mut had_chars = false;
for ch in line.chars() {
had_chars = true;
if ch == ' ' {
ws += 1;
} else if ch == '\t' {
ws += tab_width - (ws % tab_width);
} else {
return ws;
}
}
if had_chars {
return ws;
}
let above = (0..row)
.rev()
.find_map(|r| leading_indent_visual(&lines[r], tab_width))
.unwrap_or(0);
let below = (row + 1..line_count)
.find_map(|r| leading_indent_visual(&lines[r], tab_width))
.unwrap_or(0);
above.min(below)
};
let start_col = if skip_levels == 0 { 0 } else { indent_width };
for row in scroll..last_visible.min(line_count) {
let indent = resolve_indent(row);
let mut col = start_col;
while col < indent {
push_unique_guide(
&mut map,
row,
IndentGuide {
col,
glyph: INDENT_GUIDE_CHAR,
active: false,
},
);
col += indent_width;
}
}
if skip_levels > 0 {
let cutoff = skip_levels.saturating_mul(indent_width);
for guides in map.values_mut() {
guides.retain(|g| g.col > cutoff);
}
}
let active = active_scope_range(app, cursor_row, lines, tab_width, indent_width);
if let Some((lo_active, hi_active, ac)) = active {
let s = lo_active.saturating_sub(1);
let (anim_top, anim_bot) = animation_envelope(
&app.buffer.indent_anim,
(s, hi_active, ac),
cursor_row,
animation,
animation_ms,
);
let _ = indent_width;
match style {
IndentGuideStyle::Line => {
let s = lo_active.saturating_sub(1);
let row_lo = s.max(anim_top).max(scroll);
let row_hi = hi_active
.min(anim_bot)
.min(last_visible.saturating_sub(1))
.min(line_count.saturating_sub(1));
if row_lo > row_hi {
return map;
}
for row in row_lo..=row_hi {
if let Some(guides) = map.get_mut(&row) {
for g in guides.iter_mut() {
if g.col == ac {
g.active = true;
}
}
}
}
}
IndentGuideStyle::P10k => {
let p10k_col = ac.saturating_sub(2);
let anim_s = s.max(anim_top);
let anim_e = hi_active.min(anim_bot);
let row_lo = anim_s.max(scroll);
let row_hi = anim_e
.min(last_visible.saturating_sub(1))
.min(line_count.saturating_sub(1));
if row_lo > row_hi {
return map;
}
let top_reached = anim_s == s;
let bot_reached = anim_e == hi_active;
for row in row_lo..=row_hi {
let glyph = if top_reached && row == s {
'╭'
} else if bot_reached && row == hi_active {
'╰'
} else {
INDENT_GUIDE_CHAR
};
push_unique_guide(
&mut map,
row,
IndentGuide {
col: p10k_col,
glyph,
active: true,
},
);
}
let in_view = |row: usize| -> bool {
row >= scroll && row < last_visible && row < line_count
};
if top_reached && in_view(s) {
push_unique_guide(
&mut map,
s,
IndentGuide {
col: p10k_col + 1,
glyph: '─',
active: true,
},
);
}
if bot_reached && in_view(hi_active) && ac > 0 {
push_unique_guide(
&mut map,
hi_active,
IndentGuide {
col: p10k_col + 1,
glyph: '>',
active: true,
},
);
}
}
}
}
map
}
fn animation_envelope(
state: &std::cell::Cell<Option<IndentAnimState>>,
scope: (usize, usize, usize),
cursor_row: usize,
enabled: bool,
duration_ms: u64,
) -> (usize, usize) {
if !enabled || duration_ms == 0 {
state.set(None);
return (scope.0, scope.1);
}
let now = std::time::Instant::now();
let cached = state.get();
let started_at = match cached {
Some((Some(t), k, _)) if k == scope => Some(t),
Some((None, k, _)) if k == scope => None,
_ => {
state.set(Some((Some(now), scope, cursor_row)));
Some(now)
}
};
let p = match started_at {
Some(t) => {
let elapsed_ms = now.duration_since(t).as_millis() as u64;
(elapsed_ms as f32 / duration_ms as f32).clamp(0.0, 1.0)
}
None => 1.0,
};
let length = scope.1.saturating_sub(scope.0) as f32;
let bot = scope.0.saturating_add((length * p).round() as usize);
if p >= 1.0 && started_at.is_some() {
state.set(Some((None, scope, cursor_row)));
}
(scope.0, bot)
}
fn active_scope_range(
app: &App,
cursor_row: usize,
lines: &[String],
tab_width: usize,
indent_width: usize,
) -> Option<(usize, usize, usize)> {
if let Some(h) = app.buffer.highlighter.as_ref() {
let scopes = h.indent_scopes_in_rows(cursor_row, cursor_row);
let mut best: Option<(usize, usize)> = None;
for (s, e) in scopes {
if cursor_row >= s && cursor_row <= e {
match best {
None => best = Some((s, e)),
Some((bs, be)) if (e - s) < (be - bs) => best = Some((s, e)),
_ => {}
}
}
}
if let Some((s, e)) = best {
let col = leading_indent_visual(&lines[s], tab_width).unwrap_or(0);
return Some((s + 1, e, col));
}
}
let cursor_indent = match leading_indent_visual(&lines[cursor_row], tab_width) {
Some(v) => v,
None => {
let above = (0..cursor_row)
.rev()
.find_map(|r| leading_indent_visual(&lines[r], tab_width))
.unwrap_or(0);
let below = (cursor_row + 1..lines.len())
.find_map(|r| leading_indent_visual(&lines[r], tab_width))
.unwrap_or(0);
above.min(below)
}
};
if cursor_indent < indent_width {
return None;
}
let active_col = ((cursor_indent - 1) / indent_width) * indent_width;
if active_col == 0 {
return None;
}
let threshold = active_col + indent_width;
let n = lines.len();
let mut s = cursor_row;
while s > 0 {
match leading_indent_visual(&lines[s - 1], tab_width) {
Some(i) if i >= threshold => s -= 1,
None => s -= 1,
_ => break,
}
}
let mut e = cursor_row;
while e + 1 < n {
match leading_indent_visual(&lines[e + 1], tab_width) {
Some(i) if i >= threshold => e += 1,
None => e += 1,
_ => break,
}
}
Some((s, e, active_col))
}
fn push_unique_guide(map: &mut GuideMap, row: usize, guide: IndentGuide) {
let entry = map.entry(row).or_default();
if let Some(existing) = entry.iter_mut().find(|g| g.col == guide.col) {
if guide.active {
existing.active = true;
}
if guide.glyph != INDENT_GUIDE_CHAR {
existing.glyph = guide.glyph;
}
return;
}
entry.push(guide);
}
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)],
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().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.bg(SEL_BG);
}
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 (ch, style) = if let Some(g) = guide_at(vc) {
(g.glyph, guide_style(g))
} else if vc == 0 {
(' ', cursor_cell_style)
} else {
(' ', Style::default())
};
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 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);
}
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';
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 {
(g.glyph, guide_style(g))
} else if original == '\t' {
if k == 0 && ch != '\t' {
(ch, 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
}
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 app.buffer.pending_center.replace(false) && height > 0 {
let last = app.buffer.lines.len().saturating_sub(1);
let max_scroll = last.saturating_sub(height.saturating_sub(1));
scroll = cur.saturating_sub(height / 2).min(max_scroll);
app.buffer.scroll.set(scroll);
app.buffer.viewport_height.set(height);
return scroll;
}
let off = if height > 2 * SCROLL_OFF + 1 {
SCROLL_OFF
} else {
0
};
if cur < scroll + off {
scroll = cur.saturating_sub(off);
} else if height > 0 {
let effective_height = height.saturating_sub(off);
loop {
if scroll >= cur {
break;
}
let mut consumed: usize = 0;
for row in scroll..cur {
consumed += 1 + row_diag.get(&row).map_or(0, |_| 1);
if consumed >= effective_height {
break;
}
}
if consumed < effective_height {
break;
}
scroll += 1;
}
}
let last_row = app.buffer.lines.len().saturating_sub(1);
scroll = scroll.min(last_row);
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 = visual_col_of(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),
),
])
}
pub(super) fn draw_buffer_inactive(f: &mut Frame, buf: &Buffer, eff: &EditorConfig, area: Rect) {
let height = area.height as usize;
let cur = buf.cursor.row;
let mut scroll = buf.scroll.get();
let off = if height > 2 * SCROLL_OFF + 1 {
SCROLL_OFF
} else {
0
};
if cur < scroll + off {
scroll = cur.saturating_sub(off);
} else if height > 0 && cur + off >= scroll + height {
scroll = (cur + off + 1).saturating_sub(height);
}
let last_row = buf.lines.len().saturating_sub(1);
scroll = scroll.min(last_row);
buf.scroll.set(scroll);
buf.viewport_height.set(height);
let last_visible = scroll + height;
let captures = buf
.highlighter
.as_ref()
.map(|h| h.captures_in_rows(scroll, last_visible))
.unwrap_or_default();
let vcs_statuses = buf.vcs_statuses();
let tab_width = eff.tab_width.max(1);
let show_whitespace = eff.show_whitespace;
let inner_text_width =
area.width
.saturating_sub(GUTTER_SIGN_WIDTH + 5 + GUTTER_VCS_WIDTH) as usize;
let line = buf.lines.get(cur).map(String::as_str).unwrap_or("");
let visual_col = visual_col_of(line, buf.cursor.col, tab_width);
let mut col_scroll = buf.col_scroll.get();
if inner_text_width > 0 {
if visual_col < col_scroll {
col_scroll = visual_col;
} else if visual_col >= col_scroll + inner_text_width {
col_scroll = visual_col + 1 - inner_text_width;
}
} else {
col_scroll = 0;
}
buf.col_scroll.set(col_scroll);
let mut visible: Vec<Line> = Vec::with_capacity(height);
for (visual_y, (i, line)) in (0_usize..).zip(buf.lines.iter().enumerate().skip(scroll)) {
if visual_y >= height {
break;
}
let mut spans = vec![sign_span(None)];
let num = format!("{:>4} ", i + 1);
spans.push(Span::styled(num, Style::default().fg(Color::DarkGray)));
let vcs_status = vcs_statuses.get(i).copied().flatten();
spans.push(vcs_bar_span(vcs_status));
spans.extend(render_line(
i,
line,
None,
&captures,
&[],
&[],
&[],
&[],
&[],
tab_width,
col_scroll,
inner_text_width,
show_whitespace,
));
visible.push(Line::from(spans));
}
f.render_widget(Paragraph::new(visible), area);
}
fn severity_color(sev: Severity) -> Color {
match sev {
Severity::Error => Color::Red,
Severity::Warning => Color::Yellow,
Severity::Info => Color::LightBlue,
Severity::Hint => Color::DarkGray,
}
}