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, EditorPaintOptions, 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 {
draw_editor_with_options_and_v_scrollbar_w(
target,
dwrite,
editor,
cell_width,
line_height,
theme,
EditorPaintOptions::default(),
None,
)
}
#[allow(clippy::too_many_arguments)]
pub fn draw_editor_with_options_and_v_scrollbar_w(
target: &ID2D1RenderTarget,
dwrite: &DWrite,
editor: &Editor,
cell_width: f32,
line_height: f32,
theme: &Theme,
options: EditorPaintOptions,
v_scrollbar_w: Option<f32>,
) -> EditorPaintResult {
let rect = editor.rect;
let layout = editor.layout_with_options_and_v_scrollbar_w(
rect,
cell_width,
line_height,
options,
v_scrollbar_w,
);
let bg = if editor.show_active_bg {
theme.editor_active_background
} else {
theme.background
};
let _ = fill_rect(target, rect, bg);
let text_v_offset = text_v_offset(dwrite, line_height);
let cursorline_w = (layout.text_bounds.x + layout.text_bounds.width - rect.x).max(0.0);
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, cursorline_w, 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, _) = dwrite.measure_text(&line.gutter_text).unwrap_or((0.0, 0.0));
let gx = rect.x + (gutter_w - gw).max(0.0);
let _ = dwrite.draw_text(
target,
&line.gutter_text,
Rect::new(gx, y + text_v_offset, gw, line_height),
gutter_fg,
);
}
push_clip(target, layout.text_bounds);
paint_line_text(
target,
dwrite,
line,
layout.text_bounds.x,
y,
line_height,
text_v_offset,
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 + text_v_offset, 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()
}
fn text_v_offset(dwrite: &DWrite, line_height: f32) -> f32 {
(line_height - dwrite.natural_line_height()) / 2.0
}
#[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,
text_v_offset: 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 + text_v_offset, 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 v_scrollbar_w_option_narrows_text_bounds() {
let mut e = editor(vec![plain_line(0, "line one")]);
e.total_lines = 50; let rect = e.rect;
let baseline = e.layout(rect, CELL_W, LINE_H);
let baseline_vsb = baseline
.v_scrollbar_bounds
.expect("overflowing buffer reserves a vertical scrollbar column");
assert_eq!(baseline_vsb.width, CELL_W);
let widened = e.layout_with_options_and_v_scrollbar_w(
rect,
CELL_W,
LINE_H,
EditorPaintOptions::default(),
Some(14.0),
);
let widened_vsb = widened
.v_scrollbar_bounds
.expect("still overflowing with the override applied");
assert_eq!(widened_vsb.width, 14.0);
assert_eq!(
widened.text_bounds.width,
baseline.text_bounds.width - (14.0 - CELL_W)
);
}
#[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"
);
}
#[test]
fn body_text_centred_in_row_and_shares_baseline_with_gutter() {
let (dwrite, natural_line_height, _) =
DWrite::new("Consolas", 10.0, None).expect("create DWrite");
const ROW_PITCH: f32 = 24.0;
assert!(
natural_line_height < ROW_PITCH,
"fixture needs a font natural line height ({natural_line_height}) shorter than \
the row pitch ({ROW_PITCH}) to leave any slack to centre in",
);
let gutter_chars = 2usize;
let gutter_w = gutter_chars as f32 * CELL_W;
let theme = Theme::default();
let paint_at_pitch = |pitch: f32| -> HeadlessSurface {
let surface =
HeadlessSurface::new(200, pitch.ceil() as u32 + 8).expect("create surface");
let mut line = plain_line(0, "7");
line.gutter_text = "7".into();
let mut e = editor(vec![line]);
e.gutter_char_width = gutter_chars;
e.rect = QRect::new(0.0, 0.0, 200.0, pitch + 8.0);
surface
.paint(|target| {
draw_editor(target, &dwrite, &e, CELL_W, pitch, &theme);
})
.expect("paint editor");
surface
};
let ink_extent = |surface: &HeadlessSurface, x_start: u32, x_end: u32, y_end: u32| {
const TOL: i32 = 24;
let mut extent: Option<(u32, u32)> = None;
for y in 0..y_end {
let has_ink = (x_start..x_end).any(|x| {
let px = surface.pixel_at(x, y);
(px.r as i32 - theme.background.r as i32).abs() > TOL
|| (px.g as i32 - theme.background.g as i32).abs() > TOL
|| (px.b as i32 - theme.background.b as i32).abs() > TOL
});
if has_ink {
extent = Some(match extent {
Some((top, _)) => (top, y),
None => (y, y),
});
}
}
extent
};
let natural_surface = paint_at_pitch(natural_line_height);
let natural = ink_extent(
&natural_surface,
gutter_w.round() as u32,
(gutter_w + CELL_W).round() as u32,
natural_line_height.ceil() as u32 + 8,
)
.expect("body glyph should paint some ink at the natural pitch");
let wide_surface = paint_at_pitch(ROW_PITCH);
let wide = ink_extent(
&wide_surface,
gutter_w.round() as u32,
(gutter_w + CELL_W).round() as u32,
ROW_PITCH as u32 + 8,
)
.expect("body glyph should paint some ink at the wide pitch");
let expected_shift = (ROW_PITCH - natural_line_height) / 2.0;
let top_shift = wide.0 as f32 - natural.0 as f32;
let bottom_shift = wide.1 as f32 - natural.1 as f32;
assert!(
(top_shift - expected_shift).abs() <= 2.0
&& (bottom_shift - expected_shift).abs() <= 2.0,
"widening the row pitch from {natural_line_height} to {ROW_PITCH} should move \
the body glyph down by half the added slack ({expected_shift}px), got top \
{top_shift}px / bottom {bottom_shift}px (ink {natural:?} -> {wide:?})"
);
let gutter = ink_extent(
&wide_surface,
0,
gutter_w.round() as u32,
ROW_PITCH as u32 + 8,
)
.expect("gutter glyph should paint some ink");
assert_eq!(
gutter, wide,
"gutter number and body text (same glyph, \"7\") should share a baseline, got \
gutter={gutter:?}, body={wide:?}"
);
}
}