use windows::Win32::Graphics::Direct2D::ID2D1RenderTarget;
use super::text::{fill_rect, pop_clip, push_clip, DWrite};
use crate::backend::EditorPaintResult;
use crate::event::Rect;
use crate::primitives::editor::{
CursorShape, DiagnosticSeverity, Editor, EditorLine, EditorSelection,
};
use crate::theme::Theme;
use crate::types::Color;
pub fn draw_editor(
target: &ID2D1RenderTarget,
dwrite: &DWrite,
editor: &Editor,
cell_width: f32,
line_height: f32,
theme: &Theme,
) -> EditorPaintResult {
let rect = editor.rect;
let layout = editor.layout(rect, cell_width, line_height);
let bg = if editor.show_active_bg {
theme.editor_active_background
} else {
theme.background
};
let _ = fill_rect(target, rect, bg);
for (view_idx, line) in editor.lines.iter().enumerate() {
if line.is_current_line && editor.is_active && editor.cursorline {
let y = rect.y + view_idx as f32 * line_height;
let _ = fill_rect(
target,
Rect::new(rect.x, y, rect.width, line_height),
theme.cursorline_bg,
);
}
}
push_clip(target, layout.text_bounds);
if let Some(sel) = &editor.selection {
paint_selection(
target,
sel,
&editor.lines,
rect,
line_height,
layout.text_bounds.x,
cell_width,
theme.selection,
theme.selection_alpha,
theme.background,
);
}
for sel in &editor.extra_selections {
paint_selection(
target,
sel,
&editor.lines,
rect,
line_height,
layout.text_bounds.x,
cell_width,
theme.selection,
theme.selection_alpha,
theme.background,
);
}
if let Some(sel) = &editor.yank_highlight {
paint_selection(
target,
sel,
&editor.lines,
rect,
line_height,
layout.text_bounds.x,
cell_width,
theme.yank_highlight_bg,
theme.yank_highlight_alpha,
theme.background,
);
}
pop_clip(target);
for (view_idx, line) in editor.lines.iter().enumerate() {
let y = rect.y + view_idx as f32 * line_height;
if editor.gutter_char_width > 0 {
let gutter_fg = if line.is_current_line {
theme.line_number_active_fg
} else {
theme.line_number_fg
};
let gutter_w = editor.gutter_char_width as f32 * cell_width;
let (gw, gh) = dwrite.measure_text(&line.gutter_text).unwrap_or((0.0, 0.0));
let gx = rect.x + (gutter_w - gw).max(0.0);
let gy = y + (line_height - gh) / 2.0;
let _ = dwrite.draw_text(
target,
&line.gutter_text,
Rect::new(gx, gy, gw, gh),
gutter_fg,
);
}
push_clip(target, layout.text_bounds);
paint_line_text(
target,
dwrite,
line,
layout.text_bounds.x,
y,
line_height,
cell_width,
editor.scroll_left,
layout.visible_cols,
theme.foreground,
);
for diag in &line.diagnostics {
let start = diag.start_col.saturating_sub(editor.scroll_left);
let end = diag.end_col.saturating_sub(editor.scroll_left);
if end <= start {
continue;
}
let color = match diag.severity {
DiagnosticSeverity::Error => theme.diagnostic_error,
DiagnosticSeverity::Warning => theme.diagnostic_warning,
DiagnosticSeverity::Information => theme.diagnostic_info,
DiagnosticSeverity::Hint => theme.diagnostic_hint,
};
let ux = layout.text_bounds.x + start as f32 * cell_width;
let uw = (end - start) as f32 * cell_width;
let _ = fill_rect(target, Rect::new(ux, y + line_height - 2.0, uw, 2.0), color);
}
pop_clip(target);
}
if let Some(cursor) = &editor.cursor {
let y = rect.y + cursor.pos.view_line as f32 * line_height;
let col = cursor.pos.col.saturating_sub(editor.scroll_left);
let x = layout.text_bounds.x + col as f32 * cell_width;
match cursor.shape {
CursorShape::Block => {
let color = bg.blend(theme.cursor, theme.cursor_normal_alpha as f64);
let _ = fill_rect(target, Rect::new(x, y, cell_width, line_height), color);
if let Some(line) = editor.lines.get(cursor.pos.view_line) {
let raw = line.raw_text.trim_end_matches('\n');
if let Some(ch) = raw.chars().nth(cursor.pos.col) {
let ch = ch.to_string();
let _ = dwrite.draw_text(
target,
&ch,
Rect::new(x, y, cell_width, line_height),
theme.background,
);
}
}
}
CursorShape::Bar => {
let _ = fill_rect(target, Rect::new(x, y, 2.0, line_height), theme.cursor);
}
CursorShape::Underline => {
let _ = fill_rect(
target,
Rect::new(x, y + line_height - 2.0, cell_width, 2.0),
theme.cursor,
);
}
}
}
EditorPaintResult::default()
}
#[allow(clippy::too_many_arguments)]
fn paint_selection(
target: &ID2D1RenderTarget,
sel: &EditorSelection,
lines: &[EditorLine],
rect: Rect,
line_height: f32,
text_x: f32,
cell_width: f32,
color: Color,
alpha: f32,
bg: Color,
) {
for (view_idx, line) in lines.iter().enumerate() {
let Some(cols) = sel.cols_on(line) else {
continue;
};
let y = rect.y + view_idx as f32 * line_height;
let x = text_x + cols.start as f32 * cell_width;
let w = (cols.end - cols.start) as f32 * cell_width;
let blended = bg.blend(color, alpha as f64);
let _ = fill_rect(target, Rect::new(x, y, w, line_height), blended);
}
}
#[allow(clippy::too_many_arguments)]
fn paint_line_text(
target: &ID2D1RenderTarget,
dwrite: &DWrite,
line: &EditorLine,
text_x: f32,
y: f32,
line_height: f32,
cell_width: f32,
scroll_left: usize,
visible_cols: usize,
default_fg: Color,
) {
let raw = line.raw_text.trim_end_matches('\n');
let chars: Vec<(usize, char)> = raw.char_indices().collect();
if chars.len() <= scroll_left {
return;
}
let end = (scroll_left + visible_cols).min(chars.len());
if end <= scroll_left {
return;
}
let style_at = |byte_off: usize| -> (Color, bool) {
for span in &line.spans {
if byte_off >= span.start_byte && byte_off < span.end_byte {
return (span.style.fg, span.style.bold);
}
}
(default_fg, false)
};
let mut run_start = scroll_left;
let mut run_style = style_at(chars[scroll_left].0);
let mut cursor_x = text_x;
let flush = |from: usize, to: usize, style: (Color, bool), cursor_x: &mut f32| {
if to <= from {
return;
}
let start_byte = chars[from].0;
let end_byte = chars.get(to).map(|(b, _)| *b).unwrap_or(raw.len());
let text = &raw[start_byte..end_byte];
let w = (to - from) as f32 * cell_width;
let (_, h) = dwrite.measure_text(text).unwrap_or((0.0, line_height));
let _ = dwrite.draw_text_styled(
target,
text,
Rect::new(*cursor_x, y, w, h.max(line_height)),
style.0,
style.1,
);
*cursor_x += w;
};
for (i, (byte_off, _)) in chars.iter().enumerate().take(end).skip(scroll_left + 1) {
let s = style_at(*byte_off);
if s != run_style {
flush(run_start, i, run_style, &mut cursor_x);
run_start = i;
run_style = s;
}
}
flush(run_start, end, run_style, &mut cursor_x);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::Rect as QRect;
use crate::primitives::editor::{
CursorPos, DiagnosticMark, DiffLine, EditorCursor, EditorStyledSpan, SelectionKind, Style,
};
use crate::types::WidgetId;
use crate::win::testing::HeadlessSurface;
const CELL_W: f32 = 8.0;
const LINE_H: f32 = 16.0;
fn plain_line(idx: usize, text: &str) -> EditorLine {
EditorLine {
raw_text: text.to_string(),
gutter_text: format!("{:>3}", idx + 1),
spans: vec![EditorStyledSpan {
start_byte: 0,
end_byte: text.len(),
style: Style {
fg: Color::rgb(200, 200, 200),
bg: None,
bold: false,
italic: false,
font_scale: 1.0,
},
}],
line_idx: idx,
is_current_line: false,
is_fold_header: false,
folded_line_count: 0,
git_diff: None,
diff_status: None,
diagnostics: Vec::new(),
spell_errors: Vec::new(),
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::new(),
colorcolumns: Vec::new(),
}
}
fn editor(lines: Vec<EditorLine>) -> Editor {
Editor {
id: WidgetId::new("editor"),
rect: QRect::new(0.0, 0.0, 200.0, 100.0),
lines,
cursor: None,
extra_cursors: Vec::new(),
selection: None,
extra_selections: Vec::new(),
yank_highlight: None,
scroll_top: 0,
scroll_left: 0,
total_lines: 3,
max_col: 20,
gutter_char_width: 4,
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: Vec::new(),
active_indent_col: None,
tabstop: 4,
cursorline: true,
lightbulb_glyph: '!',
}
}
#[test]
fn paints_without_panicking_and_gutter_is_visible() {
let surface = HeadlessSurface::new(200, 100).expect("create surface");
let (dwrite, _, _) = DWrite::new("Consolas", 10.0, None).expect("create DWrite");
let mut lines = vec![
plain_line(0, "fn main() {"),
plain_line(1, " ok();"),
plain_line(2, "}"),
];
lines[0].is_current_line = true;
lines[1].diagnostics.push(DiagnosticMark {
start_col: 4,
end_col: 6,
severity: DiagnosticSeverity::Error,
message: "unused".into(),
});
let mut e = editor(lines);
e.cursor = Some(EditorCursor {
pos: CursorPos {
view_line: 0,
col: 3,
},
shape: CursorShape::Bar,
});
e.selection = Some(EditorSelection {
kind: SelectionKind::Char,
start_line: 1,
start_col: 0,
end_line: 1,
end_col: 4,
});
surface
.paint(|target| {
draw_editor(target, &dwrite, &e, CELL_W, LINE_H, &Theme::default());
})
.expect("paint editor");
let theme = Theme::default();
let gutter_w = (4.0 * CELL_W) as u32;
let found = (0..gutter_w).any(|x| {
(0..LINE_H as u32).any(|y| {
let px = surface.pixel_at(x, y);
(px.r, px.g, px.b)
== (
theme.line_number_active_fg.r,
theme.line_number_active_fg.g,
theme.line_number_active_fg.b,
)
})
});
assert!(
found,
"current line's gutter number should paint line_number_active_fg somewhere in the gutter column"
);
}
#[test]
fn line_selection_spans_full_row() {
let surface = HeadlessSurface::new(200, 100).expect("create surface");
let (dwrite, _, _) = DWrite::new("Consolas", 10.0, None).expect("create DWrite");
let text = format!("ab{}", " ".repeat(23));
let lines = vec![plain_line(0, &text)];
let mut e = editor(lines);
e.selection = Some(EditorSelection {
kind: SelectionKind::Line,
start_line: 0,
start_col: 2,
end_line: 0,
end_col: 3,
});
surface
.paint(|target| {
draw_editor(target, &dwrite, &e, CELL_W, LINE_H, &Theme::default());
})
.expect("paint editor");
let theme = Theme::default();
let text_x = (e.gutter_char_width as f32 * CELL_W) as u32;
let sample_x = 180u32.max(text_x + 5);
let px = surface.pixel_at(sample_x, 4);
let expected = theme
.background
.blend(theme.selection, theme.selection_alpha as f64);
assert_eq!((px.r, px.g, px.b), (expected.r, expected.g, expected.b));
}
#[test]
fn selection_respects_wrapped_ghost_and_diff_padding_rows() {
let surface = HeadlessSurface::new(200, 100).expect("create surface");
let (dwrite, _, _) = DWrite::new("Consolas", 10.0, None).expect("create DWrite");
let lines = vec![
plain_line(0, &format!("01{}", " ".repeat(8))),
EditorLine {
segment_col_offset: 5,
is_wrap_continuation: true,
..plain_line(1, &format!("ab{}", " ".repeat(8)))
},
EditorLine {
is_ghost_continuation: true,
ghost_suffix: Some("ai suggestion".into()),
..plain_line(1, "")
},
EditorLine {
diff_status: Some(DiffLine::Padding),
..plain_line(2, "")
},
];
let mut e = editor(lines);
e.total_lines = 4;
e.selection = Some(EditorSelection {
kind: SelectionKind::Char,
start_line: 0,
start_col: 0,
end_line: 2,
end_col: 10,
});
surface
.paint(|target| {
draw_editor(target, &dwrite, &e, CELL_W, LINE_H, &Theme::default());
})
.expect("paint editor");
let theme = Theme::default();
let blended = theme
.background
.blend(theme.selection, theme.selection_alpha as f64);
let text_x = (e.gutter_char_width as f32 * CELL_W) as i32;
let sample_x = (text_x as f32 + 5.0 * CELL_W + CELL_W / 2.0) as u32;
let row_y = |row: i32| (row as f32 * LINE_H + LINE_H / 2.0) as u32;
let row0 = surface.pixel_at(sample_x, row_y(0));
assert_eq!(
(row0.r, row0.g, row0.b),
(blended.r, blended.g, blended.b),
"row 0 (plain, selection's start line) should be tinted"
);
let row1 = surface.pixel_at(sample_x, row_y(1));
assert_eq!(
(row1.r, row1.g, row1.b),
(blended.r, blended.g, blended.b),
"row 1 (wrapped segment of a fully-selected line) should be tinted"
);
let row2 = surface.pixel_at(sample_x, row_y(2));
assert_eq!(
(row2.r, row2.g, row2.b),
(theme.background.r, theme.background.g, theme.background.b),
"row 2 (ghost continuation) must never be tinted by a selection"
);
let row3 = surface.pixel_at(sample_x, row_y(3));
assert_eq!(
(row3.r, row3.g, row3.b),
(theme.background.r, theme.background.g, theme.background.b),
"row 3 (diff padding) must never be tinted by a selection"
);
}
#[test]
fn block_cursor_repaints_glyph_in_background_colour() {
let surface = HeadlessSurface::new(200, 100).expect("create surface");
let (dwrite, _, _) = DWrite::new("Consolas", 10.0, None).expect("create DWrite");
let lines = vec![plain_line(0, "1789518172522")];
let mut e = editor(lines);
e.cursor = Some(EditorCursor {
pos: CursorPos {
view_line: 0,
col: 0,
},
shape: CursorShape::Block,
});
surface
.paint(|target| {
draw_editor(target, &dwrite, &e, CELL_W, LINE_H, &Theme::default());
})
.expect("paint editor");
let theme = Theme::default();
let text_x = (e.gutter_char_width as f32 * CELL_W) as u32;
let found = (text_x..text_x + CELL_W as u32).any(|x| {
(0..LINE_H as u32).any(|y| {
let px = surface.pixel_at(x, y);
(px.r, px.g, px.b) == (theme.background.r, theme.background.g, theme.background.b)
})
});
assert!(
found,
"block cursor should re-paint the covered glyph in \
theme.background somewhere inside the cursor cell (col 0, \
row 0) — got no such pixel, cursor fill likely still hides \
the glyph entirely"
);
}
}