use core_graphics::sys::CGContextRef;
use core_text::font::CTFont;
use super::cg::*;
use super::text::{draw_text, measure_text};
use crate::backend::EditorPaintResult;
use crate::primitives::editor::{
CursorShape, DiffLine, Editor, EditorLine, EditorSelection, SelectionKind,
};
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 {
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;
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, 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 + (line_height - measure_text(font, &line.gutter_text).1) / 2.0,
color_to_cg(theme.line_number_fg),
);
}
let raw_x = text_x_offset;
if !line.raw_text.is_empty() {
draw_text(
ctx,
font,
&line.raw_text,
raw_x,
line_y,
color_to_cg(theme.foreground),
);
}
for span in &line.spans {
let start = snap_to_char_boundary(&line.raw_text, span.start_byte);
let end = snap_to_char_boundary(&line.raw_text, span.end_byte);
if start >= end {
continue;
}
let prefix = &line.raw_text[..start];
let slice = &line.raw_text[start..end];
let (px, _) = measure_text(font, prefix);
if let Some(sbg) = span.style.bg {
let (sw, _) = measure_text(font, slice);
fill_rect(ctx, raw_x + px, line_y, sw, line_height, sbg);
}
draw_text(
ctx,
font,
slice,
raw_x + px,
line_y,
color_to_cg(span.style.fg),
);
}
}
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, 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 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 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"
);
}
}