use crate::primitives::editor::{
CursorShape, DiagnosticSeverity, DiffLine, Editor, EditorLine, EditorSelection, GitLineStatus,
};
use crate::primitives::scrollbar::Scrollbar;
use crate::text_util::{char_col_to_visual, snap_to_char_boundary};
use crate::theme::Theme;
use crate::types::Color;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color as RColor, Modifier};
use super::{qc, set_cell, set_cell_styled};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct EditorPaintResult {
pub cursor_position: Option<(u16, u16)>,
}
pub fn draw_editor(
buf: &mut Buffer,
area: Rect,
editor: &Editor,
theme: &Theme,
) -> EditorPaintResult {
let mut result = EditorPaintResult::default();
let buf_area = buf.area;
let window_bg = qc(if editor.show_active_bg {
theme.editor_active_background
} else {
theme.background
});
let default_fg = qc(theme.foreground);
let gutter_w = editor.gutter_char_width as u16;
let viewport_lines = area.height as usize;
let has_scrollbar = editor.total_lines > viewport_lines && area.width > gutter_w + 1;
let viewport_cols =
(area.width as usize).saturating_sub(gutter_w as usize + if has_scrollbar { 1 } else { 0 });
let has_h_scrollbar = editor.max_col > viewport_cols && area.height > 1;
for row in 0..area.height {
for col in 0..area.width {
set_cell(buf, area.x + col, area.y + row, ' ', default_fg, window_bg);
}
}
for (row_idx, line) in editor.lines.iter().enumerate() {
let screen_y = area.y + row_idx as u16;
if screen_y >= area.y + area.height {
break;
}
let line_bg = if line.is_dap_current {
qc(theme.dap_stopped_bg)
} else {
match line.diff_status {
Some(DiffLine::Added) => qc(theme.diff_added_bg),
Some(DiffLine::Removed) => qc(theme.diff_removed_bg),
Some(DiffLine::Padding) => qc(theme.diff_padding_bg),
_ if line.is_current_line && editor.is_active && editor.cursorline => {
qc(theme.cursorline_bg)
}
_ => window_bg,
}
};
if line_bg != window_bg {
for col in 0..area.width {
set_cell(buf, area.x + col, screen_y, ' ', default_fg, line_bg);
}
}
if gutter_w > 0 {
let line_num_fg = qc(if line.is_current_line {
theme.line_number_active_fg
} else {
theme.line_number_fg
});
let bp_offset = if editor.has_breakpoints { 1 } else { 0 };
let git_offset = if editor.has_git_diff {
bp_offset + 1
} else {
bp_offset
};
for (i, ch) in line.gutter_text.chars().enumerate() {
let gx = area.x + i as u16;
if gx >= area.x + gutter_w {
break;
}
let fg = if editor.has_breakpoints && i == 0 {
if line.is_dap_current || line.is_breakpoint {
qc(theme.diagnostic_error)
} else {
line_num_fg
}
} else if editor.has_git_diff && i == bp_offset {
qc(match line.git_diff {
Some(GitLineStatus::Added) => theme.git_added,
Some(GitLineStatus::Modified) => theme.git_modified,
Some(GitLineStatus::Deleted) => theme.git_deleted,
None => theme.line_number_fg,
})
} else {
let _ = git_offset;
line_num_fg
};
set_cell(buf, gx, screen_y, ch, fg, line_bg);
}
let diag_x = area.x + bp_offset as u16;
if let Some(severity) = editor.diagnostic_gutter.get(&line.line_idx) {
let (diag_ch, diag_color) = match severity {
DiagnosticSeverity::Error => ('●', qc(theme.diagnostic_error)),
DiagnosticSeverity::Warning => ('●', qc(theme.diagnostic_warning)),
DiagnosticSeverity::Information => ('●', qc(theme.diagnostic_info)),
DiagnosticSeverity::Hint => ('●', qc(theme.diagnostic_hint)),
};
set_cell(buf, diag_x, screen_y, diag_ch, diag_color, line_bg);
} else if !line.is_wrap_continuation
&& editor.code_action_lines.contains(&line.line_idx)
&& editor.lightbulb_glyph != '\0'
{
set_cell(
buf,
diag_x,
screen_y,
editor.lightbulb_glyph,
qc(theme.lightbulb),
line_bg,
);
}
}
let text_area_x = area.x + gutter_w;
let text_width = area
.width
.saturating_sub(gutter_w)
.saturating_sub(if has_scrollbar { 1 } else { 0 });
render_text_line(
buf,
text_area_x,
screen_y,
text_width,
line,
editor.scroll_left,
theme,
line_bg,
editor.tabstop,
);
if !line.indent_guides.is_empty() {
let guide_fg = qc(theme.indent_guide_fg);
let active_fg = qc(theme.indent_guide_active_fg);
for &guide_col in &line.indent_guides {
if guide_col < editor.scroll_left {
continue;
}
let vis_col = (guide_col - editor.scroll_left) as u16;
if vis_col >= text_width {
break;
}
let cx = text_area_x + vis_col;
if cx < buf_area.x + buf_area.width && screen_y < buf_area.y + buf_area.height {
let cell = &mut buf[(cx, screen_y)];
if cell.symbol() == " " {
let is_active = editor.active_indent_col == Some(guide_col);
let fg = if is_active { active_fg } else { guide_fg };
cell.set_char('│');
cell.set_fg(fg);
}
}
}
}
if !line.colorcolumns.is_empty() {
let cc_bg = qc(theme.colorcolumn_bg);
for &cc_col in &line.colorcolumns {
if cc_col < editor.scroll_left {
continue;
}
let vis_col = (cc_col - editor.scroll_left) as u16;
if vis_col >= text_width {
break;
}
let cx = text_area_x + vis_col;
if cx < buf_area.x + buf_area.width && screen_y < buf_area.y + buf_area.height {
let cell = &mut buf[(cx, screen_y)];
cell.set_bg(cc_bg);
}
}
}
if line.is_ghost_continuation {
if let Some(ghost) = &line.ghost_suffix {
let ghost_fg = qc(theme.ghost_text_fg);
for (i, ch) in ghost.chars().enumerate() {
let gx = text_area_x + i as u16;
if gx >= text_area_x + text_width {
break;
}
set_cell(buf, gx, screen_y, ch, ghost_fg, line_bg);
}
}
}
for dm in &line.diagnostics {
let diag_fg = qc(match dm.severity {
DiagnosticSeverity::Error => theme.diagnostic_error,
DiagnosticSeverity::Warning => theme.diagnostic_warning,
DiagnosticSeverity::Information => theme.diagnostic_info,
DiagnosticSeverity::Hint => theme.diagnostic_hint,
});
let vis_start = char_col_to_visual(&line.raw_text, dm.start_col, editor.tabstop);
let vis_end = char_col_to_visual(&line.raw_text, dm.end_col, editor.tabstop);
for vcol in vis_start..vis_end {
if vcol < editor.scroll_left {
continue;
}
let vis_col = (vcol - editor.scroll_left) as u16;
if vis_col >= text_width {
break;
}
let cx = text_area_x + vis_col;
if cx < buf_area.x + buf_area.width && screen_y < buf_area.y + buf_area.height {
let cell = &mut buf[(cx, screen_y)];
cell.set_fg(diag_fg);
cell.modifier |= Modifier::UNDERLINED;
cell.underline_color = diag_fg;
}
}
}
let spell_fg = qc(theme.spell_error);
for sm in &line.spell_errors {
let vis_start = char_col_to_visual(&line.raw_text, sm.start_col, editor.tabstop);
let vis_end = char_col_to_visual(&line.raw_text, sm.end_col, editor.tabstop);
for vcol in vis_start..vis_end {
if vcol < editor.scroll_left {
continue;
}
let vis_col = (vcol - editor.scroll_left) as u16;
if vis_col >= text_width {
break;
}
let cx = text_area_x + vis_col;
if cx < buf_area.x + buf_area.width && screen_y < buf_area.y + buf_area.height {
let cell = &mut buf[(cx, screen_y)];
cell.set_fg(spell_fg);
cell.modifier |= Modifier::UNDERLINED;
cell.underline_color = spell_fg;
}
}
}
let bracket_bg = qc(theme.bracket_match_bg);
for &(view_line, col) in &editor.bracket_match_positions {
if view_line == row_idx {
let vis = char_col_to_visual(&line.raw_text, col, editor.tabstop);
if vis < editor.scroll_left {
continue;
}
let vis_col = (vis - editor.scroll_left) as u16;
if vis_col >= text_width {
continue;
}
let cx = text_area_x + vis_col;
if cx < buf_area.x + buf_area.width && screen_y < buf_area.y + buf_area.height {
let cell = &mut buf[(cx, screen_y)];
cell.set_bg(bracket_bg);
}
}
}
}
if let Some(sel) = &editor.selection {
render_selection(
buf,
area,
editor,
sel,
window_bg,
theme.selection,
default_fg,
);
}
for esel in &editor.extra_selections {
render_selection(
buf,
area,
editor,
esel,
window_bg,
theme.selection,
default_fg,
);
}
if let Some(yh) = &editor.yank_highlight {
render_selection(
buf,
area,
editor,
yh,
window_bg,
theme.yank_highlight_bg,
default_fg,
);
}
if has_scrollbar {
let track_h = if has_h_scrollbar {
area.height.saturating_sub(1)
} else {
area.height
};
if track_h > 0 {
let track = crate::event::Rect::new(
(area.x + area.width - 1) as f32,
area.y as f32,
1.0,
track_h as f32,
);
let scrollbar = Scrollbar::vertical(
"tui:editor:v_scrollbar",
track,
editor.scroll_top as f32,
editor.total_lines as f32,
viewport_lines as f32,
1.0,
);
super::draw_scrollbar(buf, &scrollbar, theme, theme.background);
}
}
if has_h_scrollbar && editor.max_col > 0 && viewport_cols > 0 {
let cell_bg = if editor.show_active_bg {
theme.editor_active_background
} else {
theme.background
};
let sb_y = area.y + area.height - 1;
let track_x = area.x + gutter_w;
let track_w = area
.width
.saturating_sub(gutter_w + if has_scrollbar { 1 } else { 0 });
if track_w > 0 {
let track = crate::event::Rect::new(track_x as f32, sb_y as f32, track_w as f32, 1.0);
let scrollbar = Scrollbar::horizontal(
"tui:editor:h_scrollbar",
track,
editor.scroll_left as f32,
editor.max_col as f32,
viewport_cols as f32,
1.0,
);
super::draw_scrollbar(buf, &scrollbar, theme, cell_bg);
if has_scrollbar {
set_cell(
buf,
area.x + area.width - 1,
sb_y,
'┘',
qc(theme.separator),
qc(cell_bg),
);
}
}
}
if let Some(cursor) = &editor.cursor {
let cursor_screen_y = area.y + cursor.pos.view_line as u16;
let raw = editor
.lines
.get(cursor.pos.view_line)
.map(|l| l.raw_text.as_str())
.unwrap_or("");
let vis_col = char_col_to_visual(raw, cursor.pos.col, editor.tabstop)
.saturating_sub(editor.scroll_left) as u16;
let cursor_screen_x = area.x + gutter_w + vis_col;
let buf_area = buf.area;
match cursor.shape {
CursorShape::Block => {
if cursor_screen_x < buf_area.x + buf_area.width
&& cursor_screen_y < buf_area.y + buf_area.height
{
let cell = &mut buf[(cursor_screen_x, cursor_screen_y)];
cell.set_bg(qc(theme.cursor)).set_fg(qc(theme.background));
}
}
CursorShape::Bar | CursorShape::Underline => {
result.cursor_position = Some((cursor_screen_x, cursor_screen_y));
}
}
}
if let Some(cursor) = &editor.cursor {
if let Some(rl) = editor.lines.get(cursor.pos.view_line) {
if let Some(ghost) = &rl.ghost_suffix {
let ghost_screen_y = area.y + cursor.pos.view_line as u16;
let vis_col = char_col_to_visual(&rl.raw_text, cursor.pos.col, editor.tabstop)
.saturating_sub(editor.scroll_left) as u16;
let ghost_start_x = area.x + gutter_w + vis_col;
let ghost_fg = qc(theme.ghost_text_fg);
for (i, ch) in ghost.chars().enumerate() {
let gx = ghost_start_x + i as u16;
if gx >= area.x + area.width {
break;
}
set_cell(buf, gx, ghost_screen_y, ch, ghost_fg, window_bg);
}
}
}
}
let cursor_color = qc(theme.cursor);
let bg_color = qc(theme.background);
let has_extra_sels = !editor.extra_selections.is_empty();
for extra_pos in &editor.extra_cursors {
let sy = area.y + extra_pos.view_line as u16;
let col = if has_extra_sels {
extra_pos.col + 1
} else {
extra_pos.col
};
let raw = editor
.lines
.get(extra_pos.view_line)
.map(|l| l.raw_text.as_str())
.unwrap_or("");
let vis_col =
char_col_to_visual(raw, col, editor.tabstop).saturating_sub(editor.scroll_left) as u16;
let sx = area.x + gutter_w + vis_col;
let buf_area = buf.area;
if sx < buf_area.x + buf_area.width && sy < buf_area.y + buf_area.height {
let cell = &mut buf[(sx, sy)];
cell.set_bg(cursor_color).set_fg(bg_color);
}
}
result
}
fn byte_to_char_idx(text: &str, byte_offset: usize) -> usize {
text[..snap_to_char_boundary(text, byte_offset)]
.chars()
.count()
}
#[allow(clippy::too_many_arguments)]
fn render_text_line(
buf: &mut Buffer,
x_start: u16,
y: u16,
max_width: u16,
line: &EditorLine,
scroll_left: usize,
theme: &Theme,
window_bg: RColor,
tabstop: usize,
) {
let raw = &line.raw_text;
let chars: Vec<char> = raw.chars().filter(|&c| c != '\n' && c != '\r').collect();
let mut char_fgs: Vec<Color> = vec![theme.foreground; chars.len()];
let mut char_bgs: Vec<Option<Color>> = vec![None; chars.len()];
let mut char_mods: Vec<Modifier> = vec![Modifier::empty(); chars.len()];
for span in &line.spans {
let start = byte_to_char_idx(raw, span.start_byte);
let end = byte_to_char_idx(raw, span.end_byte).min(chars.len());
for i in start..end {
char_fgs[i] = span.style.fg;
char_bgs[i] = span.style.bg;
let mut m = Modifier::empty();
if span.style.bold {
m |= Modifier::BOLD;
}
if span.style.italic {
m |= Modifier::ITALIC;
}
char_mods[i] = m;
}
}
let tabstop = tabstop.max(1);
let mut vis_col: usize = 0;
let mut cells: Vec<(usize, char, usize)> = Vec::with_capacity(chars.len());
for (i, &ch) in chars.iter().enumerate() {
if ch == '\t' {
let next_stop = ((vis_col / tabstop) + 1) * tabstop;
while vis_col < next_stop {
cells.push((vis_col, ' ', i));
vis_col += 1;
}
} else {
cells.push((vis_col, ch, i));
vis_col += 1;
}
}
let total_vis_cols = vis_col;
for &(vcol, ch, ci) in &cells {
if vcol < scroll_left {
continue;
}
let col = (vcol - scroll_left) as u16;
if col >= max_width {
break;
}
let fg = qc(char_fgs[ci]);
let bg = char_bgs[ci].map(qc).unwrap_or(window_bg);
if char_mods[ci].is_empty() {
set_cell(buf, x_start + col, y, ch, fg, bg);
} else {
set_cell_styled(buf, x_start + col, y, ch, fg, bg, char_mods[ci], None);
}
}
if let Some(ann) = &line.annotation {
let visible_cols = total_vis_cols.saturating_sub(scroll_left);
let ann_start = x_start + visible_cols.min(max_width as usize) as u16;
let ann_fg = qc(theme.annotation_fg);
for (i, ch) in ann.chars().enumerate() {
let col = ann_start + i as u16;
if col >= x_start + max_width {
break;
}
set_cell(buf, col, y, ch, ann_fg, window_bg);
}
}
}
fn render_selection(
buf: &mut Buffer,
area: Rect,
editor: &Editor,
sel: &EditorSelection,
window_bg: RColor,
color: Color,
default_fg: RColor,
) {
let sel_bg = qc(color);
let gutter_w = editor.gutter_char_width as u16;
let text_area_x = area.x + gutter_w;
let text_width = area.width.saturating_sub(gutter_w) as usize;
for (row_idx, line) in editor.lines.iter().enumerate() {
let Some(cols) = sel.cols_on(line) else {
continue;
};
let screen_y = area.y + row_idx as u16;
let vis_start = char_col_to_visual(&line.raw_text, cols.start, editor.tabstop);
let vis_end = char_col_to_visual(&line.raw_text, cols.end, editor.tabstop);
for vis in vis_start..vis_end {
if vis < editor.scroll_left {
continue;
}
let screen_col = (vis - editor.scroll_left) as u16;
if screen_col >= text_width as u16 {
break;
}
let sx = text_area_x + screen_col;
let buf_area = buf.area;
if sx < buf_area.x + buf_area.width && screen_y < buf_area.y + buf_area.height {
let cell = &mut buf[(sx, screen_y)];
let old_fg = cell.fg;
cell.set_bg(sel_bg);
if old_fg == window_bg {
cell.set_fg(default_fg);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::editor::{DiagnosticMark, SelectionKind, SpellMark};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect as RtRect;
fn decorated_line(line_idx: usize, col: usize) -> EditorLine {
EditorLine {
raw_text: "x".repeat(col + 2),
gutter_text: String::new(),
spans: vec![],
line_idx,
is_current_line: false,
is_fold_header: false,
folded_line_count: 0,
git_diff: None,
diff_status: None,
diagnostics: vec![DiagnosticMark {
start_col: col,
end_col: col + 1,
severity: DiagnosticSeverity::Error,
message: String::new(),
}],
spell_errors: vec![SpellMark {
start_col: col,
end_col: col + 1,
}],
is_breakpoint: false,
is_conditional_bp: false,
is_dap_current: false,
is_wrap_continuation: false,
segment_col_offset: 0,
annotation: None,
ghost_suffix: None,
is_ghost_continuation: false,
indent_guides: vec![col],
colorcolumns: vec![col],
}
}
fn test_editor(lines: Vec<EditorLine>, bracket_match_positions: Vec<(usize, usize)>) -> Editor {
Editor {
id: crate::types::WidgetId::new("editor"),
rect: crate::event::Rect::new(0.0, 0.0, 20.0, 5.0),
lines,
cursor: None,
extra_cursors: vec![],
selection: None,
extra_selections: vec![],
yank_highlight: None,
scroll_top: 0,
scroll_left: 0,
total_lines: 2,
max_col: 20,
gutter_char_width: 0,
is_active: true,
show_active_bg: false,
has_git_diff: false,
has_breakpoints: false,
diagnostic_gutter: Default::default(),
code_action_lines: Default::default(),
bracket_match_positions,
active_indent_col: None,
tabstop: 4,
cursorline: false,
lightbulb_glyph: '\0',
}
}
#[test]
fn draw_editor_does_not_panic_when_area_outlives_buf() {
let lines = vec![
decorated_line(0, 15), decorated_line(1, 2), ];
let editor = test_editor(lines, vec![(0, 15), (1, 2)]);
let theme = Theme::default();
let mut buf = Buffer::empty(RtRect::new(0, 0, 10, 1));
let area = RtRect::new(0, 0, 20, 5);
let _ = draw_editor(&mut buf, area, &editor, &theme);
}
fn sel_test_line(
line_idx: usize,
raw_text: &str,
segment_col_offset: usize,
is_ghost_continuation: bool,
diff_status: Option<DiffLine>,
) -> EditorLine {
EditorLine {
raw_text: raw_text.into(),
gutter_text: String::new(),
spans: vec![],
line_idx,
is_current_line: false,
is_fold_header: false,
folded_line_count: 0,
git_diff: None,
diff_status,
diagnostics: vec![],
spell_errors: vec![],
is_breakpoint: false,
is_conditional_bp: false,
is_dap_current: false,
is_wrap_continuation: segment_col_offset > 0,
segment_col_offset,
annotation: None,
ghost_suffix: is_ghost_continuation.then(|| "ai suggestion".to_string()),
is_ghost_continuation,
indent_guides: vec![],
colorcolumns: vec![],
}
}
#[test]
fn draw_editor_selection_respects_wrapped_ghost_and_diff_padding_rows() {
let lines = vec![
sel_test_line(0, "0123456789", 0, false, None),
sel_test_line(1, "abcde", 5, false, None),
sel_test_line(1, "", 0, true, None),
sel_test_line(2, "", 0, false, Some(DiffLine::Padding)),
];
let mut editor = test_editor(lines, vec![]);
editor.max_col = 0;
editor.total_lines = 4;
editor.selection = Some(EditorSelection {
kind: SelectionKind::Char,
start_line: 0,
start_col: 0,
end_line: 2,
end_col: 10,
});
let theme = Theme::default();
let mut buf = Buffer::empty(RtRect::new(0, 0, 10, 4));
let area = RtRect::new(0, 0, 10, 4);
let _ = draw_editor(&mut buf, area, &editor, &theme);
let sel_bg = qc(theme.selection);
let window_bg = qc(theme.background);
let diff_padding_bg = qc(theme.diff_padding_bg);
assert_eq!(
buf[(0, 0)].bg,
sel_bg,
"row 0 (plain, selection's start line) should be tinted"
);
assert_eq!(
buf[(0, 1)].bg,
sel_bg,
"row 1 (wrapped segment of a fully-selected line) should be tinted"
);
assert_eq!(
buf[(0, 2)].bg,
window_bg,
"row 2 (ghost continuation) must never be tinted by a selection"
);
assert_eq!(
buf[(0, 3)].bg,
diff_padding_bg,
"row 3 (diff padding) must show only its diff background, not a selection tint"
);
}
}