use core_graphics::sys::CGContextRef;
use core_text::font::CTFont;
use super::cg::*;
use super::surface::CgSurface;
use super::text::{draw_text, measure_text};
use crate::backend::EditorPaintResult;
use crate::primitives::editor::{
CursorShape, DiffLine, Editor, EditorLine, EditorPaintOptions, EditorSelection, SelectionKind,
};
use crate::primitives::scrollbar::{native_surface_paint, Scrollbar};
use crate::text_util::snap_to_char_boundary;
use crate::theme::Theme;
use crate::types::Color;
pub unsafe fn draw_editor(
ctx: CGContextRef,
font: &CTFont,
editor: &Editor,
theme: &Theme,
char_width: f64,
line_height: f64,
) -> EditorPaintResult {
draw_editor_with_options(
ctx,
font,
editor,
theme,
char_width,
line_height,
EditorPaintOptions::default(),
)
}
#[allow(clippy::too_many_arguments)]
pub unsafe fn draw_editor_with_options(
ctx: CGContextRef,
font: &CTFont,
editor: &Editor,
theme: &Theme,
char_width: f64,
line_height: f64,
options: EditorPaintOptions,
) -> EditorPaintResult {
draw_editor_with_options_and_v_scrollbar_w(
ctx,
font,
editor,
theme,
char_width,
line_height,
options,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub unsafe fn draw_editor_with_options_and_v_scrollbar_w(
ctx: CGContextRef,
font: &CTFont,
editor: &Editor,
theme: &Theme,
char_width: f64,
line_height: f64,
options: EditorPaintOptions,
v_scrollbar_w: Option<f32>,
) -> EditorPaintResult {
let rect = &editor.rect;
if rect.width <= 0.0 || rect.height <= 0.0 {
return EditorPaintResult::default();
}
let x = rect.x as f64;
let y = rect.y as f64;
let w = rect.width as f64;
let h = rect.height as f64;
CGContextSaveGState(ctx);
CGContextClipToRect(ctx, super::cg::rect(x, y, w, h));
let bg = if editor.show_active_bg {
theme.editor_active_background
} else {
theme.background
};
fill_rect(ctx, x, y, w, h, bg);
let gutter_width = editor.gutter_char_width as f64 * char_width;
let h_scroll_offset = editor.scroll_left as f64 * char_width;
let text_x_offset = x + gutter_width - h_scroll_offset;
let v_offset = text_v_offset(font, line_height);
let editor_geom = editor.layout_with_options_and_v_scrollbar_w(
*rect,
char_width as f32,
line_height as f32,
options,
v_scrollbar_w,
);
let row_bg_w =
(editor_geom.text_bounds.x as f64 + editor_geom.text_bounds.width as f64 - x).max(0.0);
for (view_idx, line) in editor.lines.iter().enumerate() {
let row_bg = if line.is_dap_current {
Some(theme.dap_stopped_bg)
} else if let Some(diff_status) = line.diff_status {
match diff_status {
DiffLine::Added => Some(theme.diff_added_bg),
DiffLine::Removed => Some(theme.diff_removed_bg),
DiffLine::Padding => Some(theme.diff_padding_bg),
DiffLine::Same => None,
}
} else if line.is_current_line && editor.is_active && editor.cursorline {
Some(theme.cursorline_bg)
} else {
None
};
if let Some(color) = row_bg {
let line_y = y + view_idx as f64 * line_height;
fill_rect(ctx, x, line_y, row_bg_w, line_height, color);
}
}
if let Some(sel) = &editor.selection {
draw_visual_selection(
ctx,
font,
sel,
&editor.lines,
x,
y,
w,
line_height,
text_x_offset,
theme.selection,
theme.selection_alpha as f64,
);
}
for esel in &editor.extra_selections {
draw_visual_selection(
ctx,
font,
esel,
&editor.lines,
x,
y,
w,
line_height,
text_x_offset,
theme.selection,
theme.selection_alpha as f64,
);
}
if let Some(yh) = &editor.yank_highlight {
draw_visual_selection(
ctx,
font,
yh,
&editor.lines,
x,
y,
w,
line_height,
text_x_offset,
theme.yank_highlight_bg,
theme.yank_highlight_alpha as f64,
);
}
for (view_idx, line) in editor.lines.iter().enumerate() {
let line_y = y + view_idx as f64 * line_height;
if line_y >= y + h {
break;
}
if gutter_width > 0.0 && !line.gutter_text.is_empty() {
let (gtw, _) = measure_text(font, &line.gutter_text);
let gx = x + gutter_width - gtw - 4.0;
draw_text(
ctx,
font,
&line.gutter_text,
gx.max(x + 2.0),
line_y + v_offset,
color_to_cg(theme.line_number_fg),
);
}
}
CGContextSaveGState(ctx);
CGContextClipToRect(
ctx,
super::cg::rect(x + gutter_width, y, editor_geom.text_bounds.width as f64, h),
);
for (view_idx, line) in editor.lines.iter().enumerate() {
let line_y = y + view_idx as f64 * line_height;
if line_y >= y + h {
break;
}
let raw_x = text_x_offset;
paint_line_text(
ctx,
font,
line,
raw_x,
line_y,
v_offset,
line_height,
theme.foreground,
);
}
CGContextRestoreGState(ctx);
if let Some(v_track) = editor_geom.v_scrollbar_bounds {
let sb = Scrollbar::vertical(
"macos:editor:v_scrollbar",
v_track,
editor.scroll_top as f32,
editor.total_lines as f32,
editor_geom.visible_lines as f32,
line_height as f32,
);
let mut surface = CgSurface {
ctx,
font: Some(font),
};
native_surface_paint::paint(&sb, &mut surface, theme);
}
if let Some(cursor) = editor.cursor {
if cursor.pos.view_line < editor.lines.len() {
let line = &editor.lines[cursor.pos.view_line];
let prefix_end = char_byte_offset(&line.raw_text, cursor.pos.col);
let prefix = &line.raw_text[..prefix_end];
let (prefix_w, _) = measure_text(font, prefix);
let cur_x = text_x_offset + prefix_w;
let cur_y = y + cursor.pos.view_line as f64 * line_height;
match cursor.shape {
CursorShape::Block => {
fill_rect(ctx, cur_x, cur_y, char_width, line_height, theme.cursor);
let ch = line.raw_text[prefix_end..]
.chars()
.next()
.map(|c| c.to_string())
.unwrap_or_default();
if !ch.is_empty() {
draw_text(
ctx,
font,
&ch,
cur_x,
cur_y + v_offset,
color_to_cg(theme.background),
);
}
}
CursorShape::Bar => {
fill_rect(ctx, cur_x, cur_y, 2.0, line_height, theme.cursor);
}
CursorShape::Underline => {
let underline_h = (line_height * 0.12).max(1.0);
fill_rect(
ctx,
cur_x,
cur_y + line_height - underline_h,
char_width,
underline_h,
theme.cursor,
);
}
}
}
}
CGContextRestoreGState(ctx);
EditorPaintResult::default()
}
fn text_v_offset(font: &CTFont, line_height: f64) -> f64 {
(line_height - (font.ascent() + font.descent() + font.leading())) / 2.0
}
#[allow(clippy::too_many_arguments)]
unsafe fn paint_line_text(
ctx: CGContextRef,
font: &CTFont,
line: &EditorLine,
raw_x: f64,
line_y: f64,
v_offset: f64,
line_height: f64,
default_fg: Color,
) {
let text = &line.raw_text;
if text.is_empty() {
return;
}
let mut ordered_spans: Vec<(usize, usize, Color, Option<Color>)> = line
.spans
.iter()
.filter_map(|span| {
let start = snap_to_char_boundary(text, span.start_byte);
let end = snap_to_char_boundary(text, span.end_byte);
(start < end).then_some((start, end, span.style.fg, span.style.bg))
})
.collect();
ordered_spans.sort_by_key(|&(start, _, _, _)| start);
let paint_run = |from: usize, to: usize, fg: Color, bg: Option<Color>| {
if from >= to {
return;
}
let (prefix_w, _) = measure_text(font, &text[..from]);
let run_x = raw_x + prefix_w;
let slice = &text[from..to];
if let Some(bg) = bg {
let (run_w, _) = measure_text(font, slice);
fill_rect(ctx, run_x, line_y, run_w, line_height, bg);
}
draw_text(ctx, font, slice, run_x, line_y + v_offset, color_to_cg(fg));
};
let mut cursor = 0usize;
for (start, end, fg, bg) in ordered_spans {
let start = start.max(cursor);
if start >= end {
continue;
}
if start > cursor {
paint_run(cursor, start, default_fg, None);
}
paint_run(start, end, fg, bg);
cursor = end;
}
if cursor < text.len() {
paint_run(cursor, text.len(), default_fg, None);
}
}
fn char_byte_offset(s: &str, col: usize) -> usize {
s.char_indices().nth(col).map(|(b, _)| b).unwrap_or(s.len())
}
#[allow(clippy::too_many_arguments)]
unsafe fn draw_visual_selection(
ctx: CGContextRef,
font: &CTFont,
sel: &EditorSelection,
lines: &[EditorLine],
x: f64,
y: f64,
w: f64,
line_height: f64,
text_x_offset: f64,
color: Color,
alpha: f64,
) {
for (view_idx, rl) in lines.iter().enumerate() {
let Some(cols) = sel.cols_on(rl) else {
continue;
};
let line_y = y + view_idx as f64 * line_height;
if sel.kind == SelectionKind::Line {
let highlight_width = w - (text_x_offset - x);
fill_rect_alpha(
ctx,
text_x_offset,
line_y,
highlight_width,
line_height,
color,
alpha,
);
continue;
}
let start_byte = char_byte_offset(&rl.raw_text, cols.start);
let (prefix_w, _) = measure_text(font, &rl.raw_text[..start_byte]);
let start_x = text_x_offset + prefix_w;
let width = if cols.extends_beyond {
let (line_w, _) = measure_text(font, &rl.raw_text);
(text_x_offset + line_w - start_x).max(0.0)
} else {
let end_byte = char_byte_offset(&rl.raw_text, cols.end);
let (end_w, _) = measure_text(font, &rl.raw_text[..end_byte]);
(text_x_offset + end_w - start_x).max(0.0)
};
if width > 0.0 {
fill_rect_alpha(ctx, start_x, line_y, width, line_height, color, alpha);
}
}
}
#[cfg(test)]
mod tests {
use super::super::headless::BitmapSurface;
use super::super::text::{font_metrics, make_font};
use super::super::MacBackend;
use super::*;
use crate::event::{Rect as QRect, Viewport};
use crate::primitives::editor::{
CursorPos, EditorCursor, EditorLine, EditorStyledSpan as ESpan, Style,
};
use crate::theme::Theme;
use crate::Backend;
use std::collections::{HashMap, HashSet};
const W: u32 = 400;
const H: u32 = 160;
fn font() -> CTFont {
make_font("Menlo", 14.0).expect("Menlo installed")
}
fn one_line(text: &str) -> EditorLine {
EditorLine {
raw_text: text.into(),
gutter_text: "1".into(),
spans: vec![],
line_idx: 0,
is_current_line: true,
is_fold_header: false,
folded_line_count: 0,
git_diff: None,
diff_status: None,
diagnostics: vec![],
spell_errors: vec![],
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![],
colorcolumns: vec![],
}
}
fn editor_with_cursor(text: &str, shape: CursorShape, col: usize) -> Editor {
Editor {
id: "editor:0".into(),
rect: QRect::new(0.0, 0.0, W as f32, H as f32),
lines: vec![one_line(text)],
cursor: Some(EditorCursor {
pos: CursorPos { view_line: 0, col },
shape,
}),
extra_cursors: vec![],
selection: None,
extra_selections: vec![],
yank_highlight: None,
scroll_top: 0,
scroll_left: 0,
total_lines: 1,
max_col: text.chars().count(),
gutter_char_width: 3,
is_active: true,
show_active_bg: false,
has_git_diff: false,
has_breakpoints: false,
diagnostic_gutter: HashMap::new(),
code_action_lines: HashSet::new(),
bracket_match_positions: vec![],
active_indent_col: None,
tabstop: 4,
cursorline: false,
lightbulb_glyph: '!',
}
}
fn paint_via_backend(editor: &Editor) -> BitmapSurface {
let surface = BitmapSurface::new(W, H);
surface.fill(0.0, 0.0, 0.0, 0.0);
let mut backend = MacBackend::new();
backend.set_current_font(font());
backend.begin_frame(Viewport::new(W as f32, H as f32, 1.0));
backend.enter_frame_scope(surface.context_ptr(), |b| {
b.draw_editor(editor.rect, editor);
});
backend.end_frame();
surface
}
#[test]
fn block_cursor_paints_theme_cursor_color() {
let editor = editor_with_cursor("let x = 1;", CursorShape::Block, 0);
let surface = paint_via_backend(&editor);
let theme = Theme::default();
let metrics = font_metrics(&font());
let cx = (3.0 * metrics.char_width) as u32 + 1;
let cy = (metrics.line_height / 2.0) as u32;
let (r, g, b, _) = surface.pixel(cx, cy);
assert_eq!(
(r, g, b),
(theme.cursor.r, theme.cursor.g, theme.cursor.b),
"block cursor pixel at ({}, {}) should be theme.cursor",
cx,
cy,
);
}
#[test]
fn cursorline_highlight_painted_when_active() {
let mut editor = editor_with_cursor("let x = 1;", CursorShape::Bar, 0);
editor.cursorline = true;
let surface = paint_via_backend(&editor);
let theme = Theme::default();
let px = W - 4;
let py = 4_u32;
let (r, g, b, _) = surface.pixel(px, py);
assert_eq!(
(r, g, b),
(
theme.cursorline_bg.r,
theme.cursorline_bg.g,
theme.cursorline_bg.b
),
);
}
#[test]
fn span_bg_paints_over_raw_run() {
let mut line = one_line("alpha beta");
line.spans = vec![ESpan {
start_byte: 0,
end_byte: 3,
style: Style {
fg: Color::rgb(255, 255, 255),
bg: Some(Color::rgb(50, 100, 150)),
bold: false,
italic: false,
font_scale: 1.0,
},
}];
let mut editor = editor_with_cursor("alpha beta", CursorShape::Bar, 99);
editor.lines = vec![line];
let surface = paint_via_backend(&editor);
let metrics = font_metrics(&font());
let text_start = 3.0 * metrics.char_width;
let px = (text_start + metrics.char_width * 0.5) as u32;
let py = 0_u32;
let (r, g, b, _) = surface.pixel(px, py);
assert_eq!((r, g, b), (50, 100, 150));
}
#[test]
fn span_covering_whole_line_paints_exactly_one_text_run() {
let mut line = one_line("alpha beta");
line.spans = vec![ESpan {
start_byte: 0,
end_byte: line.raw_text.len(),
style: Style {
fg: Color::rgb(200, 100, 50),
bg: None,
bold: false,
italic: false,
font_scale: 1.0,
},
}];
let mut editor = editor_with_cursor("alpha beta", CursorShape::Bar, 99);
editor.lines = vec![line];
let surface = BitmapSurface::new(W, H);
surface.fill(0.0, 0.0, 0.0, 0.0);
let mut backend = MacBackend::new();
backend.set_current_font(font());
backend.set_painted_text_recording(true);
backend.begin_frame(Viewport::new(W as f32, H as f32, 1.0));
backend.enter_frame_scope(surface.context_ptr(), |b| {
b.draw_editor(editor.rect, &editor);
});
backend.end_frame();
let whole_line_runs: Vec<_> = backend
.text_runs()
.iter()
.filter(|run| run.text == "alpha beta")
.collect();
assert_eq!(
whole_line_runs.len(),
1,
"\"alpha beta\" should be painted exactly once (overlapping raw-run + \
span-overlay would record it twice at the same position), got: {:?}",
whole_line_runs,
);
}
#[test]
fn span_covering_part_of_line_leaves_gap_runs_contiguous() {
let mut line = one_line("alpha beta");
line.spans = vec![ESpan {
start_byte: 0,
end_byte: 5,
style: Style {
fg: Color::rgb(200, 100, 50),
bg: None,
bold: false,
italic: false,
font_scale: 1.0,
},
}];
let mut editor = editor_with_cursor("alpha beta", CursorShape::Bar, 99);
editor.lines = vec![line];
let surface = BitmapSurface::new(W, H);
surface.fill(0.0, 0.0, 0.0, 0.0);
let mut backend = MacBackend::new();
backend.set_current_font(font());
backend.set_painted_text_recording(true);
backend.begin_frame(Viewport::new(W as f32, H as f32, 1.0));
backend.enter_frame_scope(surface.context_ptr(), |b| {
b.draw_editor(editor.rect, &editor);
});
backend.end_frame();
let metrics = font_metrics(&font());
let text_start = 3.0 * metrics.char_width;
let span_runs: Vec<_> = backend
.text_runs()
.iter()
.filter(|run| run.text == "alpha")
.collect();
assert_eq!(
span_runs.len(),
1,
"the span \"alpha\" should be painted exactly once, got: {:?}",
span_runs,
);
assert!(
(span_runs[0].bounds.x as f64 - text_start).abs() < 0.5,
"the span run should start at the line's text origin, got x={}",
span_runs[0].bounds.x,
);
let gap_runs: Vec<_> = backend
.text_runs()
.iter()
.filter(|run| run.text == " beta")
.collect();
assert_eq!(
gap_runs.len(),
1,
"the uncovered gap \" beta\" should still be painted exactly \
once, got: {:?}",
gap_runs,
);
let gap_x_expected = text_start + 5.0 * metrics.char_width;
assert!(
(gap_runs[0].bounds.x as f64 - gap_x_expected).abs() < 0.5,
"the gap run should start right after the span ends, got x={}, expected={}",
gap_runs[0].bounds.x,
gap_x_expected,
);
}
#[test]
fn multibyte_span_and_cursor_do_not_panic() {
let text = "café🎉 beta";
assert!(!text.is_char_boundary(4));
assert!(!text.is_char_boundary(8));
let mut line = one_line(text);
line.spans = vec![ESpan {
start_byte: 4,
end_byte: 8,
style: Style {
fg: Color::rgb(255, 255, 255),
bg: Some(Color::rgb(50, 100, 150)),
bold: false,
italic: false,
font_scale: 1.0,
},
}];
let mut editor = editor_with_cursor(text, CursorShape::Block, 3);
editor.lines = vec![line];
let _surface = paint_via_backend(&editor);
}
#[test]
fn empty_editor_returns_default_paint_result() {
let editor = Editor {
id: "editor:empty".into(),
rect: QRect::new(0.0, 0.0, 0.0, 0.0),
lines: vec![],
cursor: None,
extra_cursors: vec![],
selection: None,
extra_selections: vec![],
yank_highlight: None,
scroll_top: 0,
scroll_left: 0,
total_lines: 0,
max_col: 0,
gutter_char_width: 0,
is_active: false,
show_active_bg: false,
has_git_diff: false,
has_breakpoints: false,
diagnostic_gutter: HashMap::new(),
code_action_lines: HashSet::new(),
bracket_match_positions: vec![],
active_indent_col: None,
tabstop: 4,
cursorline: false,
lightbulb_glyph: '!',
};
let surface = BitmapSurface::new(10, 10);
surface.fill(0.0, 0.0, 0.0, 0.0);
let mut backend = MacBackend::new();
backend.set_current_font(font());
backend.begin_frame(Viewport::new(10.0, 10.0, 1.0));
let result = std::cell::RefCell::new(None);
backend.enter_frame_scope(surface.context_ptr(), |b| {
*result.borrow_mut() = Some(b.draw_editor(editor.rect, &editor));
});
backend.end_frame();
assert_eq!(result.into_inner().unwrap(), EditorPaintResult::default(),);
}
fn approx(a: u8, b: u8, tol: u8) -> bool {
a.abs_diff(b) <= tol
}
#[test]
fn line_selection_paints_over_background() {
let mut editor = editor_with_cursor("let x = 1;", CursorShape::Bar, 99);
editor.selection = Some(EditorSelection {
kind: SelectionKind::Line,
start_line: 0,
start_col: 0,
end_line: 0,
end_col: 0,
});
let surface = paint_via_backend(&editor);
let theme = Theme::default();
let (r, g, b, _) = surface.pixel(W - 4, 4);
assert_ne!(
(r, g, b),
(theme.background.r, theme.background.g, theme.background.b),
"selected row should not read as plain background"
);
let expected_r = (theme.selection.r as f32 * theme.selection_alpha
+ theme.background.r as f32 * (1.0 - theme.selection_alpha))
as u8;
let expected_g = (theme.selection.g as f32 * theme.selection_alpha
+ theme.background.g as f32 * (1.0 - theme.selection_alpha))
as u8;
let expected_b = (theme.selection.b as f32 * theme.selection_alpha
+ theme.background.b as f32 * (1.0 - theme.selection_alpha))
as u8;
assert!(
approx(r, expected_r, 4) && approx(g, expected_g, 4) && approx(b, expected_b, 4),
"expected ~({expected_r}, {expected_g}, {expected_b}), got ({r}, {g}, {b})"
);
}
#[test]
fn yank_highlight_paints_distinct_color() {
let mut editor = editor_with_cursor("let x = 1;", CursorShape::Bar, 99);
editor.yank_highlight = Some(EditorSelection {
kind: SelectionKind::Line,
start_line: 0,
start_col: 0,
end_line: 0,
end_col: 0,
});
let surface = paint_via_backend(&editor);
let theme = Theme::default();
let (r, g, b, _) = surface.pixel(W - 4, 4);
assert_ne!(
(r, g, b),
(theme.background.r, theme.background.g, theme.background.b),
"yanked row should not read as plain background"
);
let expected_r = (theme.yank_highlight_bg.r as f32 * theme.yank_highlight_alpha
+ theme.background.r as f32 * (1.0 - theme.yank_highlight_alpha))
as u8;
let expected_g = (theme.yank_highlight_bg.g as f32 * theme.yank_highlight_alpha
+ theme.background.g as f32 * (1.0 - theme.yank_highlight_alpha))
as u8;
let expected_b = (theme.yank_highlight_bg.b as f32 * theme.yank_highlight_alpha
+ theme.background.b as f32 * (1.0 - theme.yank_highlight_alpha))
as u8;
assert!(
approx(r, expected_r, 4) && approx(g, expected_g, 4) && approx(b, expected_b, 4),
"expected ~({expected_r}, {expected_g}, {expected_b}), got ({r}, {g}, {b})"
);
}
#[test]
fn char_selection_highlights_only_selected_columns() {
let mut editor = editor_with_cursor("alpha beta", CursorShape::Bar, 99);
editor.selection = Some(EditorSelection {
kind: SelectionKind::Char,
start_line: 0,
start_col: 0,
end_line: 0,
end_col: 2, });
let surface = paint_via_backend(&editor);
let theme = Theme::default();
let metrics = font_metrics(&font());
let text_start = 3.0 * metrics.char_width;
let in_px = (text_start + metrics.char_width * 1.5) as u32;
let (r, g, b, _) = surface.pixel(in_px, 0);
assert_ne!(
(r, g, b),
(theme.background.r, theme.background.g, theme.background.b),
"column inside selection should be highlighted"
);
let out_px = (text_start + metrics.char_width * 6.5) as u32;
let (r2, g2, b2, _) = surface.pixel(out_px, 0);
assert_eq!(
(r2, g2, b2),
(theme.background.r, theme.background.g, theme.background.b),
"column outside selection should stay plain background"
);
}
#[test]
fn diff_added_background_overrides_cursorline() {
let mut line = one_line("added line");
line.diff_status = Some(DiffLine::Added);
let mut editor = editor_with_cursor("added line", CursorShape::Bar, 99);
editor.lines = vec![line];
editor.cursorline = true; let surface = paint_via_backend(&editor);
let theme = Theme::default();
let (r, g, b, _) = surface.pixel(W - 4, 4);
assert_eq!(
(r, g, b),
(
theme.diff_added_bg.r,
theme.diff_added_bg.g,
theme.diff_added_bg.b
),
"diff-added row should paint theme.diff_added_bg, not cursorline_bg"
);
}
fn ink_vertical_extent(
surface: &BitmapSurface,
x_start: u32,
x_end: u32,
y_start: u32,
y_end: u32,
bg: Color,
) -> Option<(u32, u32)> {
const TOL: i32 = 8;
let mut extent: Option<(u32, u32)> = None;
for y in y_start..y_end {
let has_ink = (x_start..x_end).any(|x| {
let (r, g, b, _) = surface.pixel(x, y);
(r as i32 - bg.r as i32).abs() > TOL
|| (g as i32 - bg.g as i32).abs() > TOL
|| (b as i32 - bg.b as i32).abs() > TOL
});
if has_ink {
extent = Some(match extent {
Some((top, _)) => (top, y),
None => (y, y),
});
}
}
extent
}
#[test]
fn body_text_centred_in_row_and_shares_baseline_with_gutter() {
const ROW_PITCH: f64 = 18.0;
let metrics = font_metrics(&make_font("Menlo", 12.0).expect("Menlo installed"));
let natural_pitch = metrics.line_height;
assert!(
natural_pitch < ROW_PITCH,
"fixture needs a font natural line height ({natural_pitch}) shorter \
than the row pitch ({ROW_PITCH}) to leave any slack to centre in",
);
let theme = Theme::default();
let gutter_chars = 2usize;
let paint_at_pitch = |pitch: f64| {
let mut line = one_line("7");
line.gutter_text = "7".into();
let mut editor = editor_with_cursor("7", CursorShape::Bar, 99);
editor.cursor = None; editor.lines = vec![line];
editor.gutter_char_width = gutter_chars;
let surface = BitmapSurface::new(W, H);
surface.fill(
theme.background.r as f64 / 255.0,
theme.background.g as f64 / 255.0,
theme.background.b as f64 / 255.0,
1.0,
);
let mut backend = MacBackend::new();
backend.set_current_font(make_font("Menlo", 12.0).expect("Menlo installed"));
backend.set_current_line_height(pitch);
backend.begin_frame(Viewport::new(W as f32, H as f32, 1.0));
backend.enter_frame_scope(surface.context_ptr(), |b| {
b.draw_editor(editor.rect, &editor);
});
backend.end_frame();
surface
};
let char_w = metrics.char_width;
let gutter_w = gutter_chars as f64 * char_w;
let body_ink = |surface: &BitmapSurface, pitch: f64| {
ink_vertical_extent(
surface,
gutter_w.round() as u32,
(gutter_w + char_w).round() as u32,
0,
pitch.ceil() as u32,
theme.background,
)
.expect("body glyph should paint some ink")
};
let natural = body_ink(&paint_at_pitch(natural_pitch), natural_pitch);
let wide_surface = paint_at_pitch(ROW_PITCH);
let wide = body_ink(&wide_surface, ROW_PITCH);
let expected_shift = (ROW_PITCH - natural_pitch) / 2.0;
let top_shift = wide.0 as f64 - natural.0 as f64;
let bottom_shift = wide.1 as f64 - natural.1 as f64;
assert!(
(top_shift - expected_shift).abs() <= 1.0
&& (bottom_shift - expected_shift).abs() <= 1.0,
"widening the row pitch from {natural_pitch} 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_vertical_extent(
&wide_surface,
0,
gutter_w.round() as u32,
0,
ROW_PITCH as u32,
theme.background,
)
.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:?}"
);
}
const SCROLL_TEST_W: u32 = 200;
const SCROLL_TEST_H: u32 = 80;
const SCROLL_TEST_CHAR_W: f64 = 8.0;
const SCROLL_TEST_LINE_H: f64 = 16.0;
fn blank_line(line_idx: usize) -> EditorLine {
EditorLine {
raw_text: String::new(),
gutter_text: String::new(),
spans: Vec::new(),
line_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 scroll_test_editor(total_lines: usize, num_lines: usize) -> Editor {
Editor {
id: "editor:scroll".into(),
rect: QRect::new(0.0, 0.0, SCROLL_TEST_W as f32, SCROLL_TEST_H as f32),
lines: (0..num_lines).map(blank_line).collect(),
cursor: None,
extra_cursors: Vec::new(),
selection: None,
extra_selections: Vec::new(),
yank_highlight: None,
scroll_top: 0,
scroll_left: 0,
total_lines,
max_col: 0,
gutter_char_width: 0,
is_active: true,
show_active_bg: false,
has_git_diff: false,
has_breakpoints: false,
diagnostic_gutter: HashMap::new(),
code_action_lines: HashSet::new(),
bracket_match_positions: Vec::new(),
active_indent_col: None,
tabstop: 4,
cursorline: false,
lightbulb_glyph: '!',
}
}
fn scroll_test_paint(editor: &Editor, options: EditorPaintOptions) -> BitmapSurface {
scroll_test_paint_with_v_scrollbar_w(editor, options, None)
}
fn scroll_test_paint_with_v_scrollbar_w(
editor: &Editor,
options: EditorPaintOptions,
v_scrollbar_w: Option<f32>,
) -> BitmapSurface {
let surface = BitmapSurface::new(SCROLL_TEST_W, SCROLL_TEST_H);
surface.fill(0.0, 0.0, 0.0, 0.0);
let f = font();
let theme = Theme::default();
unsafe {
draw_editor_with_options_and_v_scrollbar_w(
surface.context_ptr(),
&f,
editor,
&theme,
SCROLL_TEST_CHAR_W,
SCROLL_TEST_LINE_H,
options,
v_scrollbar_w,
);
}
surface
}
#[test]
fn draw_editor_paints_vertical_scrollbar_when_buffer_overflows() {
let bg = Theme::default().background;
let overflowing = scroll_test_editor(50, 5);
let surface = scroll_test_paint(&overflowing, EditorPaintOptions::default());
let (r, g, b, _) = surface.pixel(198, 40);
assert_ne!(
(r, g, b),
(bg.r, bg.g, bg.b),
"vertical scrollbar track should tint this pixel when total_lines overflows the viewport"
);
let fits = scroll_test_editor(3, 3);
let surface2 = scroll_test_paint(&fits, EditorPaintOptions::default());
let (r2, g2, b2, _) = surface2.pixel(198, 40);
assert_eq!(
(r2, g2, b2),
(bg.r, bg.g, bg.b),
"no vertical scrollbar should paint when the buffer fits the viewport"
);
}
#[test]
fn draw_editor_with_options_suppress_v_scrollbar_paints_no_column() {
let bg = Theme::default().background;
let overflowing = scroll_test_editor(50, 5);
let surface = scroll_test_paint(
&overflowing,
EditorPaintOptions {
suppress_v_scrollbar: true,
..Default::default()
},
);
let (r, g, b, _) = surface.pixel(198, 40);
assert_eq!(
(r, g, b),
(bg.r, bg.g, bg.b),
"suppress_v_scrollbar should stop the vertical scrollbar from painting even though \
total_lines overflows the viewport"
);
}
#[test]
fn draw_editor_v_scrollbar_w_widens_painted_column() {
let bg = Theme::default().background;
let overflowing = scroll_test_editor(50, 5);
let baseline = scroll_test_paint(&overflowing, EditorPaintOptions::default());
let (r, g, b, _) = baseline.pixel(188, 40);
assert_eq!(
(r, g, b),
(bg.r, bg.g, bg.b),
"x=188 sits outside the default 8px-wide column, [192, 200)"
);
let widened = scroll_test_paint_with_v_scrollbar_w(
&overflowing,
EditorPaintOptions::default(),
Some(14.0),
);
let (r2, g2, b2, _) = widened.pixel(188, 40);
assert_ne!(
(r2, g2, b2),
(bg.r, bg.g, bg.b),
"a Some(14.0) v_scrollbar_w should widen the painted track to cover x=188 ([186, 200))"
);
}
#[test]
fn draw_editor_v_scrollbar_w_narrows_text_clip() {
let mut editor = scroll_test_editor(50, 5);
let text = " ".repeat(40);
editor.lines[2] = EditorLine {
spans: vec![ESpan {
start_byte: 0,
end_byte: text.len(),
style: Style {
fg: Color::rgb(255, 255, 255),
bg: Some(Color::rgb(10, 200, 10)),
bold: false,
italic: false,
font_scale: 1.0,
},
}],
raw_text: text,
..blank_line(2)
};
let baseline = scroll_test_paint(&editor, EditorPaintOptions::default());
let (r, g, b, _) = baseline.pixel(188, 40);
assert_eq!(
(r, g, b),
(10, 200, 10),
"baseline text clip should reach x=188 (span bg), just short of the 8px column"
);
let widened = scroll_test_paint_with_v_scrollbar_w(
&editor,
EditorPaintOptions::default(),
Some(14.0),
);
let (r2, g2, b2, _) = widened.pixel(188, 40);
assert_ne!(
(r2, g2, b2),
(10, 200, 10),
"a Some(14.0) v_scrollbar_w should narrow the text clip so x=188 no longer shows span bg"
);
}
fn scroll_test_paint_via_backend(editor: &Editor, v_scrollbar_w: Option<f32>) -> BitmapSurface {
let surface = BitmapSurface::new(SCROLL_TEST_W, SCROLL_TEST_H);
surface.fill(0.0, 0.0, 0.0, 0.0);
let mut backend = MacBackend::new();
backend.set_current_font(font());
backend.set_editor_v_scrollbar_width(v_scrollbar_w);
backend.begin_frame(Viewport::new(
SCROLL_TEST_W as f32,
SCROLL_TEST_H as f32,
1.0,
));
backend.enter_frame_scope(surface.context_ptr(), |b| {
b.draw_editor(editor.rect, editor);
});
backend.end_frame();
surface
}
const SCROLL_TEST_AA_TOLERANCE: i32 = 4;
fn scroll_test_pixel_is_bg(surface: &BitmapSurface, x: u32, y: u32) -> bool {
let bg = Theme::default().background;
let (r, g, b, _) = surface.pixel(x, y);
(r as i32 - bg.r as i32).abs() <= SCROLL_TEST_AA_TOLERANCE
&& (g as i32 - bg.g as i32).abs() <= SCROLL_TEST_AA_TOLERANCE
&& (b as i32 - bg.b as i32).abs() <= SCROLL_TEST_AA_TOLERANCE
}
#[test]
fn backend_set_editor_v_scrollbar_width_paints_14px_column() {
let overflowing = scroll_test_editor(50, 5);
let surface = scroll_test_paint_via_backend(&overflowing, Some(14.0));
let y = SCROLL_TEST_H - 4;
for x in 186..SCROLL_TEST_W {
assert!(
!scroll_test_pixel_is_bg(&surface, x, y),
"x={x} lies inside the 14px scrollbar column [186, 200) and should be tinted"
);
}
assert!(
scroll_test_pixel_is_bg(&surface, 183, y),
"x=183 lies left of the 14px scrollbar column and should be plain background"
);
let mut backend = MacBackend::new();
backend.set_current_font(font());
backend.set_editor_v_scrollbar_width(Some(14.0));
assert_eq!(backend.editor_v_scrollbar_width(), Some(14.0));
let layout = backend.editor_layout(overflowing.rect, &overflowing);
let vsb = layout.v_scrollbar_bounds.expect("buffer overflows");
assert_eq!(vsb.width, 14.0);
assert_eq!(vsb.x, SCROLL_TEST_W as f32 - 14.0);
}
#[test]
fn backend_default_editor_v_scrollbar_width_is_one_char() {
let overflowing = scroll_test_editor(50, 5);
let mut backend = MacBackend::new();
backend.set_current_font(font());
assert_eq!(backend.editor_v_scrollbar_width(), None);
let char_w = backend.char_width();
assert!(
char_w > 0.0 && char_w < 14.0,
"fixture needs a default column narrower than the 14px override, got {char_w}"
);
let surface = scroll_test_paint_via_backend(&overflowing, None);
let y = SCROLL_TEST_H - 4;
let text_area_x = (SCROLL_TEST_W as f32 - char_w).floor() as u32 - 3;
assert!(
scroll_test_pixel_is_bg(&surface, text_area_x, y),
"default column is {char_w}px wide, so x={text_area_x} is text-area background"
);
assert!(
!scroll_test_pixel_is_bg(&surface, SCROLL_TEST_W - 2, y),
"the default column still paints at the right edge"
);
}
#[test]
fn backend_clamps_oversized_editor_rect_to_viewport() {
let mut oversized = scroll_test_editor(50, 5);
oversized.rect.width = SCROLL_TEST_W as f32 + 20.0;
let surface = scroll_test_paint_via_backend(&oversized, Some(14.0));
let y = SCROLL_TEST_H - 4;
for x in 186..SCROLL_TEST_W {
assert!(
!scroll_test_pixel_is_bg(&surface, x, y),
"x={x} lies inside the 14px scrollbar column [186, 200) and should be \
tinted — editor.rect ({}px) is wider than the real 200px viewport, so an \
unclamped draw_editor would have laid this column out past the window edge",
oversized.rect.width
);
}
assert!(
scroll_test_pixel_is_bg(&surface, 183, y),
"x=183 lies left of the 14px scrollbar column and should be plain background"
);
}
}