use crate::primitives::editor::{
CursorShape, DiagnosticSeverity, DiffLine, Editor, EditorLayout, EditorLine,
EditorPaintOptions, EditorSelection, EditorStyledSpan, GitLineStatus, SelectionKind,
};
use crate::primitives::scrollbar::Scrollbar;
use crate::theme::Theme;
use crate::types::Color;
use gtk4::cairo::Context;
use gtk4::pango::{self, AttrColor, AttrList};
#[allow(clippy::too_many_arguments)]
pub fn draw_editor(
cr: &Context,
layout: &pango::Layout,
font_metrics: &pango::FontMetrics,
editor: &Editor,
theme: &Theme,
char_width: f64,
line_height: f64,
) {
draw_editor_with_options(
cr,
layout,
font_metrics,
editor,
theme,
char_width,
line_height,
EditorPaintOptions::default(),
)
}
struct EditorPaintCtx<'a> {
cr: &'a Context,
layout: &'a pango::Layout,
font_metrics: &'a pango::FontMetrics,
editor: &'a Editor,
theme: &'a Theme,
char_width: f64,
line_height: f64,
rect: &'a crate::Rect,
gutter_width: f64,
text_x_offset: f64,
editor_geom: EditorLayout,
text_v_offset: f64,
}
fn text_v_offset(font_metrics: &pango::FontMetrics, line_height: f64) -> f64 {
let natural_height =
(font_metrics.ascent() + font_metrics.descent()) as f64 / pango::SCALE as f64;
(line_height - natural_height) / 2.0
}
#[allow(clippy::too_many_arguments)]
pub fn draw_editor_with_options(
cr: &Context,
layout: &pango::Layout,
font_metrics: &pango::FontMetrics,
editor: &Editor,
theme: &Theme,
char_width: f64,
line_height: f64,
options: EditorPaintOptions,
) {
draw_editor_with_options_and_v_scrollbar_w(
cr,
layout,
font_metrics,
editor,
theme,
char_width,
line_height,
options,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub fn draw_editor_with_options_and_v_scrollbar_w(
cr: &Context,
layout: &pango::Layout,
font_metrics: &pango::FontMetrics,
editor: &Editor,
theme: &Theme,
char_width: f64,
line_height: f64,
options: EditorPaintOptions,
v_scrollbar_w: Option<f32>,
) {
let rect = &editor.rect;
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 = rect.x as f64 + gutter_width - h_scroll_offset;
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 text_v_offset = text_v_offset(font_metrics, line_height);
let ctx = EditorPaintCtx {
cr,
layout,
font_metrics,
editor,
theme,
char_width,
line_height,
rect,
gutter_width,
text_x_offset,
editor_geom,
text_v_offset,
};
paint_window_background(&ctx);
paint_line_backgrounds(&ctx);
paint_selection_overlays(&ctx);
paint_gutter(&ctx);
cr.save().ok();
cr.rectangle(
rect.x as f64 + gutter_width,
rect.y as f64,
ctx.editor_geom.text_bounds.width as f64,
rect.height as f64,
);
cr.clip();
paint_text_lines(&ctx);
cr.restore().ok();
paint_scrollbars(&ctx);
paint_primary_cursor(&ctx);
paint_ai_ghost_text(&ctx);
paint_secondary_cursors(&ctx);
}
fn paint_window_background(ctx: &EditorPaintCtx) {
let EditorPaintCtx {
cr, editor, theme, ..
} = ctx;
let rect = ctx.rect;
let bg = if editor.show_active_bg {
theme.editor_active_background
} else {
theme.background
};
let (br, bg_g, bb) = cairo_rgb(bg);
cr.set_source_rgb(br, bg_g, bb);
cr.rectangle(
rect.x as f64,
rect.y as f64,
rect.width as f64,
rect.height as f64,
);
cr.fill().ok();
}
fn paint_line_backgrounds(ctx: &EditorPaintCtx) {
let EditorPaintCtx {
cr,
editor,
theme,
line_height,
..
} = ctx;
let (cr, editor, theme, line_height) = (*cr, *editor, *theme, *line_height);
let rect = ctx.rect;
let bg_right = ctx.editor_geom.text_bounds.x as f64 + ctx.editor_geom.text_bounds.width as f64;
let bg_width = (bg_right - rect.x as f64).max(0.0);
for (view_idx, rl) in editor.lines.iter().enumerate() {
let y = rect.y as f64 + view_idx as f64 * line_height;
let bg_color = if rl.is_dap_current {
Some(theme.dap_stopped_bg)
} else if let Some(diff_status) = rl.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 rl.is_current_line && editor.is_active && editor.cursorline {
Some(theme.cursorline_bg)
} else {
None
};
if let Some(color) = bg_color {
let (dr, dg, db) = cairo_rgb(color);
cr.set_source_rgb(dr, dg, db);
cr.rectangle(rect.x as f64, y, bg_width, line_height);
cr.fill().ok();
}
}
}
fn paint_selection_overlays(ctx: &EditorPaintCtx) {
let EditorPaintCtx {
cr,
layout,
editor,
theme,
line_height,
text_x_offset,
..
} = ctx;
let (cr, layout, editor, theme, line_height, text_x_offset) =
(*cr, *layout, *editor, *theme, *line_height, *text_x_offset);
let rect = ctx.rect;
if let Some(sel) = &editor.selection {
draw_visual_selection(
cr,
layout,
sel,
&editor.lines,
rect,
line_height,
text_x_offset,
theme.selection,
theme.selection_alpha as f64,
);
}
for esel in &editor.extra_selections {
draw_visual_selection(
cr,
layout,
esel,
&editor.lines,
rect,
line_height,
text_x_offset,
theme.selection,
theme.selection_alpha as f64,
);
}
if let Some(yh) = &editor.yank_highlight {
draw_visual_selection(
cr,
layout,
yh,
&editor.lines,
rect,
line_height,
text_x_offset,
theme.yank_highlight_bg,
theme.yank_highlight_alpha as f64,
);
}
}
fn paint_gutter(ctx: &EditorPaintCtx) {
let EditorPaintCtx {
editor,
line_height,
..
} = ctx;
let (editor, line_height) = (*editor, *line_height);
if editor.gutter_char_width == 0 {
return;
}
for (view_idx, rl) in editor.lines.iter().enumerate() {
let y = ctx.rect.y as f64 + view_idx as f64 * line_height;
paint_gutter_row_number(ctx, rl, y);
paint_gutter_diagnostic_or_lightbulb(ctx, rl, y);
}
}
fn paint_gutter_row_number(ctx: &EditorPaintCtx, rl: &EditorLine, y: f64) {
let EditorPaintCtx {
cr,
layout,
editor,
theme,
char_width,
gutter_width,
..
} = ctx;
let (cr, layout, editor, theme, char_width, gutter_width) =
(*cr, *layout, *editor, *theme, *char_width, *gutter_width);
let rect = ctx.rect;
let y = y + ctx.text_v_offset;
let mut char_offset = 0usize;
if editor.has_breakpoints {
let bp_ch: String = rl.gutter_text.chars().take(1).collect();
let bp_color = if rl.is_dap_current || rl.is_breakpoint {
theme.diagnostic_error
} else {
theme.line_number_fg
};
layout.set_text(&bp_ch);
layout.set_attributes(None);
let (br, bg_c, bb) = cairo_rgb(bp_color);
cr.set_source_rgb(br, bg_c, bb);
cr.move_to(rect.x as f64 + 3.0, y);
super::painted_text::show_layout(cr, layout);
char_offset += 1;
}
if editor.has_git_diff {
let git_ch: String = rl.gutter_text.chars().skip(char_offset).take(1).collect();
let git_color = match rl.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,
};
layout.set_text(&git_ch);
layout.set_attributes(None);
let (gr, gg, gb) = cairo_rgb(git_color);
cr.set_source_rgb(gr, gg, gb);
cr.move_to(rect.x as f64 + char_offset as f64 * char_width + 3.0, y);
super::painted_text::show_layout(cr, layout);
char_offset += 1;
let rest: String = rl.gutter_text.chars().skip(char_offset).collect();
layout.set_text(&rest);
layout.set_attributes(None);
} else if char_offset > 0 {
let rest: String = rl.gutter_text.chars().skip(char_offset).collect();
layout.set_text(&rest);
layout.set_attributes(None);
} else {
layout.set_text(&rl.gutter_text);
layout.set_attributes(None);
}
let (num_width, _) = layout.pixel_size();
let num_x = rect.x as f64 + gutter_width - num_width as f64 - char_width + 3.0;
let num_color = if editor.is_active && rl.is_current_line {
theme.line_number_active_fg
} else {
theme.line_number_fg
};
let (nr, ng, nb) = cairo_rgb(num_color);
cr.set_source_rgb(nr, ng, nb);
cr.move_to(num_x, y);
super::painted_text::show_layout(cr, layout);
}
fn paint_gutter_diagnostic_or_lightbulb(ctx: &EditorPaintCtx, rl: &EditorLine, y: f64) {
let EditorPaintCtx {
cr,
layout,
editor,
theme,
line_height,
..
} = ctx;
let (cr, layout, editor, theme, line_height) = (*cr, *layout, *editor, *theme, *line_height);
let rect = ctx.rect;
if let Some(severity) = editor.diagnostic_gutter.get(&rl.line_idx) {
let diag_color = match severity {
DiagnosticSeverity::Error => theme.diagnostic_error,
DiagnosticSeverity::Warning => theme.diagnostic_warning,
DiagnosticSeverity::Information => theme.diagnostic_info,
DiagnosticSeverity::Hint => theme.diagnostic_hint,
};
let (dr, dg, db) = cairo_rgb(diag_color);
cr.set_source_rgb(dr, dg, db);
let dot_r = line_height * 0.2;
let dot_cx = rect.x as f64 + 3.0 + dot_r;
let dot_cy = y + line_height * 0.5;
cr.arc(dot_cx, dot_cy, dot_r, 0.0, 2.0 * std::f64::consts::PI);
cr.fill().ok();
} else if !rl.is_wrap_continuation
&& editor.code_action_lines.contains(&rl.line_idx)
&& editor.lightbulb_glyph != '\0'
{
let (lr, lg, lb) = cairo_rgb(theme.lightbulb);
cr.set_source_rgb(lr, lg, lb);
let bulb_layout = layout.clone();
bulb_layout.set_text(&editor.lightbulb_glyph.to_string());
cr.move_to(rect.x as f64 + 1.0, y + ctx.text_v_offset);
super::painted_text::show_layout(cr, &bulb_layout);
}
}
fn paint_text_lines(ctx: &EditorPaintCtx) {
let EditorPaintCtx {
editor,
line_height,
..
} = ctx;
let (editor, line_height) = (*editor, *line_height);
for (view_idx, rl) in editor.lines.iter().enumerate() {
let y = ctx.rect.y as f64 + view_idx as f64 * line_height;
paint_line_text_ghost_and_annotation(ctx, rl, y);
paint_line_indent_guides_and_colorcolumns(ctx, rl, y);
paint_line_bracket_match(ctx, view_idx, y);
ctx.layout.set_text(&rl.raw_text);
let line_attrs = build_pango_attrs(&rl.spans);
ctx.layout.set_attributes(Some(&line_attrs));
paint_line_diagnostic_underlines(ctx, rl, y);
paint_line_spell_underlines(ctx, rl, y);
}
}
fn paint_line_text_ghost_and_annotation(ctx: &EditorPaintCtx, rl: &EditorLine, y: f64) {
let EditorPaintCtx {
cr,
layout,
theme,
char_width,
text_x_offset,
..
} = ctx;
let (cr, layout, theme, char_width, text_x_offset) =
(*cr, *layout, *theme, *char_width, *text_x_offset);
let y = y + ctx.text_v_offset;
layout.set_text(&rl.raw_text);
let attrs = build_pango_attrs(&rl.spans);
layout.set_attributes(Some(&attrs));
let (fr, fg_g, fb) = cairo_rgb(theme.foreground);
cr.set_source_rgb(fr, fg_g, fb);
cr.move_to(text_x_offset, y);
super::painted_text::show_layout(cr, layout);
if rl.is_ghost_continuation {
if let Some(ghost) = &rl.ghost_suffix {
let (gr, gg, gb) = cairo_rgb(theme.ghost_text_fg);
cr.set_source_rgb(gr, gg, gb);
cr.move_to(text_x_offset, y);
layout.set_text(ghost);
layout.set_attributes(None);
super::painted_text::show_layout(cr, layout);
}
}
if let Some(ann) = &rl.annotation {
let text_pixel_width = layout.pixel_size().0 as f64;
let ann_x = text_x_offset + text_pixel_width + char_width * 2.0;
let (ar, ag, ab) = cairo_rgb(theme.annotation_fg);
cr.set_source_rgb(ar, ag, ab);
cr.move_to(ann_x, y);
layout.set_text(ann);
layout.set_attributes(None);
super::painted_text::show_layout(cr, layout);
}
}
fn paint_line_indent_guides_and_colorcolumns(ctx: &EditorPaintCtx, rl: &EditorLine, y: f64) {
let EditorPaintCtx {
cr,
editor,
theme,
char_width,
line_height,
text_x_offset,
..
} = ctx;
let (cr, editor, theme, char_width, line_height, text_x_offset) = (
*cr,
*editor,
*theme,
*char_width,
*line_height,
*text_x_offset,
);
if !rl.indent_guides.is_empty() {
cr.set_line_width(1.0);
for &guide_col in &rl.indent_guides {
let is_active = editor.active_indent_col == Some(guide_col);
let (gr, gg, gb) = if is_active {
cairo_rgb(theme.indent_guide_active_fg)
} else {
cairo_rgb(theme.indent_guide_fg)
};
cr.set_source_rgb(gr, gg, gb);
let gx = text_x_offset + guide_col as f64 * char_width;
cr.move_to(gx, y);
cr.line_to(gx, y + line_height);
cr.stroke().ok();
}
}
if !rl.colorcolumns.is_empty() {
let (cr2, cg, cb) = cairo_rgb(theme.colorcolumn_bg);
cr.set_source_rgb(cr2, cg, cb);
for &cc_col in &rl.colorcolumns {
let cx = text_x_offset + cc_col as f64 * char_width;
cr.rectangle(cx, y, char_width, line_height);
cr.fill().ok();
}
}
}
fn paint_line_bracket_match(ctx: &EditorPaintCtx, view_idx: usize, y: f64) {
let EditorPaintCtx {
cr,
editor,
theme,
char_width,
line_height,
text_x_offset,
..
} = ctx;
let (cr, editor, theme, char_width, line_height, text_x_offset) = (
*cr,
*editor,
*theme,
*char_width,
*line_height,
*text_x_offset,
);
for &(bm_view_line, bm_col) in &editor.bracket_match_positions {
if bm_view_line == view_idx {
let (br, bg_c, bb) = cairo_rgb(theme.bracket_match_bg);
cr.set_source_rgba(br, bg_c, bb, 0.6);
let bx = text_x_offset + bm_col as f64 * char_width;
cr.rectangle(bx, y, char_width, line_height);
cr.fill().ok();
}
}
}
fn paint_line_diagnostic_underlines(ctx: &EditorPaintCtx, rl: &EditorLine, y: f64) {
let EditorPaintCtx {
cr,
layout,
theme,
line_height,
text_x_offset,
..
} = ctx;
let (cr, layout, theme, line_height, text_x_offset) =
(*cr, *layout, *theme, *line_height, *text_x_offset);
for dm in &rl.diagnostics {
let diag_color = match dm.severity {
DiagnosticSeverity::Error => theme.diagnostic_error,
DiagnosticSeverity::Warning => theme.diagnostic_warning,
DiagnosticSeverity::Information => theme.diagnostic_info,
DiagnosticSeverity::Hint => theme.diagnostic_hint,
};
let (dr, dg, db) = cairo_rgb(diag_color);
cr.set_source_rgb(dr, dg, db);
cr.set_line_width(1.0);
let start_byte = rl
.raw_text
.char_indices()
.nth(dm.start_col)
.map(|(i, _)| i)
.unwrap_or(rl.raw_text.len());
let end_byte = rl
.raw_text
.char_indices()
.nth(dm.end_col)
.map(|(i, _)| i)
.unwrap_or(rl.raw_text.len());
let start_pos = layout.index_to_pos(start_byte as i32);
let end_pos = layout.index_to_pos(end_byte as i32);
let x0 = text_x_offset + start_pos.x() as f64 / pango::SCALE as f64;
let x1 = text_x_offset + end_pos.x() as f64 / pango::SCALE as f64;
let underline_y = y + line_height - 2.0;
let wave_h = 1.5;
let wave_len = 4.0;
cr.move_to(x0, underline_y);
let mut wx = x0;
let mut up = true;
while wx < x1 {
let next_x = (wx + wave_len).min(x1);
let cy = if up {
underline_y - wave_h
} else {
underline_y + wave_h
};
cr.curve_to(
wx + (next_x - wx) * 0.5,
cy,
wx + (next_x - wx) * 0.5,
cy,
next_x,
underline_y,
);
wx = next_x;
up = !up;
}
cr.stroke().ok();
}
}
fn paint_line_spell_underlines(ctx: &EditorPaintCtx, rl: &EditorLine, y: f64) {
let EditorPaintCtx {
cr,
layout,
theme,
line_height,
text_x_offset,
..
} = ctx;
let (cr, layout, theme, line_height, text_x_offset) =
(*cr, *layout, *theme, *line_height, *text_x_offset);
for sm in &rl.spell_errors {
let (sr, sg, sb) = cairo_rgb(theme.spell_error);
cr.set_source_rgb(sr, sg, sb);
cr.set_line_width(1.0);
let start_byte = rl
.raw_text
.char_indices()
.nth(sm.start_col)
.map(|(i, _)| i)
.unwrap_or(rl.raw_text.len());
let end_byte = rl
.raw_text
.char_indices()
.nth(sm.end_col)
.map(|(i, _)| i)
.unwrap_or(rl.raw_text.len());
let start_pos = layout.index_to_pos(start_byte as i32);
let end_pos = layout.index_to_pos(end_byte as i32);
let x0 = text_x_offset + start_pos.x() as f64 / pango::SCALE as f64;
let x1 = text_x_offset + end_pos.x() as f64 / pango::SCALE as f64;
let underline_y = y + line_height - 2.0;
let dot_spacing = 3.0;
let mut dx = x0;
while dx < x1 {
cr.rectangle(dx, underline_y, 1.0, 1.0);
dx += dot_spacing;
}
cr.fill().ok();
}
}
fn paint_scrollbars(ctx: &EditorPaintCtx) {
let EditorPaintCtx {
cr,
editor,
theme,
line_height,
editor_geom,
..
} = ctx;
let (cr, editor, theme, line_height) = (*cr, *editor, *theme, *line_height);
if let Some(v_track) = editor_geom.v_scrollbar_bounds {
let sb = Scrollbar::vertical(
"gtk: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 raw = super::surface::CairoSurface {
cr,
layout: None,
translucent_fill: true,
};
crate::primitives::scrollbar::native_surface_paint::paint(&sb, &mut raw, theme);
}
if let Some(h_track) = editor_geom.h_scrollbar_bounds {
let sb = Scrollbar::horizontal(
"gtk:editor:h_scrollbar",
h_track,
editor.scroll_left as f32,
editor.max_col as f32,
editor_geom.visible_cols as f32,
line_height as f32,
);
let mut raw = super::surface::CairoSurface {
cr,
layout: None,
translucent_fill: true,
};
crate::primitives::scrollbar::native_surface_paint::paint(&sb, &mut raw, theme);
}
}
fn paint_primary_cursor(ctx: &EditorPaintCtx) {
let EditorPaintCtx {
cr,
layout,
font_metrics,
editor,
theme,
line_height,
text_x_offset,
..
} = ctx;
let (cr, layout, font_metrics, editor, theme, line_height, text_x_offset) = (
*cr,
*layout,
*font_metrics,
*editor,
*theme,
*line_height,
*text_x_offset,
);
let rect = ctx.rect;
let Some(cursor) = &editor.cursor else {
return;
};
let Some(rl) = editor.lines.get(cursor.pos.view_line) else {
return;
};
layout.set_text(&rl.raw_text);
let cursor_attrs = build_pango_attrs(&rl.spans);
layout.set_attributes(Some(&cursor_attrs));
let render_col = if !editor.extra_selections.is_empty() && cursor.shape == CursorShape::Bar {
cursor.pos.col + 1
} else {
cursor.pos.col
};
let byte_offset: usize = rl
.raw_text
.char_indices()
.nth(render_col)
.map(|(i, _)| i)
.unwrap_or(rl.raw_text.len());
let pos = layout.index_to_pos(byte_offset as i32);
let cursor_x = text_x_offset + pos.x() as f64 / pango::SCALE as f64;
let raw_w = pos.width() as f64 / pango::SCALE as f64;
let cursor_y = rect.y as f64 + cursor.pos.view_line as f64 * line_height;
let (cr_r, cr_g, cr_b) = cairo_rgb(theme.cursor);
let char_w = if raw_w > 0.0 {
raw_w
} else {
font_metrics.approximate_char_width() as f64 / pango::SCALE as f64
};
match cursor.shape {
CursorShape::Block => {
cr.set_source_rgba(cr_r, cr_g, cr_b, theme.cursor_normal_alpha as f64);
cr.rectangle(cursor_x, cursor_y, char_w, line_height);
cr.fill().ok();
}
CursorShape::Bar => {
cr.set_source_rgb(cr_r, cr_g, cr_b);
cr.rectangle(cursor_x, cursor_y, 2.0, line_height);
cr.fill().ok();
}
CursorShape::Underline => {
cr.set_source_rgb(cr_r, cr_g, cr_b);
let bar_h = (line_height * 0.12).max(2.0);
cr.rectangle(cursor_x, cursor_y + line_height - bar_h, char_w, bar_h);
cr.fill().ok();
}
}
}
fn paint_ai_ghost_text(ctx: &EditorPaintCtx) {
let EditorPaintCtx {
cr,
layout,
editor,
theme,
line_height,
text_x_offset,
..
} = ctx;
let (cr, layout, editor, theme, line_height, text_x_offset) =
(*cr, *layout, *editor, *theme, *line_height, *text_x_offset);
let rect = ctx.rect;
let Some(cursor) = &editor.cursor else {
return;
};
let Some(rl) = editor.lines.get(cursor.pos.view_line) else {
return;
};
let Some(ghost) = &rl.ghost_suffix else {
return;
};
layout.set_text(&rl.raw_text);
let ghost_line_attrs = build_pango_attrs(&rl.spans);
layout.set_attributes(Some(&ghost_line_attrs));
let byte_offset: usize = rl
.raw_text
.char_indices()
.nth(cursor.pos.col)
.map(|(i, _)| i)
.unwrap_or(rl.raw_text.len());
let pos = layout.index_to_pos(byte_offset as i32);
let ghost_x = text_x_offset + pos.x() as f64 / pango::SCALE as f64;
let ghost_y = rect.y as f64 + cursor.pos.view_line as f64 * line_height + ctx.text_v_offset;
let (gr, gg, gb) = cairo_rgb(theme.ghost_text_fg);
cr.set_source_rgb(gr, gg, gb);
cr.move_to(ghost_x, ghost_y);
layout.set_text(ghost);
layout.set_attributes(None);
super::painted_text::show_layout(cr, layout);
}
fn paint_secondary_cursors(ctx: &EditorPaintCtx) {
let EditorPaintCtx {
cr,
layout,
font_metrics,
editor,
theme,
line_height,
text_x_offset,
..
} = ctx;
let (cr, layout, font_metrics, editor, theme, line_height, text_x_offset) = (
*cr,
*layout,
*font_metrics,
*editor,
*theme,
*line_height,
*text_x_offset,
);
let rect = ctx.rect;
let extra_cursor_shape = editor
.cursor
.as_ref()
.map(|c| c.shape)
.unwrap_or(CursorShape::Bar);
let has_extra_sels = !editor.extra_selections.is_empty();
let fallback_char_w = font_metrics.approximate_char_width() as f64 / pango::SCALE as f64;
let (cr_r, cr_g, cr_b) = cairo_rgb(theme.cursor);
for extra_pos in &editor.extra_cursors {
if let Some(rl) = editor.lines.get(extra_pos.view_line) {
layout.set_text(&rl.raw_text);
let extra_attrs = build_pango_attrs(&rl.spans);
layout.set_attributes(Some(&extra_attrs));
let render_col = if has_extra_sels && extra_cursor_shape == CursorShape::Bar {
extra_pos.col + 1
} else {
extra_pos.col
};
let byte_offset: usize = rl
.raw_text
.char_indices()
.nth(render_col)
.map(|(i, _)| i)
.unwrap_or(rl.raw_text.len());
let pos = layout.index_to_pos(byte_offset as i32);
let ex = text_x_offset + pos.x() as f64 / pango::SCALE as f64;
let ew = {
let w = pos.width() as f64 / pango::SCALE as f64;
if w > 0.0 {
w
} else {
fallback_char_w
}
};
let ey = rect.y as f64 + extra_pos.view_line as f64 * line_height;
match extra_cursor_shape {
CursorShape::Bar => {
cr.set_source_rgb(cr_r, cr_g, cr_b);
cr.rectangle(ex, ey, 2.0, line_height);
cr.fill().ok();
}
CursorShape::Block => {
cr.set_source_rgba(cr_r, cr_g, cr_b, theme.cursor_normal_alpha as f64);
cr.rectangle(ex, ey, ew, line_height);
cr.fill().ok();
}
CursorShape::Underline => {
cr.set_source_rgb(cr_r, cr_g, cr_b);
let bar_h = (line_height * 0.12).max(2.0);
cr.rectangle(ex, ey + line_height - bar_h, ew, bar_h);
cr.fill().ok();
}
}
}
}
}
fn cairo_rgb(c: Color) -> (f64, f64, f64) {
(c.r as f64 / 255.0, c.g as f64 / 255.0, c.b as f64 / 255.0)
}
fn pango_u16(c: Color) -> (u16, u16, u16) {
(c.r as u16 * 257, c.g as u16 * 257, c.b as u16 * 257)
}
fn build_pango_attrs(spans: &[EditorStyledSpan]) -> AttrList {
let attrs = AttrList::new();
for span in spans {
let (fr, fg_g, fb) = pango_u16(span.style.fg);
let mut fg_attr = AttrColor::new_foreground(fr, fg_g, fb);
fg_attr.set_start_index(span.start_byte as u32);
fg_attr.set_end_index(span.end_byte as u32);
attrs.insert(fg_attr);
if let Some(bg) = span.style.bg {
let (br, bg_g, bb) = pango_u16(bg);
let mut bg_attr = AttrColor::new_background(br, bg_g, bb);
bg_attr.set_start_index(span.start_byte as u32);
bg_attr.set_end_index(span.end_byte as u32);
attrs.insert(bg_attr);
}
if span.style.bold {
let mut w = pango::AttrInt::new_weight(pango::Weight::Bold);
w.set_start_index(span.start_byte as u32);
w.set_end_index(span.end_byte as u32);
attrs.insert(w);
}
if span.style.italic {
let mut s = pango::AttrInt::new_style(pango::Style::Italic);
s.set_start_index(span.start_byte as u32);
s.set_end_index(span.end_byte as u32);
attrs.insert(s);
}
if (span.style.font_scale - 1.0).abs() > f32::EPSILON {
let mut sc = pango::AttrFloat::new_scale(span.style.font_scale as f64);
sc.set_start_index(span.start_byte as u32);
sc.set_end_index(span.end_byte as u32);
attrs.insert(sc);
}
}
attrs
}
pub fn editor_col_at_x(
pango_layout: &pango::Layout,
line: &EditorLine,
editor_layout: &EditorLayout,
x: f32,
) -> usize {
pango_layout.set_text(&line.raw_text);
let attrs = build_pango_attrs(&line.spans);
pango_layout.set_attributes(Some(&attrs));
let cell_width = editor_layout.cell_width as f64;
let local_x = (x as f64 - editor_layout.text_bounds.x as f64)
+ editor_layout.scroll_left as f64 * cell_width;
let x_pango = (local_x.max(0.0) * pango::SCALE as f64).round() as i32;
let (_inside, byte_index, _trailing) = pango_layout.xy_to_index(x_pango, 0);
let clamped =
crate::text_util::snap_to_char_boundary(&line.raw_text, byte_index.max(0) as usize);
line.raw_text[..clamped].chars().count() + line.segment_col_offset
}
#[allow(clippy::too_many_arguments)]
fn draw_visual_selection(
cr: &Context,
layout: &pango::Layout,
sel: &EditorSelection,
lines: &[EditorLine],
rect: &crate::event::Rect,
line_height: f64,
text_x_offset: f64,
color: Color,
alpha: f64,
) {
let (sr, sg, sb) = cairo_rgb(color);
cr.set_source_rgba(sr, sg, sb, alpha);
for (view_idx, rl) in lines.iter().enumerate() {
let Some(cols) = sel.cols_on(rl) else {
continue;
};
let y = rect.y as f64 + view_idx as f64 * line_height;
if sel.kind == SelectionKind::Line {
let highlight_width = rect.width as f64 - (text_x_offset - rect.x as f64);
cr.rectangle(text_x_offset, y, highlight_width, line_height);
continue;
}
let line_text = &rl.raw_text;
layout.set_text(line_text);
layout.set_attributes(None);
let start_byte = line_text
.char_indices()
.nth(cols.start)
.map(|(i, _)| i)
.unwrap_or(line_text.len());
let start_pos = layout.index_to_pos(start_byte as i32);
let start_x = text_x_offset + start_pos.x() as f64 / pango::SCALE as f64;
if cols.extends_beyond {
let (line_width, _) = layout.pixel_size();
cr.rectangle(
start_x,
y,
(text_x_offset + line_width as f64 - start_x).max(0.0),
line_height,
);
} else {
let end_byte = line_text
.char_indices()
.nth(cols.end)
.map(|(i, _)| i)
.unwrap_or(line_text.len());
let end_pos = layout.index_to_pos(end_byte as i32);
let end_x = text_x_offset + end_pos.x() as f64 / pango::SCALE as f64;
cr.rectangle(start_x, y, end_x - start_x, line_height);
}
}
cr.fill().ok();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::Rect;
use crate::primitives::editor::{Editor, Style};
use crate::types::Color;
use pangocairo::cairo::{Context, Format, ImageSurface};
use std::collections::{HashMap, HashSet};
fn styled_line() -> EditorLine {
let plain = Style {
fg: Color::rgb(220, 220, 220),
bg: None,
bold: false,
italic: false,
font_scale: 1.0,
};
EditorLine {
raw_text: "let x = width; // note".into(),
gutter_text: " 1".into(),
spans: vec![
EditorStyledSpan {
start_byte: 0,
end_byte: 3,
style: Style {
bold: true,
..plain
},
}, EditorStyledSpan {
start_byte: 8,
end_byte: 13,
style: Style {
italic: true,
..plain
},
}, EditorStyledSpan {
start_byte: 15,
end_byte: 22,
style: Style {
font_scale: 1.8,
..plain
},
}, ],
line_idx: 0,
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 headless_pango_layout() -> pango::Layout {
let surface = ImageSurface::create(Format::ARgb32, 900, 60).expect("create ImageSurface");
let cr = Context::new(&surface).expect("Context::new");
let ctx = pangocairo::functions::create_context(&cr);
ctx.set_font_description(&pango::FontDescription::from_string("Monospace 12"));
pango::Layout::new(&ctx)
}
#[test]
fn editor_col_at_x_round_trips_styled_line_with_scroll() {
let truth_layout = headless_pango_layout();
let probe_layout = headless_pango_layout();
let line = styled_line();
let editor = Editor {
id: "ed".into(),
rect: Rect::new(0.0, 0.0, 900.0, 60.0),
lines: vec![line.clone()],
cursor: None,
extra_cursors: Vec::new(),
selection: None,
extra_selections: Vec::new(),
yank_highlight: None,
scroll_top: 0,
scroll_left: 3,
total_lines: 1,
max_col: 30,
gutter_char_width: 4,
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: true,
lightbulb_glyph: '!',
};
let cell_width = 9.0_f32;
let line_height = 18.0_f32;
let viewport = Rect::new(0.0, 0.0, 900.0, 60.0);
let editor_layout = editor.layout(viewport, cell_width, line_height);
truth_layout.set_text(&line.raw_text);
let attrs = build_pango_attrs(&line.spans);
truth_layout.set_attributes(Some(&attrs));
let text_x_offset =
editor_layout.text_bounds.x as f64 - editor.scroll_left as f64 * cell_width as f64;
let glyph_click_xs: Vec<(usize, char, f64)> = line
.raw_text
.char_indices()
.enumerate()
.map(|(char_idx, (byte_idx, ch))| {
let pos = truth_layout.index_to_pos(byte_idx as i32);
let glyph_left_x = text_x_offset + pos.x() as f64 / pango::SCALE as f64;
let glyph_width = (pos.width() as f64 / pango::SCALE as f64).max(2.0);
(char_idx, ch, glyph_left_x + glyph_width * 0.25)
})
.collect();
for (char_idx, ch, click_x) in glyph_click_xs {
let resolved = editor_col_at_x(&probe_layout, &line, &editor_layout, click_x as f32);
assert_eq!(
resolved, char_idx,
"glyph '{ch}' (char {char_idx}) at x={click_x} resolved to col {resolved}"
);
}
}
#[test]
fn editor_col_at_x_adds_segment_col_offset_for_wrap_continuation() {
let pango_layout = headless_pango_layout();
let mut line = styled_line();
line.is_wrap_continuation = true;
line.segment_col_offset = 12;
let editor = Editor {
id: "ed".into(),
rect: Rect::new(0.0, 0.0, 900.0, 60.0),
lines: vec![line.clone()],
cursor: None,
extra_cursors: Vec::new(),
selection: None,
extra_selections: Vec::new(),
yank_highlight: None,
scroll_top: 0,
scroll_left: 0,
total_lines: 1,
max_col: 30,
gutter_char_width: 4,
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: true,
lightbulb_glyph: '!',
};
let cell_width = 9.0_f32;
let viewport = Rect::new(0.0, 0.0, 900.0, 60.0);
let editor_layout = editor.layout(viewport, cell_width, 18.0);
let resolved = editor_col_at_x(
&pango_layout,
&line,
&editor_layout,
editor_layout.text_bounds.x,
);
assert_eq!(resolved, 12);
}
#[test]
fn editor_col_at_x_multibyte_line_does_not_panic() {
let pango_layout = headless_pango_layout();
let mut line = styled_line();
line.raw_text = "café 🎉 䏿–‡ done".into();
line.spans = Vec::new();
let editor = Editor {
id: "ed".into(),
rect: Rect::new(0.0, 0.0, 900.0, 60.0),
lines: vec![line.clone()],
cursor: None,
extra_cursors: Vec::new(),
selection: None,
extra_selections: Vec::new(),
yank_highlight: None,
scroll_top: 0,
scroll_left: 0,
total_lines: 1,
max_col: 30,
gutter_char_width: 4,
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: true,
lightbulb_glyph: '!',
};
let cell_width = 9.0_f32;
let viewport = Rect::new(0.0, 0.0, 900.0, 60.0);
let editor_layout = editor.layout(viewport, cell_width, 18.0);
let mut x = editor_layout.text_bounds.x - 5.0;
while x < editor_layout.text_bounds.x + 300.0 {
let _ = editor_col_at_x(&pango_layout, &line, &editor_layout, x);
x += 1.0;
}
}
const SCROLL_TEST_W: i32 = 200;
const SCROLL_TEST_H: i32 = 80;
const SCROLL_TEST_CHAR_W: f64 = 8.0;
const SCROLL_TEST_LINE_H: f64 = 16.0;
fn rgb(c: Color) -> (u8, u8, u8) {
(c.r, c.g, c.b)
}
fn scroll_test_pixel(data: &[u8], stride: usize, x: i32, y: i32) -> (u8, u8, u8) {
let off = y as usize * stride + x as usize * 4;
(data[off + 2], data[off + 1], data[off])
}
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, max_col: usize, num_lines: usize) -> Editor {
Editor {
id: "ed".into(),
rect: Rect::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,
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: '\0',
}
}
fn scroll_test_paint(editor: &Editor, theme: &Theme) -> (Vec<u8>, i32) {
let mut surface = ImageSurface::create(Format::ARgb32, SCROLL_TEST_W, SCROLL_TEST_H)
.expect("create ImageSurface");
{
let cr = Context::new(&surface).expect("Context::new");
let (br, bg, bb) = cairo_rgb(theme.background);
cr.set_source_rgb(br, bg, bb);
cr.paint().ok();
let pango_layout = pangocairo::functions::create_layout(&cr);
let pango_ctx = pango_layout.context();
pango_ctx.set_font_description(&pango::FontDescription::from_string("Monospace 12"));
let metrics = pango_ctx.metrics(None, None);
draw_editor(
&cr,
&pango_layout,
&metrics,
editor,
theme,
SCROLL_TEST_CHAR_W,
SCROLL_TEST_LINE_H,
);
}
surface.flush();
let stride = surface.stride();
let data = surface.data().expect("surface data").to_vec();
(data, stride)
}
fn scroll_test_paint_with_options(
editor: &Editor,
theme: &Theme,
options: EditorPaintOptions,
) -> (Vec<u8>, i32) {
scroll_test_paint_with_v_scrollbar_w(editor, theme, options, None)
}
fn scroll_test_paint_with_v_scrollbar_w(
editor: &Editor,
theme: &Theme,
options: EditorPaintOptions,
v_scrollbar_w: Option<f32>,
) -> (Vec<u8>, i32) {
let mut surface = ImageSurface::create(Format::ARgb32, SCROLL_TEST_W, SCROLL_TEST_H)
.expect("create ImageSurface");
{
let cr = Context::new(&surface).expect("Context::new");
let (br, bg, bb) = cairo_rgb(theme.background);
cr.set_source_rgb(br, bg, bb);
cr.paint().ok();
let pango_layout = pangocairo::functions::create_layout(&cr);
let pango_ctx = pango_layout.context();
pango_ctx.set_font_description(&pango::FontDescription::from_string("Monospace 12"));
let metrics = pango_ctx.metrics(None, None);
draw_editor_with_options_and_v_scrollbar_w(
&cr,
&pango_layout,
&metrics,
editor,
theme,
SCROLL_TEST_CHAR_W,
SCROLL_TEST_LINE_H,
options,
v_scrollbar_w,
);
}
surface.flush();
let stride = surface.stride();
let data = surface.data().expect("surface data").to_vec();
(data, stride)
}
#[test]
fn draw_editor_paints_vertical_scrollbar_when_buffer_overflows() {
let theme = Theme::default();
let overflowing = scroll_test_editor(50, 0, 5);
let (data, stride) = scroll_test_paint(&overflowing, &theme);
let px = scroll_test_pixel(&data, stride as usize, 198, 40);
assert_ne!(
px,
rgb(theme.background),
"vertical scrollbar track should tint this pixel when total_lines overflows the viewport"
);
let fits = scroll_test_editor(3, 0, 3);
let (data2, stride2) = scroll_test_paint(&fits, &theme);
let px2 = scroll_test_pixel(&data2, stride2 as usize, 198, 40);
assert_eq!(
px2,
rgb(theme.background),
"no vertical scrollbar should paint when the buffer fits the viewport"
);
}
#[test]
fn draw_editor_scrollbar_column_unaffected_by_overflowing_text() {
let theme = Theme::default();
let mut with_text = scroll_test_editor(50, 0, 5);
for line in with_text.lines.iter_mut() {
line.raw_text = "M".repeat(60);
}
let blank = scroll_test_editor(50, 0, 5);
let (data_text, stride) = scroll_test_paint(&with_text, &theme);
let (data_blank, stride2) = scroll_test_paint(&blank, &theme);
assert_eq!(stride, stride2);
let px_text = scroll_test_pixel(&data_text, stride as usize, 198, 40);
let px_blank = scroll_test_pixel(&data_blank, stride as usize, 198, 40);
assert_eq!(
px_text, px_blank,
"scrollbar column pixel must be identical regardless of overflowing text — \
the text loop should narrow around the reserved column instead of painting \
glyphs the scrollbar then overlays (#968)"
);
}
#[test]
fn draw_editor_cursorline_background_does_not_bleed_into_scrollbar_column() {
let theme = Theme::default();
assert_ne!(
theme.cursorline_bg, theme.background,
"fixture assumption: cursorline_bg must differ from background for this test to \
be meaningful"
);
let mut with_cursorline = scroll_test_editor(50, 0, 5);
with_cursorline.cursorline = true;
with_cursorline.lines[2].is_current_line = true;
let blank = scroll_test_editor(50, 0, 5);
let (data_cursorline, stride) = scroll_test_paint(&with_cursorline, &theme);
let (data_blank, stride2) = scroll_test_paint(&blank, &theme);
assert_eq!(stride, stride2);
let px_cursorline = scroll_test_pixel(&data_cursorline, stride as usize, 198, 40);
let px_blank = scroll_test_pixel(&data_blank, stride as usize, 198, 40);
assert_eq!(
px_cursorline, px_blank,
"scrollbar column pixel must be identical regardless of the cursorline background \
on the row underneath it — the row fill should narrow around the reserved column \
instead of bleeding into it"
);
}
#[test]
fn draw_editor_with_options_suppress_v_scrollbar_paints_no_column() {
let theme = Theme::default();
let overflowing = scroll_test_editor(50, 0, 5);
let (data0, stride0) = scroll_test_paint(&overflowing, &theme);
assert_ne!(
scroll_test_pixel(&data0, stride0 as usize, 198, 40),
rgb(theme.background)
);
let (data, stride) = scroll_test_paint_with_options(
&overflowing,
&theme,
EditorPaintOptions {
suppress_v_scrollbar: true,
},
);
let px = scroll_test_pixel(&data, stride as usize, 198, 40);
assert_eq!(
px,
rgb(theme.background),
"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 theme = Theme::default();
let overflowing = scroll_test_editor(50, 0, 5);
let (data0, stride0) = scroll_test_paint(&overflowing, &theme);
let baseline_px = scroll_test_pixel(&data0, stride0 as usize, 188, 40);
assert_eq!(
baseline_px,
rgb(theme.background),
"x=188 sits outside the default 8px-wide column, [192, 200)"
);
let (data, stride) = scroll_test_paint_with_v_scrollbar_w(
&overflowing,
&theme,
EditorPaintOptions::default(),
Some(14.0),
);
let widened_px = scroll_test_pixel(&data, stride as usize, 188, 40);
assert_ne!(
widened_px,
rgb(theme.background),
"a Some(14.0) v_scrollbar_w should widen the painted track to cover x=188 ([186, 200))"
);
}
#[test]
fn draw_editor_paints_horizontal_scrollbar_when_line_overflows() {
let theme = Theme::default();
let overflowing = scroll_test_editor(3, 40, 3);
let (data, stride) = scroll_test_paint(&overflowing, &theme);
let px = scroll_test_pixel(&data, stride as usize, 100, 70);
assert_ne!(
px,
rgb(theme.background),
"horizontal scrollbar track should tint this pixel when max_col overflows the viewport"
);
let fits = scroll_test_editor(3, 5, 3);
let (data2, stride2) = scroll_test_paint(&fits, &theme);
let px2 = scroll_test_pixel(&data2, stride2 as usize, 100, 70);
assert_eq!(
px2,
rgb(theme.background),
"no horizontal scrollbar should paint when every line fits the viewport"
);
}
fn text_line(line_idx: usize, raw_text: &str) -> EditorLine {
EditorLine {
raw_text: raw_text.into(),
..blank_line(line_idx)
}
}
#[test]
fn draw_editor_selection_respects_wrapped_ghost_and_diff_padding_rows() {
let theme = Theme::default();
let lines = vec![
text_line(0, "0123456789"),
EditorLine {
segment_col_offset: 5,
is_wrap_continuation: true,
..text_line(1, "abcde")
},
EditorLine {
ghost_suffix: Some("ai suggestion".into()),
..text_line(1, "")
},
EditorLine {
diff_status: Some(DiffLine::Padding),
..text_line(2, "")
},
];
let mut editor = scroll_test_editor(4, 0, 4);
editor.lines = lines;
editor.selection = Some(EditorSelection {
kind: SelectionKind::Char,
start_line: 0,
start_col: 0,
end_line: 2,
end_col: 10,
});
let (data, stride) = scroll_test_paint(&editor, &theme);
let stride = stride as usize;
let sample_x = 2; let row_y = |row: i32| row * SCROLL_TEST_LINE_H as i32 + (SCROLL_TEST_LINE_H as i32 / 2);
assert_ne!(
scroll_test_pixel(&data, stride, sample_x, row_y(0)),
rgb(theme.background),
"row 0 (plain, selection's start line) should be tinted"
);
assert_ne!(
scroll_test_pixel(&data, stride, sample_x, row_y(1)),
rgb(theme.background),
"row 1 (wrapped segment of a fully-selected line) should be tinted"
);
assert_eq!(
scroll_test_pixel(&data, stride, sample_x, row_y(2)),
rgb(theme.background),
"row 2 (ghost continuation) must never be tinted by a selection"
);
assert_eq!(
scroll_test_pixel(&data, stride, sample_x, row_y(3)),
rgb(theme.diff_padding_bg),
"row 3 (diff padding) must show only its diff background, not a selection tint"
);
}
const CENTRE_TEST_W: i32 = 200;
const CENTRE_TEST_ROW_PITCH: f64 = 24.0;
fn centre_test_metrics() -> (f64, f64) {
let surface =
ImageSurface::create(Format::ARgb32, CENTRE_TEST_W, 40).expect("create ImageSurface");
let cr = Context::new(&surface).expect("Context::new");
let pango_layout = pangocairo::functions::create_layout(&cr);
let pango_ctx = pango_layout.context();
pango_ctx.set_font_description(&pango::FontDescription::from_string("Monospace 12"));
let metrics = pango_ctx.metrics(None, None);
let natural_pitch = (metrics.ascent() + metrics.descent()) as f64 / pango::SCALE as f64;
pango_layout.set_text("7");
let char_w = pango_layout.pixel_size().0 as f64;
(natural_pitch, char_w)
}
fn centre_test_paint(pitch: f64, gutter_chars: usize) -> (Vec<u8>, i32) {
let theme = Theme::default();
let mut surface = ImageSurface::create(Format::ARgb32, CENTRE_TEST_W, pitch.ceil() as i32)
.expect("create ImageSurface");
{
let cr = Context::new(&surface).expect("Context::new");
let (br, bg, bb) = cairo_rgb(theme.background);
cr.set_source_rgb(br, bg, bb);
cr.paint().ok();
let pango_layout = pangocairo::functions::create_layout(&cr);
let pango_ctx = pango_layout.context();
pango_ctx.set_font_description(&pango::FontDescription::from_string("Monospace 12"));
let metrics = pango_ctx.metrics(None, None);
pango_layout.set_text("7");
let char_w = pango_layout.pixel_size().0 as f64;
let mut line = blank_line(0);
line.raw_text = "7".into();
line.gutter_text = "7".into();
let mut editor = scroll_test_editor(1, 1, 1);
editor.lines = vec![line];
editor.gutter_char_width = gutter_chars;
editor.rect = Rect::new(0.0, 0.0, CENTRE_TEST_W as f32, pitch as f32);
draw_editor_with_options_and_v_scrollbar_w(
&cr,
&pango_layout,
&metrics,
&editor,
&theme,
char_w,
pitch,
EditorPaintOptions::default(),
None,
);
}
surface.flush();
let stride = surface.stride();
let data = surface.data().expect("surface data").to_vec();
(data, stride)
}
fn centre_test_ink_extent(
data: &[u8],
stride: i32,
x_start: i32,
x_end: i32,
y_start: i32,
y_end: i32,
bg: Color,
) -> Option<(i32, i32)> {
const TOL: i32 = 24;
let mut extent: Option<(i32, i32)> = None;
for y in y_start..y_end {
let has_ink = (x_start..x_end).any(|x| {
let (r, g, b) = scroll_test_pixel(data, stride as usize, 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 ai_ghost_text_after_cursor_shares_body_text_baseline() {
use crate::primitives::editor::{CursorPos, CursorShape, EditorCursor};
let mut theme = Theme::default();
theme.ghost_text_fg = theme.foreground;
let pitch = CENTRE_TEST_ROW_PITCH;
let mut surface = ImageSurface::create(Format::ARgb32, CENTRE_TEST_W, pitch.ceil() as i32)
.expect("create ImageSurface");
let char_w;
{
let cr = Context::new(&surface).expect("Context::new");
let (br, bg, bb) = cairo_rgb(theme.background);
cr.set_source_rgb(br, bg, bb);
cr.paint().ok();
let pango_layout = pangocairo::functions::create_layout(&cr);
let pango_ctx = pango_layout.context();
pango_ctx.set_font_description(&pango::FontDescription::from_string("Monospace 12"));
let metrics = pango_ctx.metrics(None, None);
pango_layout.set_text("7");
char_w = pango_layout.pixel_size().0 as f64;
let mut line = blank_line(0);
line.raw_text = "7".into();
line.ghost_suffix = Some("7".into());
let mut editor = scroll_test_editor(1, 1, 1);
editor.lines = vec![line];
editor.gutter_char_width = 0;
editor.cursor = Some(EditorCursor {
pos: CursorPos {
view_line: 0,
col: 1,
},
shape: CursorShape::Bar,
});
editor.rect = Rect::new(0.0, 0.0, CENTRE_TEST_W as f32, pitch as f32);
draw_editor_with_options_and_v_scrollbar_w(
&cr,
&pango_layout,
&metrics,
&editor,
&theme,
char_w,
pitch,
EditorPaintOptions::default(),
None,
);
}
surface.flush();
let stride = surface.stride();
let data = surface.data().expect("surface data").to_vec();
let body = centre_test_ink_extent(
&data,
stride,
0,
char_w.round() as i32 - 1,
0,
pitch as i32,
theme.background,
)
.expect("typed glyph should paint ink");
let ghost = centre_test_ink_extent(
&data,
stride,
char_w.round() as i32 + 3,
(2.0 * char_w).round() as i32,
0,
pitch as i32,
theme.background,
)
.expect("ghost glyph should paint ink");
assert!(
(ghost.0 - body.0).abs() <= 1 && (ghost.1 - body.1).abs() <= 1,
"ghost suggestion ink {ghost:?} should share the typed glyph's vertical extent {body:?}"
);
}
#[test]
fn draw_editor_body_text_centred_in_row_and_shares_baseline_with_gutter() {
let theme = Theme::default();
let (natural_pitch, char_w) = centre_test_metrics();
assert!(
natural_pitch < CENTRE_TEST_ROW_PITCH,
"fixture needs a font natural line height ({natural_pitch}) shorter than the \
row pitch ({CENTRE_TEST_ROW_PITCH}) to leave any slack to centre in",
);
let gutter_chars = 2usize;
let gutter_w = gutter_chars as f64 * char_w;
let (natural_data, natural_stride) = centre_test_paint(natural_pitch, gutter_chars);
let natural = centre_test_ink_extent(
&natural_data,
natural_stride,
gutter_w.round() as i32,
(gutter_w + char_w).round() as i32,
0,
natural_pitch.ceil() as i32,
theme.background,
)
.expect("body glyph should paint some ink at the natural pitch");
let (wide_data, wide_stride) = centre_test_paint(CENTRE_TEST_ROW_PITCH, gutter_chars);
let wide = centre_test_ink_extent(
&wide_data,
wide_stride,
gutter_w.round() as i32,
(gutter_w + char_w).round() as i32,
0,
CENTRE_TEST_ROW_PITCH as i32,
theme.background,
)
.expect("body glyph should paint some ink at the wide pitch");
let expected_shift = (CENTRE_TEST_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() <= 2.0
&& (bottom_shift - expected_shift).abs() <= 2.0,
"widening the row pitch from {natural_pitch} to {CENTRE_TEST_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 = centre_test_ink_extent(
&wide_data,
wide_stride,
0,
gutter_w.round() as i32,
0,
CENTRE_TEST_ROW_PITCH as i32,
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:?}"
);
}
}