pub use crate::input::keybindings::Action;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Caret {
pub position: usize,
pub anchor: Option<usize>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ModalSnapshot {
pub top_popup: Option<PopupView>,
pub depth: usize,
pub prompt: Option<PromptView>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PopupView {
pub kind: String,
pub title: Option<String>,
pub items: Vec<String>,
pub selected_index: Option<usize>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PromptView {
pub prompt_type: String,
pub input: String,
pub cursor_pos: usize,
pub suggestions: Vec<String>,
pub selected_suggestion: Option<usize>,
}
impl Caret {
pub fn at(position: usize) -> Self {
Self {
position,
anchor: None,
}
}
pub fn range(anchor: usize, position: usize) -> Self {
Self {
position,
anchor: Some(anchor),
}
}
pub fn selection_range(&self) -> Option<std::ops::Range<usize>> {
self.anchor.map(|a| {
if a <= self.position {
a..self.position
} else {
self.position..a
}
})
}
}
pub trait EditorTestApi {
fn dispatch(&mut self, action: Action);
fn dispatch_seq(&mut self, actions: &[Action]);
fn buffer_text(&self) -> String;
fn primary_caret(&self) -> Caret;
fn carets(&self) -> Vec<Caret>;
fn selection_text(&mut self) -> String;
fn viewport_top_byte(&self) -> usize;
fn terminal_width(&self) -> u16;
fn terminal_height(&self) -> u16;
fn gutter_width(&self) -> u16;
fn hardware_cursor_position(&mut self) -> Option<(u16, u16)>;
fn visible_byte_range(&self) -> Option<(usize, usize)>;
fn modal_snapshot(&self) -> ModalSnapshot;
fn buffer_count(&self) -> usize;
fn active_buffer_path(&self) -> Option<String>;
fn buffer_paths(&self) -> Vec<String>;
fn dispatch_mouse_click(&mut self, col: u16, row: u16) -> bool;
fn is_modified(&self) -> bool;
fn take_full_redraw_request_for_tests(&mut self) -> bool;
fn seed_marker(&mut self, byte_offset: usize, symbol: &str, color: &str);
fn marker_positions(&self, symbol: &str) -> Vec<usize>;
fn active_event_log_len(&self) -> usize;
fn notify_file_changed(&mut self, path: &str);
fn create_side_by_side_diff(
&mut self,
name: &str,
mode: &str,
old_content: &str,
new_content: &str,
hunks: &[(usize, usize, usize, usize)],
) -> usize;
fn set_composite_initial_focus_hunk_on(&mut self, composite_handle: usize, hunk_index: usize);
fn composite_initial_focus_hunk_on(&self, composite_handle: usize) -> Option<usize>;
fn composite_next_hunk_active_on(&mut self, composite_handle: usize) -> bool;
fn composite_prev_hunk_active_on(&mut self, composite_handle: usize) -> bool;
fn flush_layout_for_tests(&mut self);
fn status_message(&self) -> Option<String>;
fn seed_virtual_line(
&mut self,
byte_offset: usize,
text: &str,
fg: Option<(u8, u8, u8)>,
bg: Option<(u8, u8, u8)>,
placement: &str,
namespace: &str,
priority: i32,
);
fn virtual_text_count(&self) -> usize;
fn clear_virtual_text_namespace(&mut self, namespace: &str);
fn add_margin_annotation(
&mut self,
line: usize,
position: &str,
symbol: &str,
color: Option<(u8, u8, u8)>,
annotation_id: Option<&str>,
);
fn remove_margin_annotation(&mut self, annotation_id: &str);
fn margin_left_total_width(&self) -> usize;
fn top_line_number(&mut self) -> usize;
fn primary_scrollbar_geometry(&self) -> Option<(usize, usize, u16, u16)>;
}
impl EditorTestApi for crate::app::Editor {
fn dispatch(&mut self, action: Action) {
self.dispatch_action_for_tests(action);
let _ = self.process_async_messages();
}
fn dispatch_seq(&mut self, actions: &[Action]) {
for a in actions {
self.dispatch_action_for_tests(a.clone());
}
let _ = self.process_async_messages();
}
fn buffer_text(&self) -> String {
self.active_state()
.buffer
.to_string()
.expect("buffer_text(): buffer has unloaded regions; semantic tests do not support large-file mode")
}
fn primary_caret(&self) -> Caret {
let c = self.active_cursors().primary();
Caret {
position: c.position,
anchor: c.anchor,
}
}
fn carets(&self) -> Vec<Caret> {
let mut out: Vec<Caret> = self
.active_cursors()
.iter()
.map(|(_, c)| Caret {
position: c.position,
anchor: c.anchor,
})
.collect();
out.sort_by_key(|c| c.position);
out
}
fn selection_text(&mut self) -> String {
let mut ranges: Vec<std::ops::Range<usize>> = self
.active_cursors()
.iter()
.filter_map(|(_, c)| c.selection_range())
.collect();
if ranges.is_empty() {
return String::new();
}
ranges.sort_by_key(|r| r.start);
let state = self.active_state_mut();
let parts: Vec<String> = ranges
.into_iter()
.map(|r| state.get_text_range(r.start, r.end))
.collect();
parts.join("\n")
}
fn viewport_top_byte(&self) -> usize {
self.active_viewport().top_byte
}
fn terminal_width(&self) -> u16 {
self.active_viewport().width
}
fn terminal_height(&self) -> u16 {
self.active_viewport().height
}
fn gutter_width(&self) -> u16 {
let buffer = &self.active_state().buffer;
u16::try_from(self.active_viewport().gutter_width(buffer)).unwrap_or(u16::MAX)
}
fn hardware_cursor_position(&mut self) -> Option<(u16, u16)> {
let cursor = *self.active_cursors().primary();
let viewport = self.active_viewport().clone();
let viewport_height = viewport.height;
let viewport_width = viewport.width;
let buffer = &mut self.active_state_mut().buffer;
let (col, row) = viewport.cursor_screen_position(buffer, &cursor);
if row >= viewport_height || col >= viewport_width {
None
} else {
Some((col, row))
}
}
fn visible_byte_range(&self) -> Option<(usize, usize)> {
None
}
fn is_modified(&self) -> bool {
self.active_state().buffer.is_modified()
}
fn take_full_redraw_request_for_tests(&mut self) -> bool {
self.take_full_redraw_request()
}
fn modal_snapshot(&self) -> ModalSnapshot {
use crate::view::popup::{Popup, PopupContent, PopupKind};
fn kind_name(kind: PopupKind) -> &'static str {
match kind {
PopupKind::Completion => "completion",
PopupKind::Hover => "hover",
PopupKind::Action => "action",
PopupKind::List => "list",
PopupKind::Text => "text",
}
}
fn project(p: &Popup) -> PopupView {
let (items, selected_index) = match &p.content {
PopupContent::List { items, selected } => (
items.iter().map(|i| i.text.clone()).collect(),
Some(*selected),
),
_ => (Vec::new(), None),
};
PopupView {
kind: kind_name(p.kind).to_string(),
title: p.title.clone(),
items,
selected_index,
}
}
let global = self.global_popups.all();
let local = &self.active_state().popups;
let depth = global.len() + local.all().len();
let top = self
.global_popups
.top()
.or_else(|| local.top())
.map(project);
let prompt = self.active_window().prompt.as_ref().map(|p| PromptView {
prompt_type: format!("{:?}", p.prompt_type),
input: p.input.clone(),
cursor_pos: p.cursor_pos,
suggestions: p.suggestions.iter().map(|s| s.text.clone()).collect(),
selected_suggestion: p.selected_suggestion,
});
ModalSnapshot {
top_popup: top,
depth,
prompt,
}
}
fn buffer_count(&self) -> usize {
self.buffer_count_for_tests()
}
fn active_buffer_path(&self) -> Option<String> {
let id = self.active_buffer();
let name = self.get_buffer_display_name(id);
if name.is_empty() {
None
} else {
Some(name)
}
}
fn buffer_paths(&self) -> Vec<String> {
self.all_buffer_ids_for_tests()
.into_iter()
.map(|id| {
let name = self.get_buffer_display_name(id);
if name.is_empty() {
format!("<unnamed:{}>", id.0)
} else {
name
}
})
.collect()
}
fn dispatch_mouse_click(&mut self, col: u16, row: u16) -> bool {
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
let down = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::NONE,
};
if let Err(e) = self.handle_mouse(down) {
tracing::trace!("mouse down errored in test dispatch: {e}");
}
let up = MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::NONE,
};
self.handle_mouse(up).unwrap_or(false)
}
fn seed_marker(&mut self, byte_offset: usize, symbol: &str, color: &str) {
use crate::view::margin::LineIndicator;
use ratatui::style::Color;
let color = match color.to_ascii_lowercase().as_str() {
"red" => Color::Red,
"green" => Color::Green,
"blue" => Color::Blue,
"yellow" => Color::Yellow,
"cyan" => Color::Cyan,
"magenta" => Color::Magenta,
"white" => Color::White,
"black" => Color::Black,
_ => Color::Red,
};
let indicator = LineIndicator::new(symbol, color, 10);
let state = self.active_state_mut();
let _ = state
.margins
.set_line_indicator(byte_offset, symbol.to_string(), indicator);
}
fn marker_positions(&self, symbol: &str) -> Vec<usize> {
let margins = &self.active_state().margins;
let max = self
.active_state()
.buffer
.to_string()
.map(|s| s.len())
.unwrap_or(usize::MAX);
let mut out: Vec<usize> = margins
.query_indicator_range(0, max.saturating_add(1))
.into_iter()
.filter_map(|(id, start, _end)| {
if margins.get_indicator_position(id).is_some()
&& margins
.namespaces_for_marker(id)
.iter()
.any(|n| n == symbol)
{
Some(start)
} else {
None
}
})
.collect();
out.sort_unstable();
out
}
fn active_event_log_len(&self) -> usize {
self.active_event_log().len()
}
fn notify_file_changed(&mut self, path: &str) {
self.handle_file_changed(path);
}
fn create_side_by_side_diff(
&mut self,
name: &str,
mode: &str,
old_content: &str,
new_content: &str,
hunks: &[(usize, usize, usize, usize)],
) -> usize {
use crate::model::composite_buffer::{
CompositeLayout, DiffHunk, LineAlignment, PaneStyle, SourcePane,
};
use crate::primitives::text_property::TextPropertyEntry;
let old_buffer_id = self.active_window_mut().create_virtual_buffer(
"OLD".to_string(),
"text".to_string(),
true,
);
self.set_virtual_buffer_content(old_buffer_id, vec![TextPropertyEntry::text(old_content)])
.expect("seed OLD virtual buffer");
let new_buffer_id = self.active_window_mut().create_virtual_buffer(
"NEW".to_string(),
"text".to_string(),
true,
);
self.set_virtual_buffer_content(new_buffer_id, vec![TextPropertyEntry::text(new_content)])
.expect("seed NEW virtual buffer");
let sources = vec![
SourcePane::new(old_buffer_id, "OLD", false).with_style(PaneStyle::old_diff()),
SourcePane::new(new_buffer_id, "NEW", false).with_style(PaneStyle::new_diff()),
];
let layout = CompositeLayout::SideBySide {
ratios: vec![0.5, 0.5],
show_separator: true,
};
let composite_id =
self.create_composite_buffer(name.to_string(), mode.to_string(), layout, sources);
let hunk_vec: Vec<DiffHunk> = hunks
.iter()
.map(|(os, oc, ns, nc)| DiffHunk::new(*os, *oc, *ns, *nc))
.collect();
let old_line_count = old_content.lines().count();
let new_line_count = new_content.lines().count();
let alignment = LineAlignment::from_hunks(&hunk_vec, old_line_count, new_line_count);
self.active_window_mut()
.set_composite_alignment(composite_id, alignment);
self.switch_buffer(composite_id);
composite_id.0
}
fn set_composite_initial_focus_hunk_on(&mut self, composite_handle: usize, hunk_index: usize) {
use crate::model::event::BufferId;
let id = BufferId(composite_handle);
if let Some(c) = self.active_window_mut().get_composite_mut(id) {
c.initial_focus_hunk = Some(hunk_index);
}
}
fn composite_initial_focus_hunk_on(&self, composite_handle: usize) -> Option<usize> {
use crate::model::event::BufferId;
let id = BufferId(composite_handle);
self.active_window()
.get_composite(id)
.and_then(|c| c.initial_focus_hunk)
}
fn composite_next_hunk_active_on(&mut self, composite_handle: usize) -> bool {
use crate::model::event::BufferId;
let id = BufferId(composite_handle);
self.active_window_mut().composite_next_hunk_active(id)
}
fn composite_prev_hunk_active_on(&mut self, composite_handle: usize) -> bool {
use crate::model::event::BufferId;
let id = BufferId(composite_handle);
self.active_window_mut().composite_prev_hunk_active(id)
}
fn flush_layout_for_tests(&mut self) {
self.flush_layout();
}
fn status_message(&self) -> Option<String> {
self.get_status_message().cloned()
}
fn seed_virtual_line(
&mut self,
byte_offset: usize,
text: &str,
fg: Option<(u8, u8, u8)>,
bg: Option<(u8, u8, u8)>,
placement: &str,
namespace: &str,
priority: i32,
) {
use crate::view::virtual_text::{VirtualTextNamespace, VirtualTextPosition};
use ratatui::style::{Color, Style};
let pos = match placement {
"above" | "Above" | "LineAbove" => VirtualTextPosition::LineAbove,
"below" | "Below" | "LineBelow" => VirtualTextPosition::LineBelow,
other => panic!(
"seed_virtual_line: unsupported placement {other:?}; want 'above' or 'below'"
),
};
let mut style = Style::default();
if let Some((r, g, b)) = fg {
style = style.fg(Color::Rgb(r, g, b));
} else {
style = style.fg(Color::DarkGray);
}
if let Some((r, g, b)) = bg {
style = style.bg(Color::Rgb(r, g, b));
}
let ns = VirtualTextNamespace::from_string(namespace.to_string());
let state = self.active_state_mut();
state.virtual_texts.add_line(
&mut state.marker_list,
byte_offset,
text.to_string(),
style,
pos,
ns,
priority,
);
}
fn virtual_text_count(&self) -> usize {
self.active_state().virtual_texts.len()
}
fn clear_virtual_text_namespace(&mut self, namespace: &str) {
use crate::view::virtual_text::VirtualTextNamespace;
let ns = VirtualTextNamespace::from_string(namespace.to_string());
let state = self.active_state_mut();
state
.virtual_texts
.clear_namespace(&mut state.marker_list, &ns);
}
fn add_margin_annotation(
&mut self,
line: usize,
position: &str,
symbol: &str,
color: Option<(u8, u8, u8)>,
annotation_id: Option<&str>,
) {
use crate::model::event::{Event, MarginContentData, MarginPositionData};
let pos = match position {
"left" | "Left" => MarginPositionData::Left,
"right" | "Right" => MarginPositionData::Right,
other => panic!("add_margin_annotation: unsupported position {other:?}"),
};
let event = Event::AddMarginAnnotation {
line,
position: pos,
content: MarginContentData::Symbol {
text: symbol.to_string(),
color,
},
annotation_id: annotation_id.map(|s| s.to_string()),
};
self.apply_event_to_active_buffer(&event);
}
fn remove_margin_annotation(&mut self, annotation_id: &str) {
use crate::model::event::Event;
let event = Event::RemoveMarginAnnotation {
annotation_id: annotation_id.to_string(),
};
self.apply_event_to_active_buffer(&event);
}
fn margin_left_total_width(&self) -> usize {
self.active_state().margins.left_total_width()
}
fn top_line_number(&mut self) -> usize {
let top_byte = self.active_viewport().top_byte;
self.active_state_mut().buffer.get_line_number(top_byte)
}
fn primary_scrollbar_geometry(&self) -> Option<(usize, usize, u16, u16)> {
let areas = self.get_split_areas();
let (_split, _buf, _content, scrollbar_rect, thumb_start, thumb_end) = areas.first()?;
Some((
*thumb_start,
*thumb_end,
scrollbar_rect.height,
scrollbar_rect.y,
))
}
}