use crate::event::Rect;
use crate::types::{Color, WidgetId};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum DiagnosticSeverity {
Error = 1,
Warning = 2,
Information = 3,
Hint = 4,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum GitLineStatus {
Added,
Modified,
Deleted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DiffLine {
Same,
Added,
Removed,
Padding,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CursorShape {
Block,
Bar,
Underline,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SelectionKind {
Char,
Line,
Block,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct CursorPos {
pub view_line: usize,
pub col: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct EditorCursor {
pub pos: CursorPos,
pub shape: CursorShape,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct EditorSelection {
pub kind: SelectionKind,
pub start_line: usize,
pub start_col: usize,
pub end_line: usize,
pub end_col: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SelectionCols {
pub start: usize,
pub end: usize,
pub extends_beyond: bool,
}
impl EditorSelection {
pub fn cols_on(&self, line: &EditorLine) -> Option<SelectionCols> {
if line.is_ghost_continuation || line.diff_status == Some(DiffLine::Padding) {
return None;
}
let line_idx = line.line_idx;
if line_idx < self.start_line || line_idx > self.end_line {
return None;
}
let seg_offset = line.segment_col_offset;
let char_count = line.raw_text.trim_end_matches('\n').chars().count().max(1);
let seg_end = seg_offset + char_count;
let (buf_start, buf_end) = match self.kind {
SelectionKind::Line => (0usize, usize::MAX),
SelectionKind::Char => {
let start = if line_idx == self.start_line {
self.start_col
} else {
0
};
let end = if line_idx == self.end_line {
self.end_col + 1
} else {
usize::MAX
};
(start, end)
}
SelectionKind::Block => (self.start_col, self.end_col + 1),
};
if buf_start >= seg_end && buf_end != usize::MAX {
return None;
}
if buf_end <= seg_offset {
return None;
}
let start = buf_start.saturating_sub(seg_offset);
let end = if buf_end == usize::MAX {
char_count
} else {
buf_end.saturating_sub(seg_offset).min(char_count)
};
if start >= end {
return None;
}
let extends_beyond = end >= char_count && buf_end > seg_end;
Some(SelectionCols {
start,
end,
extends_beyond,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Style {
pub fg: Color,
pub bg: Option<Color>,
#[serde(default)]
pub bold: bool,
#[serde(default)]
pub italic: bool,
pub font_scale: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EditorStyledSpan {
pub start_byte: usize,
pub end_byte: usize,
pub style: Style,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DiagnosticMark {
pub start_col: usize,
pub end_col: usize,
pub severity: DiagnosticSeverity,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SpellMark {
pub start_col: usize,
pub end_col: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EditorLine {
pub raw_text: String,
pub gutter_text: String,
pub spans: Vec<EditorStyledSpan>,
pub line_idx: usize,
#[serde(default)]
pub is_current_line: bool,
#[serde(default)]
pub is_fold_header: bool,
#[serde(default)]
pub folded_line_count: usize,
#[serde(default)]
pub git_diff: Option<GitLineStatus>,
#[serde(default)]
pub diff_status: Option<DiffLine>,
#[serde(default)]
pub diagnostics: Vec<DiagnosticMark>,
#[serde(default)]
pub spell_errors: Vec<SpellMark>,
#[serde(default)]
pub is_breakpoint: bool,
#[serde(default)]
pub is_conditional_bp: bool,
#[serde(default)]
pub is_dap_current: bool,
#[serde(default)]
pub is_wrap_continuation: bool,
#[serde(default)]
pub segment_col_offset: usize,
#[serde(default)]
pub annotation: Option<String>,
#[serde(default)]
pub ghost_suffix: Option<String>,
#[serde(default)]
pub is_ghost_continuation: bool,
#[serde(default)]
pub indent_guides: Vec<usize>,
#[serde(default)]
pub colorcolumns: Vec<usize>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Editor {
pub id: WidgetId,
pub rect: Rect,
pub lines: Vec<EditorLine>,
#[serde(default)]
pub cursor: Option<EditorCursor>,
#[serde(default)]
pub extra_cursors: Vec<CursorPos>,
#[serde(default)]
pub selection: Option<EditorSelection>,
#[serde(default)]
pub extra_selections: Vec<EditorSelection>,
#[serde(default)]
pub yank_highlight: Option<EditorSelection>,
pub scroll_top: usize,
#[serde(default)]
pub scroll_left: usize,
pub total_lines: usize,
#[serde(default)]
pub max_col: usize,
#[serde(default)]
pub gutter_char_width: usize,
pub is_active: bool,
pub show_active_bg: bool,
pub has_git_diff: bool,
pub has_breakpoints: bool,
#[serde(default)]
pub diagnostic_gutter: HashMap<usize, DiagnosticSeverity>,
#[serde(default)]
pub code_action_lines: HashSet<usize>,
#[serde(default)]
pub bracket_match_positions: Vec<(usize, usize)>,
#[serde(default)]
pub active_indent_col: Option<usize>,
pub tabstop: usize,
pub cursorline: bool,
#[serde(default = "default_lightbulb_glyph")]
pub lightbulb_glyph: char,
}
fn default_lightbulb_glyph() -> char {
'!'
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct EditorPaintOptions {
pub suppress_v_scrollbar: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditorHit {
BufferPos { line: usize, col: usize },
Gutter { line_idx: usize, gutter_col: usize },
VScrollbar,
HScrollbar,
Empty,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EditorLayout {
pub bounds: Rect,
pub gutter_bounds: Option<Rect>,
pub text_bounds: Rect,
pub v_scrollbar_bounds: Option<Rect>,
pub h_scrollbar_bounds: Option<Rect>,
pub cell_width: f32,
pub line_height: f32,
pub scroll_top: usize,
pub scroll_left: usize,
pub visible_lines: usize,
pub visible_cols: usize,
}
impl EditorLayout {
pub fn hit_test(&self, x: f32, y: f32) -> EditorHit {
if x < self.bounds.x
|| x >= self.bounds.x + self.bounds.width
|| y < self.bounds.y
|| y >= self.bounds.y + self.bounds.height
{
return EditorHit::Empty;
}
if let Some(vsb) = self.v_scrollbar_bounds {
if x >= vsb.x && x < vsb.x + vsb.width && y >= vsb.y && y < vsb.y + vsb.height {
return EditorHit::VScrollbar;
}
}
if let Some(hsb) = self.h_scrollbar_bounds {
if x >= hsb.x && x < hsb.x + hsb.width && y >= hsb.y && y < hsb.y + hsb.height {
return EditorHit::HScrollbar;
}
}
if let Some(g) = self.gutter_bounds {
if x >= g.x && x < g.x + g.width && y >= g.y && y < g.y + g.height {
let row = ((y - g.y) / self.line_height).floor() as usize;
let line_idx = self.scroll_top + row;
let gutter_col = ((x - g.x) / self.cell_width).floor() as usize;
return EditorHit::Gutter {
line_idx,
gutter_col,
};
}
}
if x >= self.text_bounds.x
&& x < self.text_bounds.x + self.text_bounds.width
&& y >= self.text_bounds.y
&& y < self.text_bounds.y + self.text_bounds.height
{
let row = ((y - self.text_bounds.y) / self.line_height).floor() as usize;
let col_offset = ((x - self.text_bounds.x) / self.cell_width).floor() as usize;
let line = self.scroll_top + row;
let col = self.scroll_left + col_offset;
return EditorHit::BufferPos { line, col };
}
EditorHit::Empty
}
pub fn col_at_x(&self, editor: &Editor, view_row: usize, x: f32) -> usize {
let segment_col_offset = editor
.lines
.get(view_row)
.map(|line| line.segment_col_offset)
.unwrap_or(0);
let col_offset = if self.cell_width > 0.0 {
((x - self.text_bounds.x) / self.cell_width)
.floor()
.max(0.0) as usize
} else {
0
};
self.scroll_left + col_offset + segment_col_offset
}
pub fn x_at_col(&self, editor: &Editor, view_row: usize, col: usize) -> f32 {
let segment_col_offset = editor
.lines
.get(view_row)
.map(|line| line.segment_col_offset)
.unwrap_or(0);
let col_offset = col.saturating_sub(self.scroll_left + segment_col_offset);
self.text_bounds.x + col_offset as f32 * self.cell_width
}
}
impl Default for Editor {
fn default() -> Self {
Self::new(WidgetId::new(String::new()), Rect::default())
}
}
impl Editor {
pub fn new(id: WidgetId, rect: Rect) -> Self {
Self {
id,
rect,
lines: Vec::new(),
cursor: None,
extra_cursors: Vec::new(),
selection: None,
extra_selections: Vec::new(),
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::new(),
active_indent_col: None,
tabstop: 4,
cursorline: false,
lightbulb_glyph: default_lightbulb_glyph(),
}
}
#[must_use]
pub fn with_lines(mut self, lines: Vec<EditorLine>) -> Self {
self.lines = lines;
self
}
#[must_use]
pub fn with_cursor(mut self, cursor: EditorCursor) -> Self {
self.cursor = Some(cursor);
self
}
#[must_use]
pub fn with_extra_cursors(mut self, extra_cursors: Vec<CursorPos>) -> Self {
self.extra_cursors = extra_cursors;
self
}
#[must_use]
pub fn with_selection(mut self, selection: EditorSelection) -> Self {
self.selection = Some(selection);
self
}
#[must_use]
pub fn with_extra_selections(mut self, extra_selections: Vec<EditorSelection>) -> Self {
self.extra_selections = extra_selections;
self
}
#[must_use]
pub fn with_yank_highlight(mut self, yank_highlight: EditorSelection) -> Self {
self.yank_highlight = Some(yank_highlight);
self
}
#[must_use]
pub fn with_scroll_top(mut self, scroll_top: usize) -> Self {
self.scroll_top = scroll_top;
self
}
#[must_use]
pub fn with_scroll_left(mut self, scroll_left: usize) -> Self {
self.scroll_left = scroll_left;
self
}
#[must_use]
pub fn with_total_lines(mut self, total_lines: usize) -> Self {
self.total_lines = total_lines;
self
}
#[must_use]
pub fn with_max_col(mut self, max_col: usize) -> Self {
self.max_col = max_col;
self
}
#[must_use]
pub fn with_gutter_char_width(mut self, gutter_char_width: usize) -> Self {
self.gutter_char_width = gutter_char_width;
self
}
#[must_use]
pub fn with_is_active(mut self, is_active: bool) -> Self {
self.is_active = is_active;
self
}
#[must_use]
pub fn with_show_active_bg(mut self, show_active_bg: bool) -> Self {
self.show_active_bg = show_active_bg;
self
}
#[must_use]
pub fn with_has_git_diff(mut self, has_git_diff: bool) -> Self {
self.has_git_diff = has_git_diff;
self
}
#[must_use]
pub fn with_has_breakpoints(mut self, has_breakpoints: bool) -> Self {
self.has_breakpoints = has_breakpoints;
self
}
#[must_use]
pub fn with_diagnostic_gutter(
mut self,
diagnostic_gutter: HashMap<usize, DiagnosticSeverity>,
) -> Self {
self.diagnostic_gutter = diagnostic_gutter;
self
}
#[must_use]
pub fn with_code_action_lines(mut self, code_action_lines: HashSet<usize>) -> Self {
self.code_action_lines = code_action_lines;
self
}
#[must_use]
pub fn with_bracket_match_positions(
mut self,
bracket_match_positions: Vec<(usize, usize)>,
) -> Self {
self.bracket_match_positions = bracket_match_positions;
self
}
#[must_use]
pub fn with_active_indent_col(mut self, active_indent_col: usize) -> Self {
self.active_indent_col = Some(active_indent_col);
self
}
#[must_use]
pub fn with_tabstop(mut self, tabstop: usize) -> Self {
self.tabstop = tabstop;
self
}
#[must_use]
pub fn with_cursorline(mut self, cursorline: bool) -> Self {
self.cursorline = cursorline;
self
}
#[must_use]
pub fn with_lightbulb_glyph(mut self, lightbulb_glyph: char) -> Self {
self.lightbulb_glyph = lightbulb_glyph;
self
}
pub fn layout(&self, viewport: Rect, cell_width: f32, line_height: f32) -> EditorLayout {
self.layout_with_options(
viewport,
cell_width,
line_height,
EditorPaintOptions::default(),
)
}
pub fn layout_with_options(
&self,
viewport: Rect,
cell_width: f32,
line_height: f32,
options: EditorPaintOptions,
) -> EditorLayout {
self.layout_with_options_and_v_scrollbar_w(viewport, cell_width, line_height, options, None)
}
pub fn layout_with_options_and_v_scrollbar_w(
&self,
viewport: Rect,
cell_width: f32,
line_height: f32,
options: EditorPaintOptions,
v_scrollbar_w: Option<f32>,
) -> EditorLayout {
self.layout_with_options_and_reserve(
viewport,
cell_width,
line_height,
options,
v_scrollbar_w,
0.0,
)
}
pub fn layout_with_options_and_reserve(
&self,
viewport: Rect,
cell_width: f32,
line_height: f32,
options: EditorPaintOptions,
v_scrollbar_w: Option<f32>,
right_reserve_w: f32,
) -> EditorLayout {
let right_reserve_w = right_reserve_w.max(0.0);
let gutter_w = self.gutter_char_width as f32 * cell_width;
let visible_lines = if line_height > 0.0 {
(viewport.height / line_height).floor() as usize
} else {
0
};
let has_v_scrollbar = !options.suppress_v_scrollbar
&& self.total_lines > visible_lines
&& viewport.width > gutter_w + cell_width + right_reserve_w;
let v_scrollbar_w = if has_v_scrollbar {
v_scrollbar_w.unwrap_or(cell_width)
} else {
0.0
};
let text_w = (viewport.width - gutter_w - v_scrollbar_w - right_reserve_w).max(0.0);
let visible_cols = if cell_width > 0.0 {
(text_w / cell_width).floor() as usize
} else {
0
};
let has_h_scrollbar = self.max_col > visible_cols && viewport.height > line_height;
let h_scrollbar_h = if has_h_scrollbar { line_height } else { 0.0 };
let text_h = (viewport.height - h_scrollbar_h).max(0.0);
let visible_lines_final = if line_height > 0.0 {
(text_h / line_height).floor() as usize
} else {
0
};
let gutter_bounds = if gutter_w > 0.0 {
Some(Rect::new(viewport.x, viewport.y, gutter_w, text_h))
} else {
None
};
let text_x = viewport.x + gutter_w;
let text_bounds = Rect::new(text_x, viewport.y, text_w, text_h);
let v_scrollbar_bounds = if has_v_scrollbar {
let v_track_h = text_h;
Some(Rect::new(
viewport.x + viewport.width - v_scrollbar_w,
viewport.y,
v_scrollbar_w,
v_track_h,
))
} else {
None
};
let h_scrollbar_bounds = if has_h_scrollbar {
let h_track_x = viewport.x + gutter_w;
let h_track_w = text_w;
Some(Rect::new(
h_track_x,
viewport.y + text_h,
h_track_w,
h_scrollbar_h,
))
} else {
None
};
EditorLayout {
bounds: viewport,
gutter_bounds,
text_bounds,
v_scrollbar_bounds,
h_scrollbar_bounds,
cell_width,
line_height,
scroll_top: self.scroll_top,
scroll_left: self.scroll_left,
visible_lines: visible_lines_final,
visible_cols,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn editor_line_default_constructible_via_serde() {
let json = r#"{ "raw_text": "fn main() {}",
"gutter_text": " 1",
"spans": [],
"line_idx": 0 }"#;
let line: EditorLine = serde_json::from_str(json).expect("EditorLine deserialise");
assert_eq!(line.raw_text, "fn main() {}");
assert_eq!(line.line_idx, 0);
assert!(!line.is_current_line);
assert!(line.diagnostics.is_empty());
assert!(line.indent_guides.is_empty());
}
#[test]
fn editor_round_trips_through_serde() {
let editor = Editor {
id: "editor:0".into(),
rect: Rect::new(0.0, 0.0, 800.0, 600.0),
lines: vec![EditorLine {
raw_text: "let x = 1;".into(),
gutter_text: " 1".into(),
spans: vec![EditorStyledSpan {
start_byte: 0,
end_byte: 3,
style: Style {
fg: Color::rgb(200, 200, 200),
bg: None,
bold: false,
italic: false,
font_scale: 1.0,
},
}],
line_idx: 0,
is_current_line: true,
is_fold_header: false,
folded_line_count: 0,
git_diff: Some(GitLineStatus::Added),
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![],
}],
cursor: Some(EditorCursor {
pos: CursorPos {
view_line: 0,
col: 4,
},
shape: CursorShape::Block,
}),
extra_cursors: vec![],
selection: None,
extra_selections: vec![],
yank_highlight: None,
scroll_top: 0,
scroll_left: 0,
total_lines: 1,
max_col: 10,
gutter_char_width: 3,
is_active: true,
show_active_bg: false,
has_git_diff: true,
has_breakpoints: false,
diagnostic_gutter: HashMap::new(),
code_action_lines: HashSet::new(),
bracket_match_positions: vec![],
active_indent_col: None,
tabstop: 4,
cursorline: true,
lightbulb_glyph: '!',
};
let json = serde_json::to_string(&editor).expect("Editor serialise");
let back: Editor = serde_json::from_str(&json).expect("Editor deserialise");
assert_eq!(back.lines.len(), 1);
assert_eq!(back.lines[0].raw_text, "let x = 1;");
assert!(matches!(back.lines[0].git_diff, Some(GitLineStatus::Added)));
assert!(matches!(back.cursor.unwrap().shape, CursorShape::Block));
}
#[test]
fn diagnostic_severity_lsp_order_preserved() {
assert!(DiagnosticSeverity::Error < DiagnosticSeverity::Warning);
assert!(DiagnosticSeverity::Warning < DiagnosticSeverity::Information);
assert!(DiagnosticSeverity::Information < DiagnosticSeverity::Hint);
}
fn make_editor(gutter_w: usize, total_lines: usize, max_col: usize) -> Editor {
Editor {
id: "ed".into(),
rect: Rect::new(0.0, 0.0, 80.0, 24.0),
lines: Vec::new(),
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: gutter_w,
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: '!',
}
}
#[test]
fn editor_layout_tui_basic_zones() {
let ed = make_editor(4, 100, 40);
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let l = ed.layout(vp, 1.0, 1.0);
let g = l.gutter_bounds.unwrap();
assert_eq!(g.x, 0.0);
assert_eq!(g.width, 4.0);
assert_eq!(l.text_bounds.x, 4.0);
assert_eq!(l.text_bounds.width, 75.0);
assert!(l.v_scrollbar_bounds.is_some());
let vsb = l.v_scrollbar_bounds.unwrap();
assert_eq!(vsb.x, 79.0);
assert_eq!(vsb.width, 1.0);
}
#[test]
fn editor_layout_with_options_suppress_v_scrollbar_overrides_overflow() {
let ed = make_editor(4, 100, 40);
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
assert!(ed.layout(vp, 1.0, 1.0).v_scrollbar_bounds.is_some());
let l = ed.layout_with_options(
vp,
1.0,
1.0,
EditorPaintOptions {
suppress_v_scrollbar: true,
},
);
assert!(
l.v_scrollbar_bounds.is_none(),
"suppress_v_scrollbar should suppress the column even though total_lines overflows"
);
assert_eq!(l.text_bounds.width, 76.0);
}
#[test]
fn editor_layout_v_scrollbar_w_overrides_width() {
let ed = make_editor(4, 100, 40);
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let baseline = ed.layout(vp, 1.0, 1.0);
let baseline_vsb = baseline.v_scrollbar_bounds.unwrap();
assert_eq!(baseline_vsb.width, 1.0);
assert_eq!(baseline.text_bounds.width, 75.0);
let l = ed.layout_with_options_and_v_scrollbar_w(
vp,
1.0,
1.0,
EditorPaintOptions::default(),
Some(14.0),
);
let vsb = l.v_scrollbar_bounds.expect("buffer overflows the viewport");
assert_eq!(vsb.width, 14.0);
assert_eq!(vsb.x, vp.x + vp.width - 14.0);
assert_eq!(l.text_bounds.width, 80.0 - 4.0 - 14.0);
}
#[test]
fn editor_layout_v_scrollbar_w_ignored_without_a_scrollbar() {
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let fits = make_editor(4, 10, 20);
let l = fits.layout_with_options_and_v_scrollbar_w(
vp,
1.0,
1.0,
EditorPaintOptions::default(),
Some(14.0),
);
assert!(l.v_scrollbar_bounds.is_none());
assert_eq!(l.text_bounds.width, 76.0);
let overflow = make_editor(4, 100, 40);
let l = overflow.layout_with_options_and_v_scrollbar_w(
vp,
1.0,
1.0,
EditorPaintOptions {
suppress_v_scrollbar: true,
},
Some(14.0),
);
assert!(l.v_scrollbar_bounds.is_none());
assert_eq!(l.text_bounds.width, 76.0);
}
#[test]
fn editor_layout_reserve_narrows_text_keeps_scrollbar_flush() {
let ed = make_editor(4, 100, 40);
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let zero_reserve = ed.layout_with_options_and_reserve(
vp,
1.0,
1.0,
EditorPaintOptions::default(),
Some(14.0),
0.0,
);
let baseline = ed.layout_with_options_and_v_scrollbar_w(
vp,
1.0,
1.0,
EditorPaintOptions::default(),
Some(14.0),
);
assert_eq!(zero_reserve.text_bounds, baseline.text_bounds);
assert_eq!(zero_reserve.v_scrollbar_bounds, baseline.v_scrollbar_bounds);
let l = ed.layout_with_options_and_reserve(
vp,
1.0,
1.0,
EditorPaintOptions::default(),
Some(14.0),
10.0,
);
assert_eq!(l.text_bounds.width, 80.0 - 4.0 - 14.0 - 10.0);
let vsb = l.v_scrollbar_bounds.expect("buffer overflows the viewport");
assert_eq!(vsb.x, vp.x + vp.width - 14.0);
assert_eq!(vsb.width, 14.0);
let reserved_strip_w = vsb.x - (l.text_bounds.x + l.text_bounds.width);
assert_eq!(reserved_strip_w, 10.0);
}
#[test]
fn editor_layout_reserve_negative_clamps_to_zero() {
let ed = make_editor(4, 10, 20);
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let l = ed.layout_with_options_and_reserve(
vp,
1.0,
1.0,
EditorPaintOptions::default(),
None,
-10.0,
);
assert_eq!(l.text_bounds.width, 76.0);
}
#[test]
fn editor_layout_reserve_suppresses_scrollbar_when_viewport_too_narrow() {
let ed = make_editor(4, 100, 40);
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let l = ed.layout_with_options_and_reserve(
vp,
1.0,
1.0,
EditorPaintOptions::default(),
None,
76.0,
);
assert!(l.v_scrollbar_bounds.is_none());
assert_eq!(l.text_bounds.width, 0.0);
}
#[test]
fn editor_layout_no_scrollbar_when_content_fits() {
let ed = make_editor(4, 10, 20);
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let l = ed.layout(vp, 1.0, 1.0);
assert!(l.v_scrollbar_bounds.is_none());
assert!(l.h_scrollbar_bounds.is_none());
assert_eq!(l.text_bounds.width, 76.0);
}
#[test]
fn editor_layout_h_scrollbar_present() {
let ed = make_editor(4, 10, 200);
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let l = ed.layout(vp, 1.0, 1.0);
assert!(l.h_scrollbar_bounds.is_some());
let hsb = l.h_scrollbar_bounds.unwrap();
assert_eq!(hsb.y, 23.0); assert_eq!(hsb.height, 1.0);
}
#[test]
fn editor_hit_test_text_area() {
let mut ed = make_editor(4, 100, 40);
ed.scroll_top = 10;
ed.scroll_left = 5;
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let l = ed.layout(vp, 1.0, 1.0);
assert_eq!(
l.hit_test(4.0, 0.0),
EditorHit::BufferPos { line: 10, col: 5 }
);
assert_eq!(
l.hit_test(7.0, 2.0),
EditorHit::BufferPos { line: 12, col: 8 }
);
}
#[test]
fn editor_hit_test_gutter() {
let mut ed = make_editor(4, 100, 40);
ed.scroll_top = 5;
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let l = ed.layout(vp, 1.0, 1.0);
assert_eq!(
l.hit_test(1.0, 3.0),
EditorHit::Gutter {
line_idx: 8,
gutter_col: 1
}
);
}
#[test]
fn editor_hit_test_v_scrollbar() {
let ed = make_editor(4, 100, 40);
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let l = ed.layout(vp, 1.0, 1.0);
assert_eq!(l.hit_test(79.0, 5.0), EditorHit::VScrollbar);
}
#[test]
fn editor_hit_test_h_scrollbar() {
let ed = make_editor(4, 10, 200);
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let l = ed.layout(vp, 1.0, 1.0);
assert_eq!(l.hit_test(10.0, 23.0), EditorHit::HScrollbar);
}
#[test]
fn editor_hit_test_outside() {
let ed = make_editor(4, 100, 40);
let vp = Rect::new(10.0, 10.0, 80.0, 24.0);
let l = ed.layout(vp, 1.0, 1.0);
assert_eq!(l.hit_test(5.0, 5.0), EditorHit::Empty);
}
#[test]
fn editor_layout_pixel_units() {
let ed = make_editor(4, 500, 200);
let vp = Rect::new(0.0, 0.0, 800.0, 600.0);
let l = ed.layout(vp, 8.0, 16.0);
let g = l.gutter_bounds.unwrap();
assert_eq!(g.width, 32.0);
let vsb = l.v_scrollbar_bounds.unwrap();
assert_eq!(vsb.width, 8.0);
assert_eq!(l.text_bounds.width, 760.0);
assert_eq!(l.visible_cols, 95);
assert_eq!(l.visible_lines, 36);
}
fn line_with_segment_offset(segment_col_offset: usize) -> EditorLine {
EditorLine {
raw_text: "wrapped segment text".into(),
gutter_text: String::new(),
spans: Vec::new(),
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: segment_col_offset > 0,
segment_col_offset,
annotation: None,
ghost_suffix: None,
is_ghost_continuation: false,
indent_guides: Vec::new(),
colorcolumns: Vec::new(),
}
}
#[test]
fn editor_col_at_x_matches_hit_test_in_monospace_case() {
let mut ed = make_editor(4, 100, 40);
ed.scroll_top = 10;
ed.scroll_left = 5;
ed.lines = vec![line_with_segment_offset(0)];
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let l = ed.layout(vp, 1.0, 1.0);
match l.hit_test(7.0, 2.0) {
EditorHit::BufferPos { col, .. } => {
assert_eq!(l.col_at_x(&ed, 2, 7.0), col);
}
other => panic!("expected BufferPos, got {other:?}"),
}
}
#[test]
fn editor_col_at_x_adds_segment_col_offset_for_wrapped_rows() {
let mut ed = make_editor(4, 100, 40);
ed.lines = vec![line_with_segment_offset(20)];
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let l = ed.layout(vp, 1.0, 1.0);
assert_eq!(l.col_at_x(&ed, 0, l.text_bounds.x), 20);
assert_eq!(l.col_at_x(&ed, 0, l.text_bounds.x + 3.0), 23);
}
#[test]
fn editor_col_at_x_out_of_range_row_falls_back_to_scroll_left() {
let mut ed = make_editor(4, 100, 40);
ed.scroll_left = 7;
ed.lines = Vec::new();
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let l = ed.layout(vp, 1.0, 1.0);
assert_eq!(l.col_at_x(&ed, 0, l.text_bounds.x), 7);
}
#[test]
fn editor_x_at_col_round_trips_with_col_at_x_in_monospace_case() {
let mut ed = make_editor(4, 100, 40);
ed.scroll_top = 10;
ed.scroll_left = 5;
ed.lines = vec![line_with_segment_offset(0)];
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let l = ed.layout(vp, 1.0, 1.0);
let col = l.col_at_x(&ed, 2, 7.0);
let x = l.x_at_col(&ed, 2, col);
assert_eq!(l.col_at_x(&ed, 2, x), col);
}
#[test]
fn editor_x_at_col_subtracts_segment_col_offset_for_wrapped_rows() {
let mut ed = make_editor(4, 100, 40);
ed.lines = vec![line_with_segment_offset(20)];
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let l = ed.layout(vp, 1.0, 1.0);
assert_eq!(l.x_at_col(&ed, 0, 20), l.text_bounds.x);
assert_eq!(l.x_at_col(&ed, 0, 23), l.text_bounds.x + 3.0);
}
#[test]
fn editor_x_at_col_out_of_range_row_falls_back_to_scroll_left() {
let mut ed = make_editor(4, 100, 40);
ed.scroll_left = 7;
ed.lines = Vec::new();
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let l = ed.layout(vp, 1.0, 1.0);
assert_eq!(l.x_at_col(&ed, 0, 7), l.text_bounds.x);
}
#[test]
fn editor_col_at_x_and_x_at_col_round_trip_over_a_range() {
let mut ed = make_editor(4, 100, 40);
ed.scroll_left = 3;
ed.lines = vec![line_with_segment_offset(2)];
let vp = Rect::new(0.0, 0.0, 80.0, 24.0);
let l = ed.layout(vp, 1.0, 1.0);
let base = l.scroll_left + 2; for col in base..base + 20 {
let x = l.x_at_col(&ed, 0, col);
assert_eq!(
l.col_at_x(&ed, 0, x),
col,
"round-trip failed for col {col}"
);
}
}
fn cols_line(
line_idx: usize,
raw_text: &str,
segment_col_offset: usize,
is_ghost_continuation: bool,
diff_status: Option<DiffLine>,
) -> EditorLine {
EditorLine {
raw_text: raw_text.into(),
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,
diagnostics: Vec::new(),
spell_errors: Vec::new(),
is_breakpoint: false,
is_conditional_bp: false,
is_dap_current: false,
is_wrap_continuation: segment_col_offset > 0,
segment_col_offset,
annotation: None,
ghost_suffix: None,
is_ghost_continuation,
indent_guides: Vec::new(),
colorcolumns: Vec::new(),
}
}
fn plain(line_idx: usize, raw_text: &str) -> EditorLine {
cols_line(line_idx, raw_text, 0, false, None)
}
#[test]
fn cols_on_plain_line_char_selection() {
let line = plain(0, "hello world");
let sel = EditorSelection {
kind: SelectionKind::Char,
start_line: 0,
start_col: 2,
end_line: 0,
end_col: 5,
};
let cols = sel.cols_on(&line).expect("line is within selection range");
assert_eq!((cols.start, cols.end), (2, 6));
assert!(!cols.extends_beyond);
}
#[test]
fn cols_on_returns_none_outside_selection_line_range() {
let sel = EditorSelection {
kind: SelectionKind::Char,
start_line: 3,
start_col: 0,
end_line: 5,
end_col: 0,
};
assert_eq!(sel.cols_on(&plain(1, "before")), None);
assert_eq!(sel.cols_on(&plain(6, "after")), None);
assert!(sel.cols_on(&plain(4, "inside")).is_some());
}
#[test]
fn cols_on_ghost_continuation_row_is_none() {
let line = cols_line(0, "", 0, true, None);
let sel = EditorSelection {
kind: SelectionKind::Line,
start_line: 0,
start_col: 0,
end_line: 0,
end_col: 0,
};
assert_eq!(sel.cols_on(&line), None);
}
#[test]
fn cols_on_diff_padding_row_is_none() {
let line = cols_line(2, "", 0, false, Some(DiffLine::Padding));
let sel = EditorSelection {
kind: SelectionKind::Block,
start_line: 0,
start_col: 0,
end_line: 4,
end_col: 3,
};
assert_eq!(sel.cols_on(&line), None);
}
#[test]
fn cols_on_diff_added_row_is_still_selectable() {
let line = cols_line(0, "added line", 0, false, Some(DiffLine::Added));
let sel = EditorSelection {
kind: SelectionKind::Char,
start_line: 0,
start_col: 0,
end_line: 0,
end_col: 2,
};
assert_eq!(sel.cols_on(&line).map(|c| (c.start, c.end)), Some((0, 3)));
}
#[test]
fn cols_on_wrapped_segment_offsets_into_local_columns() {
let line = cols_line(0, &"x".repeat(20), 10, false, None);
let sel = EditorSelection {
kind: SelectionKind::Char,
start_line: 0,
start_col: 10,
end_line: 0,
end_col: 24,
};
let cols = sel.cols_on(&line).expect("segment overlaps selection");
assert_eq!((cols.start, cols.end), (0, 15));
assert!(!cols.extends_beyond);
}
#[test]
fn cols_on_wrapped_segment_before_selection_is_none() {
let line = cols_line(0, &"x".repeat(20), 10, false, None);
let sel = EditorSelection {
kind: SelectionKind::Char,
start_line: 0,
start_col: 0,
end_line: 0,
end_col: 4,
};
assert_eq!(sel.cols_on(&line), None);
}
#[test]
fn cols_on_char_selection_spanning_multiple_lines_extends_beyond_last_line() {
let line = plain(0, "short");
let sel = EditorSelection {
kind: SelectionKind::Char,
start_line: 0,
start_col: 0,
end_line: 2,
end_col: 3,
};
let cols = sel.cols_on(&line).expect("first line is in range");
assert_eq!((cols.start, cols.end), (0, 5));
assert!(
cols.extends_beyond,
"line 0 isn't the selection's last line"
);
}
#[test]
fn cols_on_line_kind_always_extends_beyond() {
let line = plain(1, "abc");
let sel = EditorSelection {
kind: SelectionKind::Line,
start_line: 0,
start_col: 99, end_line: 2,
end_col: 99, };
let cols = sel.cols_on(&line).expect("line 1 is within [0, 2]");
assert!(cols.extends_beyond);
}
#[test]
fn cols_on_block_kind_uses_same_columns_on_every_line() {
let sel = EditorSelection {
kind: SelectionKind::Block,
start_line: 0,
start_col: 2,
end_line: 2,
end_col: 4,
};
for idx in 0..=2 {
let line = plain(idx, "0123456789");
let cols = sel.cols_on(&line).unwrap();
assert_eq!((cols.start, cols.end), (2, 5), "line {idx}");
}
}
#[test]
fn cols_on_blank_interior_line_highlights_one_column() {
let line = plain(1, "");
let sel = EditorSelection {
kind: SelectionKind::Char,
start_line: 0,
start_col: 5,
end_line: 2,
end_col: 1,
};
let cols = sel.cols_on(&line).expect("blank interior line is selected");
assert_eq!((cols.start, cols.end), (0, 1));
}
#[test]
fn default_matches_new_with_empty_id_and_rect() {
assert_eq!(
Editor::default(),
Editor::new(WidgetId::new(""), Rect::default())
);
}
#[test]
fn with_builders_reach_every_field_a_struct_literal_can_set() {
let ed = Editor::new(WidgetId::new("editor:0"), Rect::new(0.0, 0.0, 80.0, 24.0))
.with_cursor(EditorCursor {
pos: CursorPos {
view_line: 0,
col: 0,
},
shape: CursorShape::Bar,
})
.with_is_active(true)
.with_tabstop(4)
.with_cursorline(true);
assert_eq!(
ed,
Editor {
id: WidgetId::new("editor:0"),
rect: Rect::new(0.0, 0.0, 80.0, 24.0),
lines: Vec::new(),
cursor: Some(EditorCursor {
pos: CursorPos {
view_line: 0,
col: 0,
},
shape: CursorShape::Bar,
}),
extra_cursors: Vec::new(),
selection: None,
extra_selections: Vec::new(),
yank_highlight: None,
scroll_top: 0,
scroll_left: 0,
total_lines: 0,
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: true,
lightbulb_glyph: '!',
}
);
}
}