use crate::input::keybindings::Action;
use crate::input::line_move::{move_lines, LineMoveDirection};
use crate::model::buffer::{Buffer, LineEnding};
use crate::model::buffer_position::{byte_to_2d, pos_2d_to_byte};
use crate::model::cursor::{Cursor, Cursors, Position2D, SelectionMode};
use crate::model::event::{CursorId, Event};
use crate::primitives::display_width::{byte_offset_at_visual_column, str_width};
use crate::primitives::highlighter::HighlightCategory;
use crate::primitives::indent_pattern::PatternIndentCalculator;
use crate::primitives::word_navigation::{
find_vi_word_end, find_word_end, find_word_end_right, find_word_start, find_word_start_left,
find_word_start_right,
};
use crate::state::EditorState;
use std::ops::Range;
#[derive(Debug, Clone, Copy)]
enum BlockDirection {
Left,
Right,
Up,
Down,
}
fn calculate_visual_column(
buffer: &mut Buffer,
cursor_position: usize,
estimated_line_length: usize,
) -> (usize, usize) {
let mut iter = buffer.line_iterator(cursor_position, estimated_line_length);
let current_line_start = iter.current_position();
let byte_column = cursor_position.saturating_sub(current_line_start);
if let Some((_, line_content)) = iter.next_line() {
if byte_column > 0 && byte_column <= line_content.len() {
(str_width(&line_content[..byte_column]), byte_column)
} else {
(byte_column, byte_column) }
} else {
(byte_column, byte_column) }
}
const LINE_ENDING_CHARS: &[char] = &['\r', '\n'];
fn content_len_without_line_ending(content: &str) -> usize {
content.trim_end_matches(LINE_ENDING_CHARS).len()
}
fn adjust_position_for_crlf_left(buffer: &Buffer, pos: usize) -> usize {
if buffer.line_ending() != LineEnding::CRLF || pos == 0 {
return pos;
}
let byte_at_pos = buffer.slice_bytes(pos..pos + 1);
if byte_at_pos.first() == Some(&b'\n') {
let prev_byte = buffer.slice_bytes(pos.saturating_sub(1)..pos);
if prev_byte.first() == Some(&b'\r') {
return pos - 1; }
}
pos
}
fn next_position_for_crlf(buffer: &Buffer, pos: usize, max_pos: usize) -> usize {
if buffer.line_ending() == LineEnding::CRLF {
let cur_byte = buffer.slice_bytes(pos..pos + 1);
let next_byte = buffer.slice_bytes(pos + 1..pos + 2);
if cur_byte.first() == Some(&b'\r') && next_byte.first() == Some(&b'\n') {
return (pos + 2).min(max_pos); }
}
buffer.next_grapheme_boundary(pos).min(max_pos)
}
fn apply_deletions(
state: &mut EditorState,
deletions: Vec<(CursorId, Range<usize>)>,
events: &mut Vec<Event>,
) {
for (cursor_id, range) in deletions {
let deleted_text = state.get_text_range(range.start, range.end);
events.push(Event::Delete {
range,
deleted_text,
cursor_id,
});
}
}
fn collect_line_starts(
buffer: &mut Buffer,
start_pos: usize,
end_pos: usize,
estimated_line_length: usize,
) -> Vec<usize> {
let buffer_len = buffer.len();
let mut line_starts = Vec::new();
let mut iter = buffer.line_iterator(start_pos, estimated_line_length);
while let Some((line_start, _)) = iter.next_line() {
if line_start > end_pos || line_start > buffer_len {
break;
}
if line_start == end_pos && line_start > start_pos {
break;
}
line_starts.push(line_start);
}
line_starts
}
fn calculate_leading_whitespace_removal(
buffer: &Buffer,
line_start: usize,
tab_size: usize,
) -> (usize, String) {
let buffer_len = buffer.len();
let line_bytes = buffer.slice_bytes(line_start..buffer_len.min(line_start + tab_size + 1));
if !line_bytes.is_empty() && line_bytes[0] == b'\t' {
(1, "\t".to_string())
} else {
let spaces_to_remove = line_bytes
.iter()
.take(tab_size)
.take_while(|&&b| b == b' ')
.count();
(spaces_to_remove, " ".repeat(spaces_to_remove))
}
}
fn add_move_cursor_event(
events: &mut Vec<Event>,
cursor_id: CursorId,
old_position: usize,
new_position: usize,
old_anchor: Option<usize>,
new_anchor: Option<usize>,
old_sticky_column: usize,
) {
events.push(Event::MoveCursor {
cursor_id,
old_position,
new_position,
old_anchor,
new_anchor,
old_sticky_column,
new_sticky_column: 0,
});
}
fn move_each_cursor(
cursors: &Cursors,
events: &mut Vec<Event>,
mut new_pos_fn: impl FnMut(&Cursor) -> usize,
) {
for (cursor_id, cursor) in cursors.iter() {
let new_pos = new_pos_fn(cursor);
let new_anchor = if cursor.deselect_on_move {
None
} else {
cursor.anchor
};
add_move_cursor_event(
events,
cursor_id,
cursor.position,
new_pos,
cursor.anchor,
new_anchor,
cursor.sticky_column,
);
}
}
fn select_each_cursor(
cursors: &Cursors,
events: &mut Vec<Event>,
mut new_pos_fn: impl FnMut(&Cursor) -> usize,
) {
for (cursor_id, cursor) in cursors.iter() {
let new_pos = new_pos_fn(cursor);
let anchor = cursor.anchor.unwrap_or(cursor.position);
add_move_cursor_event(
events,
cursor_id,
cursor.position,
new_pos,
cursor.anchor,
Some(anchor),
cursor.sticky_column,
);
}
}
fn block_select_action(
state: &mut EditorState,
cursors: &mut Cursors,
events: &mut Vec<Event>,
direction: BlockDirection,
) {
let total_lines = {
let len = state.buffer.len();
if len == 0 {
1
} else {
state.buffer.get_line_number(len.saturating_sub(1)) + 1
}
};
for (cursor_id, cursor) in cursors.iter() {
let current_2d = byte_to_2d(&state.buffer, cursor.position);
let block_anchor =
if cursor.selection_mode != SelectionMode::Block || cursor.block_anchor.is_none() {
current_2d
} else {
cursor.block_anchor.unwrap()
};
let new_2d = match direction {
BlockDirection::Left => Position2D {
line: current_2d.line,
column: current_2d.column.saturating_sub(1),
},
BlockDirection::Right => {
let line_content = state.buffer.get_line(current_2d.line).unwrap_or_default();
let line_len = if line_content.last() == Some(&b'\n') {
line_content.len().saturating_sub(1)
} else {
line_content.len()
};
Position2D {
line: current_2d.line,
column: (current_2d.column + 1).min(line_len),
}
}
BlockDirection::Up => {
if current_2d.line > 0 {
Position2D {
line: current_2d.line - 1,
column: current_2d.column,
}
} else {
current_2d
}
}
BlockDirection::Down => {
if current_2d.line + 1 < total_lines {
Position2D {
line: current_2d.line + 1,
column: current_2d.column,
}
} else {
current_2d
}
}
};
let new_byte_pos = pos_2d_to_byte(&state.buffer, new_2d);
let byte_anchor = pos_2d_to_byte(&state.buffer, block_anchor);
events.push(Event::MoveCursor {
cursor_id,
old_position: cursor.position,
new_position: new_byte_pos,
old_anchor: cursor.anchor,
new_anchor: Some(byte_anchor),
old_sticky_column: cursor.sticky_column,
new_sticky_column: new_2d.column,
});
}
let buffer_ref = &state.buffer;
cursors.map(|cursor| {
if cursor.selection_mode != SelectionMode::Block || cursor.block_anchor.is_none() {
let current_2d = byte_to_2d(buffer_ref, cursor.position);
cursor.start_block_selection(current_2d.line, current_2d.column);
}
});
}
pub fn clear_block_selection_if_active(cursors: &mut Cursors) {
cursors.map(|cursor| {
if cursor.selection_mode == SelectionMode::Block {
cursor.clear_block_selection();
}
});
}
fn convert_block_selection_to_cursors(
state: &mut EditorState,
cursors: &mut Cursors,
) -> Vec<Event> {
let mut events = Vec::new();
let block_info: Option<(CursorId, Position2D, Position2D)> =
cursors.iter().find_map(|(cursor_id, cursor)| {
if cursor.has_block_selection() {
let block_anchor = cursor.block_anchor?;
let cursor_2d = byte_to_2d(&state.buffer, cursor.position);
Some((cursor_id, block_anchor, cursor_2d))
} else {
None
}
});
let Some((primary_cursor_id, block_anchor, cursor_2d)) = block_info else {
return events;
};
let min_line = block_anchor.line.min(cursor_2d.line);
let max_line = block_anchor.line.max(cursor_2d.line);
let min_col = block_anchor.column.min(cursor_2d.column);
let max_col = block_anchor.column.max(cursor_2d.column);
let mut cursor_positions: Vec<(usize, usize)> = Vec::new();
for line in min_line..=max_line {
let line_start = state.buffer.line_start_offset(line).unwrap_or(0);
let line_content = state.buffer.get_line(line).unwrap_or_default();
let line_len = if line_content.last() == Some(&b'\n') {
line_content.len().saturating_sub(1)
} else {
line_content.len()
};
let actual_min_col = min_col.min(line_len);
let actual_max_col = max_col.min(line_len);
let anchor = line_start + actual_min_col;
let position = line_start + actual_max_col;
cursor_positions.push((position, anchor));
}
if let Some((position, anchor)) = cursor_positions.first().copied() {
if let Some(cursor) = cursors.get_mut(primary_cursor_id) {
cursor.position = position;
cursor.anchor = if position != anchor {
Some(anchor)
} else {
None
};
cursor.clear_block_selection();
}
}
let mut next_cursor_id = cursors.count();
for (position, anchor) in cursor_positions.into_iter().skip(1) {
let cursor_id = CursorId(next_cursor_id);
next_cursor_id += 1;
events.push(Event::AddCursor {
cursor_id,
position,
anchor: if position != anchor {
Some(anchor)
} else {
None
},
});
}
events
}
pub fn get_auto_close_char(ch: char, auto_close: bool, language: &str) -> Option<char> {
if !auto_close {
return None;
}
if language == "text" && matches!(ch, '"' | '\'' | '`') {
return None;
}
if matches!(language, "markdown" | "mdx") && ch == '\'' {
return None;
}
match ch {
'(' => Some(')'),
'[' => Some(']'),
'{' => Some('}'),
'"' => Some('"'),
'\'' => Some('\''),
'`' => Some('`'),
_ => None,
}
}
fn calculate_closing_delimiter_indent(
state: &mut EditorState,
insert_position: usize,
ch: char,
tab_size: usize,
) -> usize {
if let Some(language) = state.highlighter.language() {
state
.indent_calculator
.borrow_mut()
.calculate_dedent_for_delimiter(&state.buffer, insert_position, ch, language, tab_size)
.unwrap_or(0)
} else {
PatternIndentCalculator::calculate_dedent_for_delimiter(
&state.buffer,
insert_position,
ch,
tab_size,
)
.unwrap_or(0)
}
}
fn indent_to_string(indent_width: usize, use_tabs: bool, tab_size: usize) -> String {
if use_tabs && tab_size > 0 {
let num_tabs = indent_width / tab_size;
let remaining_spaces = indent_width % tab_size;
let mut result = "\t".repeat(num_tabs);
if remaining_spaces > 0 {
result.push_str(&" ".repeat(remaining_spaces));
}
result
} else {
" ".repeat(indent_width)
}
}
fn handle_skip_over_with_dedent(
state: &mut EditorState,
events: &mut Vec<Event>,
cursor_id: CursorId,
ch: char,
insert_position: usize,
line_start: usize,
tab_size: usize,
) -> bool {
let correct_indent = calculate_closing_delimiter_indent(state, insert_position, ch, tab_size);
let use_tabs = state.buffer_settings.use_tabs;
let mut current_visual_indent = 0;
let mut pos = line_start;
while pos < insert_position {
match state.buffer.slice_bytes(pos..pos + 1).first() {
Some(&b' ') => current_visual_indent += 1,
Some(&b'\t') => current_visual_indent += tab_size,
_ => break,
}
pos += 1;
}
if current_visual_indent != correct_indent {
let deleted_text = state.get_text_range(line_start, insert_position);
events.push(Event::Delete {
range: line_start..insert_position,
deleted_text,
cursor_id,
});
let indent_str = indent_to_string(correct_indent, use_tabs, tab_size);
let indent_byte_len = indent_str.len();
if indent_byte_len > 0 {
events.push(Event::Insert {
position: line_start,
text: indent_str,
cursor_id,
});
}
events.push(Event::MoveCursor {
cursor_id,
old_position: line_start + indent_byte_len,
new_position: line_start + indent_byte_len + 1,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
});
return true;
}
false
}
fn handle_skip_over(events: &mut Vec<Event>, cursor_id: CursorId, insert_position: usize) {
events.push(Event::MoveCursor {
cursor_id,
old_position: insert_position,
new_position: insert_position + 1,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
});
}
fn handle_auto_dedent(
state: &mut EditorState,
events: &mut Vec<Event>,
cursor_id: CursorId,
ch: char,
insert_position: usize,
line_start: usize,
tab_size: usize,
) {
let correct_indent = calculate_closing_delimiter_indent(state, insert_position, ch, tab_size);
let spaces_to_delete = insert_position - line_start;
if spaces_to_delete > 0 {
let deleted_text = state.get_text_range(line_start, insert_position);
events.push(Event::Delete {
range: line_start..insert_position,
deleted_text,
cursor_id,
});
}
let use_tabs = state.buffer_settings.use_tabs;
let mut text = indent_to_string(correct_indent, use_tabs, tab_size);
text.push(ch);
events.push(Event::Insert {
position: line_start,
text,
cursor_id,
});
}
fn should_auto_close(char_after: Option<u8>) -> bool {
let is_alphanumeric_after = char_after
.map(|b| b.is_ascii_alphanumeric() || b == b'_')
.unwrap_or(false);
!is_alphanumeric_after
}
fn handle_auto_close(
events: &mut Vec<Event>,
cursor_id: CursorId,
ch: char,
close_char: char,
insert_position: usize,
) {
let text = format!("{}{}", ch, close_char);
events.push(Event::Insert {
position: insert_position,
text,
cursor_id,
});
events.push(Event::MoveCursor {
cursor_id,
old_position: insert_position + 2,
new_position: insert_position + 1,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
});
}
struct InsertCursorData {
cursor_id: CursorId,
selection: Option<Range<usize>>,
insert_position: usize,
line_start: usize,
only_spaces: bool,
char_after: Option<u8>,
deleted_text: Option<String>,
}
fn collect_insert_cursor_data(state: &mut EditorState, cursors: &Cursors) -> Vec<InsertCursorData> {
let mut cursor_vec: Vec<_> = cursors.iter().collect();
cursor_vec.sort_by_key(|(_, c)| {
let insert_pos = c.selection_range().map(|r| r.start).unwrap_or(c.position);
std::cmp::Reverse(insert_pos)
});
let cursor_info: Vec<_> = cursor_vec
.iter()
.map(|(cursor_id, cursor)| {
let selection = cursor.selection_range();
let insert_position = selection
.as_ref()
.map(|r| r.start)
.unwrap_or(cursor.position);
(*cursor_id, selection, insert_position)
})
.collect();
drop(cursor_vec);
cursor_info
.into_iter()
.map(|(cursor_id, selection, insert_position)| {
let mut line_start = insert_position;
while line_start > 0 {
let prev = line_start - 1;
if state.buffer.slice_bytes(prev..prev + 1).first() == Some(&b'\n') {
break;
}
line_start = prev;
}
let line_before_cursor = state.buffer.slice_bytes(line_start..insert_position);
let only_spaces = line_before_cursor.iter().all(|&b| b == b' ' || b == b'\t');
let check_pos = selection.as_ref().map(|r| r.end).unwrap_or(insert_position);
let char_after = if check_pos < state.buffer.len() {
state
.buffer
.slice_bytes(check_pos..check_pos + 1)
.first()
.copied()
} else {
None
};
let deleted_text = selection
.as_ref()
.map(|r| state.get_text_range(r.start, r.end));
InsertCursorData {
cursor_id,
selection,
insert_position,
line_start,
only_spaces,
char_after,
deleted_text,
}
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn insert_char_events(
state: &mut EditorState,
cursors: &Cursors,
events: &mut Vec<Event>,
ch: char,
tab_size: usize,
auto_indent: bool,
auto_close: bool,
auto_surround: bool,
) {
let is_closing_delimiter = matches!(ch, '}' | ')' | ']');
let auto_close_char = get_auto_close_char(ch, auto_close, &state.language);
let cursor_data = collect_insert_cursor_data(state, cursors);
for data in cursor_data {
if auto_surround {
if let Some(close_char) = auto_close_char {
if let (Some(range), Some(_)) = (&data.selection, &data.deleted_text) {
let sel_start = range.start;
let sel_end = range.end;
events.push(Event::Insert {
position: sel_end,
text: close_char.to_string(),
cursor_id: data.cursor_id,
});
events.push(Event::Insert {
position: sel_start,
text: ch.to_string(),
cursor_id: data.cursor_id,
});
events.push(Event::MoveCursor {
cursor_id: data.cursor_id,
old_position: sel_end + 2,
new_position: sel_end + 2,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
});
continue;
}
}
}
if let (Some(range), Some(text)) = (data.selection, data.deleted_text) {
events.push(Event::Delete {
range,
deleted_text: text,
cursor_id: data.cursor_id,
});
}
let skip_single_quote = ch == '\'' && matches!(state.language.as_str(), "markdown" | "mdx");
if auto_close && matches!(ch, ')' | ']' | '}' | '"' | '\'' | '`') && !skip_single_quote {
if let Some(next_byte) = data.char_after {
if next_byte == ch as u8 {
if is_closing_delimiter
&& data.only_spaces
&& data.insert_position > data.line_start
&& handle_skip_over_with_dedent(
state,
events,
data.cursor_id,
ch,
data.insert_position,
data.line_start,
tab_size,
)
{
continue;
}
handle_skip_over(events, data.cursor_id, data.insert_position);
continue;
}
}
}
if is_closing_delimiter
&& auto_indent
&& data.only_spaces
&& data.insert_position > data.line_start
{
handle_auto_dedent(
state,
events,
data.cursor_id,
ch,
data.insert_position,
data.line_start,
tab_size,
);
continue;
}
if let Some(close_char) = auto_close_char {
let suppress_quote_in_string = matches!(ch, '"' | '\'' | '`')
&& state.highlighter.category_at_position(data.insert_position)
== Some(HighlightCategory::String);
if !suppress_quote_in_string && should_auto_close(data.char_after) {
handle_auto_close(events, data.cursor_id, ch, close_char, data.insert_position);
continue;
}
}
events.push(Event::Insert {
position: data.insert_position,
text: ch.to_string(),
cursor_id: data.cursor_id,
});
}
}
fn max_cursor_position(buffer: &Buffer) -> usize {
buffer.len()
}
fn transform_case<F>(
state: &mut EditorState,
cursors: &mut Cursors,
events: &mut Vec<Event>,
transform: F,
) where
F: Fn(&str) -> String,
{
let mut selections: Vec<_> = cursors
.iter()
.map(|(cursor_id, cursor)| {
if let Some(range) = cursor.selection_range() {
(cursor_id, range.start, range.end)
} else {
let word_start = find_word_start(&state.buffer, cursor.position);
let word_end = find_word_end(&state.buffer, word_start);
(cursor_id, word_start, word_end)
}
})
.filter(|(_, start, end)| start < end)
.collect();
selections.sort_by_key(|(_, start, _)| std::cmp::Reverse(*start));
for (cursor_id, start, end) in selections {
let text = state.get_text_range(start, end);
let transformed = transform(&text);
if transformed != text {
events.push(Event::Delete {
range: start..end,
deleted_text: text,
cursor_id,
});
events.push(Event::Insert {
position: start,
text: transformed,
cursor_id,
});
}
}
}
fn handle_insert_newline(
state: &mut EditorState,
cursors: &Cursors,
events: &mut Vec<Event>,
tab_size: usize,
auto_indent: bool,
auto_close: bool,
_estimated_line_length: usize,
) {
let mut cursor_vec: Vec<_> = cursors.iter().collect();
cursor_vec.sort_by_key(|(_, c)| std::cmp::Reverse(c.position));
let deletions: Vec<_> = cursor_vec
.iter()
.filter_map(|(cursor_id, cursor)| {
cursor
.selection_range()
.map(|range| (*cursor_id, range.clone(), range.start))
})
.collect();
let indent_positions: Vec<_> = cursor_vec
.iter()
.map(|(cursor_id, cursor)| {
let indent_position = cursor
.selection_range()
.map(|r| r.start)
.unwrap_or(cursor.position);
(*cursor_id, indent_position)
})
.collect();
for (cursor_id, range, _start) in deletions {
let deleted_text = state.get_text_range(range.start, range.end);
events.push(Event::Delete {
range,
deleted_text,
cursor_id,
});
}
let line_ending = state.buffer.line_ending().as_str();
for (cursor_id, indent_position) in indent_positions {
let mut text = line_ending.to_string();
let bracket_expansion = if auto_close && indent_position > 0 {
let char_before = state
.buffer
.slice_bytes(indent_position.saturating_sub(1)..indent_position)
.first()
.copied();
let char_after = if indent_position < state.buffer.len() {
state
.buffer
.slice_bytes(indent_position..indent_position + 1)
.first()
.copied()
} else {
None
};
matches!(
(char_before, char_after),
(Some(b'('), Some(b')')) | (Some(b'['), Some(b']')) | (Some(b'{'), Some(b'}'))
)
} else {
false
};
let mut cursor_line_end_position: Option<usize> = None;
if auto_indent {
let use_tabs = state.buffer_settings.use_tabs;
let indent_width_opt = match state.highlighter.language() {
Some(language) => state.indent_calculator.borrow_mut().calculate_indent(
&state.buffer,
indent_position,
language,
tab_size,
),
None => Some(
crate::primitives::indent::IndentCalculator::calculate_indent_no_language(
&state.buffer,
indent_position,
tab_size,
),
),
};
if let Some(indent_width) = indent_width_opt {
let indent_str = indent_to_string(indent_width, use_tabs, tab_size);
text.push_str(&indent_str);
if bracket_expansion {
cursor_line_end_position =
Some(indent_position + line_ending.len() + indent_str.len());
let opening_bracket_indent =
crate::primitives::indent::IndentCalculator::get_line_indent_at_position(
&state.buffer,
indent_position.saturating_sub(1),
tab_size,
);
text.push_str(line_ending);
text.push_str(&indent_to_string(
opening_bracket_indent,
use_tabs,
tab_size,
));
}
}
}
let cursor_after_insert = indent_position + text.len();
events.push(Event::Insert {
position: indent_position,
text,
cursor_id,
});
if let Some(cursor_line_end) = cursor_line_end_position {
if let Some(cursor) = cursors.get(cursor_id) {
events.push(Event::MoveCursor {
cursor_id,
old_position: cursor_after_insert,
new_position: cursor_line_end,
old_anchor: None, new_anchor: None,
old_sticky_column: cursor.sticky_column,
new_sticky_column: cursor_line_end, });
}
}
}
}
fn handle_dedent_selection(
state: &mut EditorState,
cursors: &Cursors,
events: &mut Vec<Event>,
tab_size: usize,
estimated_line_length: usize,
) {
use std::collections::BTreeMap;
let mut all_line_deletions: BTreeMap<usize, (usize, String)> = BTreeMap::new();
let mut cursor_info = Vec::new();
for (cursor_id, cursor) in cursors.iter() {
let has_selection = cursor.selection_range().is_some();
let (start_pos, end_pos) = if let Some(range) = cursor.selection_range() {
(range.start, range.end)
} else {
let iter = state
.buffer
.line_iterator(cursor.position, estimated_line_length);
let line_start = iter.current_position();
(line_start, cursor.position)
};
let line_starts =
collect_line_starts(&mut state.buffer, start_pos, end_pos, estimated_line_length);
for &line_start in &line_starts {
if let std::collections::btree_map::Entry::Vacant(e) =
all_line_deletions.entry(line_start)
{
let (chars_to_remove, deleted_text) =
calculate_leading_whitespace_removal(&state.buffer, line_start, tab_size);
if chars_to_remove > 0 {
e.insert((chars_to_remove, deleted_text));
}
}
}
cursor_info.push((
cursor_id,
cursor.position,
cursor.anchor,
cursor.sticky_column,
has_selection,
start_pos,
end_pos,
));
}
let first_cursor_id = cursors.iter().next().unwrap().0;
for (&line_start, (chars_to_remove, deleted_text)) in all_line_deletions.iter().rev() {
events.push(Event::Delete {
range: line_start..line_start + chars_to_remove,
deleted_text: deleted_text.clone(),
cursor_id: first_cursor_id,
});
}
for (
cursor_id,
old_position,
old_anchor,
old_sticky_column,
has_selection,
start_pos,
end_pos,
) in cursor_info
{
let mut removed_before_start = 0;
let mut removed_before_end = 0;
let mut removed_before_position = 0;
for (&line_start, &(chars_to_remove, _)) in &all_line_deletions {
if line_start < start_pos {
removed_before_start += chars_to_remove;
}
if line_start <= end_pos {
removed_before_end += chars_to_remove;
}
if line_start < old_position {
removed_before_position += chars_to_remove;
}
}
if has_selection {
let new_anchor = start_pos.saturating_sub(removed_before_start);
let new_position = end_pos.saturating_sub(removed_before_end);
add_move_cursor_event(
events,
cursor_id,
old_position,
new_position,
old_anchor,
Some(new_anchor),
old_sticky_column,
);
} else {
let new_position = old_position.saturating_sub(removed_before_position);
add_move_cursor_event(
events,
cursor_id,
old_position,
new_position,
old_anchor,
None,
old_sticky_column,
);
}
}
}
fn handle_insert_tab(
state: &mut EditorState,
cursors: &Cursors,
events: &mut Vec<Event>,
tab_size: usize,
estimated_line_length: usize,
) {
let tab_str = if state.buffer_settings.use_tabs {
"\t".to_string()
} else {
" ".repeat(tab_size)
};
let has_selection = cursors
.iter()
.any(|(_, cursor)| cursor.selection_range().is_some());
if has_selection {
use std::collections::BTreeSet;
let mut all_line_starts = BTreeSet::new();
let mut cursor_info = Vec::new();
for (cursor_id, cursor) in cursors.iter() {
if let Some(range) = cursor.selection_range() {
let (start_pos, end_pos) = (range.start, range.end);
let line_starts = collect_line_starts(
&mut state.buffer,
start_pos,
end_pos,
estimated_line_length,
);
all_line_starts.extend(line_starts.iter());
cursor_info.push((
cursor_id,
cursor.position,
cursor.anchor,
cursor.sticky_column,
start_pos,
end_pos,
));
}
}
let first_cursor_id = cursors.iter().next().unwrap().0;
for &line_start in all_line_starts.iter().rev() {
events.push(Event::Insert {
position: line_start,
text: tab_str.clone(),
cursor_id: first_cursor_id,
});
}
let indent_len = tab_str.len();
for (cursor_id, old_position, old_anchor, old_sticky_column, start_pos, end_pos) in
cursor_info
{
let indents_at_or_before_anchor = all_line_starts
.iter()
.filter(|&&pos| pos <= start_pos)
.count();
let indents_before_position =
all_line_starts.iter().filter(|&&pos| pos < end_pos).count();
let new_anchor = start_pos + (indents_at_or_before_anchor * indent_len);
let new_position = end_pos + (indents_before_position * indent_len);
add_move_cursor_event(
events,
cursor_id,
old_position,
new_position,
old_anchor,
Some(new_anchor),
old_sticky_column,
);
}
} else {
let mut cursor_vec: Vec<_> = cursors.iter().collect();
cursor_vec.sort_by_key(|(_, c)| std::cmp::Reverse(c.position));
for (cursor_id, cursor) in cursor_vec {
events.push(Event::Insert {
position: cursor.position,
text: tab_str.clone(),
cursor_id,
});
}
}
}
fn handle_move_up(
state: &mut EditorState,
cursors: &Cursors,
events: &mut Vec<Event>,
estimated_line_length: usize,
) {
for (cursor_id, cursor) in cursors.iter() {
let from_pos = if cursor.deselect_on_move {
cursor
.selection_range()
.map(|r| r.start)
.unwrap_or(cursor.position)
} else {
cursor.position
};
let (current_visual_column, _) =
calculate_visual_column(&mut state.buffer, from_pos, estimated_line_length);
let goal_visual_column = if cursor.sticky_column > 0 {
cursor.sticky_column
} else {
current_visual_column
};
let mut iter = state.buffer.line_iterator(from_pos, estimated_line_length);
if let Some((prev_line_start, prev_line_content)) = iter.prev() {
let prev_line_text = prev_line_content.trim_end_matches('\n');
let byte_offset = byte_offset_at_visual_column(prev_line_text, goal_visual_column);
let new_pos = prev_line_start + byte_offset;
let new_anchor = if cursor.deselect_on_move {
None
} else {
cursor.anchor
};
events.push(Event::MoveCursor {
cursor_id,
old_position: cursor.position,
new_position: new_pos,
old_anchor: cursor.anchor,
new_anchor,
old_sticky_column: cursor.sticky_column,
new_sticky_column: goal_visual_column, });
}
}
}
fn handle_move_down(
state: &mut EditorState,
cursors: &Cursors,
events: &mut Vec<Event>,
estimated_line_length: usize,
) {
for (cursor_id, cursor) in cursors.iter() {
let from_pos = if cursor.deselect_on_move {
cursor
.selection_range()
.map(|r| r.end)
.unwrap_or(cursor.position)
} else {
cursor.position
};
let (current_visual_column, _) =
calculate_visual_column(&mut state.buffer, from_pos, estimated_line_length);
let goal_visual_column = if cursor.sticky_column > 0 {
cursor.sticky_column
} else {
current_visual_column
};
let mut iter = state.buffer.line_iterator(from_pos, estimated_line_length);
iter.next_line();
if let Some((next_line_start, next_line_content)) = iter.next_line() {
let next_line_text = next_line_content.trim_end_matches('\n');
let byte_offset = byte_offset_at_visual_column(next_line_text, goal_visual_column);
let new_pos = next_line_start + byte_offset;
let new_anchor = if cursor.deselect_on_move {
None
} else {
cursor.anchor
};
events.push(Event::MoveCursor {
cursor_id,
old_position: cursor.position,
new_position: new_pos,
old_anchor: cursor.anchor,
new_anchor,
old_sticky_column: cursor.sticky_column,
new_sticky_column: goal_visual_column, });
}
}
}
fn handle_move_page_up(
state: &mut EditorState,
cursors: &Cursors,
events: &mut Vec<Event>,
viewport_height: u16,
estimated_line_length: usize,
) {
for (cursor_id, cursor) in cursors.iter() {
let lines_to_move = viewport_height.saturating_sub(1) as usize;
let mut iter = state
.buffer
.line_iterator(cursor.position, estimated_line_length);
let current_line_start = iter.current_position();
let current_column = cursor.position - current_line_start;
let goal_column = if cursor.sticky_column > 0 {
cursor.sticky_column
} else {
current_column
};
let mut new_pos = cursor.position;
for _ in 0..lines_to_move {
if let Some((line_start, line_content)) = iter.prev() {
let line_len = line_content.trim_end_matches('\n').len();
new_pos = line_start + goal_column.min(line_len);
} else {
new_pos = 0;
break;
}
}
let new_anchor = if cursor.deselect_on_move {
None
} else {
cursor.anchor
};
events.push(Event::MoveCursor {
cursor_id,
old_position: cursor.position,
new_position: new_pos,
old_anchor: cursor.anchor,
new_anchor,
old_sticky_column: cursor.sticky_column,
new_sticky_column: goal_column, });
}
}
fn handle_move_page_down(
state: &mut EditorState,
cursors: &Cursors,
events: &mut Vec<Event>,
viewport_height: u16,
estimated_line_length: usize,
) {
for (cursor_id, cursor) in cursors.iter() {
let lines_to_move = viewport_height.saturating_sub(1) as usize;
let mut iter = state
.buffer
.line_iterator(cursor.position, estimated_line_length);
let current_line_start = iter.current_position();
let current_column = cursor.position - current_line_start;
let goal_column = if cursor.sticky_column > 0 {
cursor.sticky_column
} else {
current_column
};
iter.next_line();
let mut new_pos = cursor.position;
for _ in 0..lines_to_move {
if let Some((line_start, line_content)) = iter.next_line() {
let line_len = line_content.trim_end_matches('\n').len();
new_pos = line_start + goal_column.min(line_len);
} else {
new_pos = max_cursor_position(&state.buffer);
break;
}
}
let new_anchor = if cursor.deselect_on_move {
None
} else {
cursor.anchor
};
events.push(Event::MoveCursor {
cursor_id,
old_position: cursor.position,
new_position: new_pos,
old_anchor: cursor.anchor,
new_anchor,
old_sticky_column: cursor.sticky_column,
new_sticky_column: goal_column, });
}
}
fn handle_select_page_up(
state: &mut EditorState,
cursors: &Cursors,
events: &mut Vec<Event>,
viewport_height: u16,
estimated_line_length: usize,
) {
for (cursor_id, cursor) in cursors.iter() {
let lines_to_move = viewport_height.saturating_sub(1) as usize;
let mut iter = state
.buffer
.line_iterator(cursor.position, estimated_line_length);
let current_line_start = iter.current_position();
let current_column = cursor.position - current_line_start;
let anchor = cursor.anchor.unwrap_or(cursor.position);
let goal_column = if cursor.sticky_column > 0 {
cursor.sticky_column
} else {
current_column
};
let mut new_pos = cursor.position;
for _ in 0..lines_to_move {
if let Some((line_start, line_content)) = iter.prev() {
let line_len = line_content.trim_end_matches('\n').len();
new_pos = line_start + goal_column.min(line_len);
} else {
new_pos = 0;
break;
}
}
events.push(Event::MoveCursor {
cursor_id,
old_position: cursor.position,
new_position: new_pos,
old_anchor: cursor.anchor,
new_anchor: Some(anchor),
old_sticky_column: cursor.sticky_column,
new_sticky_column: goal_column, });
}
}
fn handle_select_page_down(
state: &mut EditorState,
cursors: &Cursors,
events: &mut Vec<Event>,
viewport_height: u16,
estimated_line_length: usize,
) {
for (cursor_id, cursor) in cursors.iter() {
let lines_to_move = viewport_height.saturating_sub(1) as usize;
let mut iter = state
.buffer
.line_iterator(cursor.position, estimated_line_length);
let current_line_start = iter.current_position();
let current_column = cursor.position - current_line_start;
let anchor = cursor.anchor.unwrap_or(cursor.position);
let goal_column = if cursor.sticky_column > 0 {
cursor.sticky_column
} else {
current_column
};
iter.next_line();
let mut new_pos = cursor.position;
for _ in 0..lines_to_move {
if let Some((line_start, line_content)) = iter.next_line() {
let line_len = line_content.trim_end_matches('\n').len();
new_pos = line_start + goal_column.min(line_len);
} else {
new_pos = max_cursor_position(&state.buffer);
break;
}
}
events.push(Event::MoveCursor {
cursor_id,
old_position: cursor.position,
new_position: new_pos,
old_anchor: cursor.anchor,
new_anchor: Some(anchor),
old_sticky_column: cursor.sticky_column,
new_sticky_column: goal_column, });
}
}
fn handle_delete_backward(
state: &mut EditorState,
cursors: &Cursors,
events: &mut Vec<Event>,
tab_size: usize,
auto_close: bool,
estimated_line_length: usize,
) {
let mut cursor_vec: Vec<_> = cursors.iter().collect();
cursor_vec.sort_by_key(|(_, c)| std::cmp::Reverse(c.position));
let deletions: Vec<_> = cursor_vec
.iter()
.filter_map(|(cursor_id, cursor)| {
if let Some(range) = cursor.selection_range() {
Some((*cursor_id, range))
} else if cursor.position > 0 {
let iter = state
.buffer
.line_iterator(cursor.position, estimated_line_length);
let line_start = iter.current_position();
let prefix_len = cursor.position - line_start;
if prefix_len > 0 {
let prefix_bytes = state.buffer.slice_bytes(line_start..cursor.position);
let all_whitespace = prefix_bytes.iter().all(|&b| b == b' ' || b == b'\t');
if all_whitespace && !prefix_bytes.is_empty() {
let last_byte = *prefix_bytes.last().unwrap();
let chars_to_remove = if last_byte == b'\t' {
1
} else {
let trailing_spaces = prefix_bytes
.iter()
.rev()
.take_while(|&&b| b == b' ')
.count();
trailing_spaces.min(tab_size)
};
if chars_to_remove > 0 {
return Some((
*cursor_id,
cursor.position - chars_to_remove..cursor.position,
));
}
}
}
let delete_from = state.buffer.prev_char_boundary(cursor.position);
let delete_from = adjust_position_for_crlf_left(&state.buffer, delete_from);
if auto_close && cursor.position < state.buffer.len() {
let char_before = state
.buffer
.slice_bytes(delete_from..cursor.position)
.first()
.copied();
let char_after = state
.buffer
.slice_bytes(cursor.position..cursor.position + 1)
.first()
.copied();
let is_matching_pair = matches!(
(char_before, char_after),
(Some(b'('), Some(b')'))
| (Some(b'['), Some(b']'))
| (Some(b'{'), Some(b'}'))
| (Some(b'"'), Some(b'"'))
| (Some(b'\''), Some(b'\''))
| (Some(b'`'), Some(b'`'))
);
if is_matching_pair {
Some((*cursor_id, delete_from..cursor.position + 1))
} else {
Some((*cursor_id, delete_from..cursor.position))
}
} else {
Some((*cursor_id, delete_from..cursor.position))
}
} else {
None
}
})
.collect();
apply_deletions(state, deletions, events);
}
fn handle_toggle_case(state: &mut EditorState, cursors: &Cursors, events: &mut Vec<Event>) {
for (cursor_id, cursor) in cursors.iter() {
let pos = cursor.position;
let buf_len = state.buffer.len();
if pos >= buf_len {
continue;
}
let next_pos = state.buffer.next_grapheme_boundary(pos);
if next_pos <= pos || next_pos > buf_len {
continue;
}
let text = state.get_text_range(pos, next_pos);
if text.is_empty() || text == "\n" || text == "\r\n" {
continue;
}
let toggled: String = text
.chars()
.map(|c| {
if c.is_uppercase() {
c.to_lowercase().to_string()
} else {
c.to_uppercase().to_string()
}
})
.collect();
if toggled != text {
events.push(Event::Delete {
range: pos..next_pos,
deleted_text: text,
cursor_id,
});
events.push(Event::Insert {
position: pos,
text: toggled,
cursor_id,
});
}
let advance_pos = next_pos.min(buf_len);
events.push(Event::MoveCursor {
cursor_id,
old_position: pos,
new_position: advance_pos,
old_anchor: cursor.anchor,
new_anchor: None,
old_sticky_column: cursor.sticky_column,
new_sticky_column: 0,
});
}
}
fn handle_sort_lines(state: &mut EditorState, cursors: &Cursors, events: &mut Vec<Event>) {
let line_ending = state.buffer.line_ending().as_str();
let mut selections: Vec<_> = cursors
.iter()
.filter_map(|(cursor_id, cursor)| cursor.selection_range().map(|range| (cursor_id, range)))
.collect();
selections.sort_by_key(|(_, range)| std::cmp::Reverse(range.start));
for (cursor_id, range) in selections {
let text = state.get_text_range(range.start, range.end);
let mut lines: Vec<&str> = text.lines().collect();
let ends_with_newline = text.ends_with('\n') || text.ends_with("\r\n");
if lines.len() > 1 {
lines.sort();
let mut sorted_text = lines.join(line_ending);
if ends_with_newline {
sorted_text.push_str(line_ending);
}
if sorted_text != text {
events.push(Event::Delete {
range: range.clone(),
deleted_text: text,
cursor_id,
});
events.push(Event::Insert {
position: range.start,
text: sorted_text,
cursor_id,
});
}
}
}
}
fn handle_duplicate_line(
state: &mut EditorState,
cursors: &Cursors,
events: &mut Vec<Event>,
estimated_line_length: usize,
) {
let mut cursor_data: Vec<_> = cursors
.iter()
.filter_map(|(cursor_id, cursor)| {
if let Some(range) = cursor.selection_range() {
let start_line = state.buffer.get_line_number(range.start);
let end_line = state
.buffer
.get_line_number(range.end.saturating_sub(1).max(range.start));
let line_start = state.buffer.line_start_offset(start_line)?;
let mut iter = state.buffer.line_iterator(
state.buffer.line_start_offset(end_line)?,
estimated_line_length,
);
let end_line_start = iter.current_position();
iter.next_line().map(|(_, content)| {
let line_end = end_line_start + content.len();
(cursor_id, line_start, line_end)
})
} else {
let mut iter = state
.buffer
.line_iterator(cursor.position, estimated_line_length);
let line_start = iter.current_position();
iter.next_line().map(|(_, content)| {
let line_end = line_start + content.len();
(cursor_id, line_start, line_end)
})
}
})
.collect();
cursor_data.sort_by_key(|(_, start, _)| std::cmp::Reverse(*start));
for (cursor_id, line_start, line_end) in cursor_data {
let line_text = state.get_text_range(line_start, line_end);
let line_ending = state.buffer.line_ending().as_str();
let has_trailing_newline = line_text.ends_with('\n') || line_text.ends_with("\r\n");
let insert_text = if has_trailing_newline {
line_text
} else {
format!("{}{}", line_ending, line_text)
};
let insert_len = insert_text.len();
events.push(Event::Insert {
position: line_end,
text: insert_text,
cursor_id,
});
let new_line_start = if has_trailing_newline {
line_end
} else {
line_end + line_ending.len()
};
let cursor = cursors.get(cursor_id);
let old_sticky = cursor.map(|c| c.sticky_column).unwrap_or(0);
events.push(Event::MoveCursor {
cursor_id,
old_position: line_end + insert_len,
new_position: new_line_start,
old_anchor: None,
new_anchor: None,
old_sticky_column: old_sticky,
new_sticky_column: 0,
});
}
}
#[allow(clippy::too_many_arguments)]
pub fn action_to_events(
state: &mut EditorState,
cursors: &mut Cursors,
action: Action,
tab_size: usize,
auto_indent: bool,
auto_close: bool,
auto_surround: bool,
estimated_line_length: usize,
viewport_height: u16,
) -> Option<Vec<Event>> {
if !state.show_cursors && action.is_movement_or_editing() {
return None;
}
let mut events = Vec::new();
if action.is_editing() {
let cursor_events = convert_block_selection_to_cursors(state, cursors);
for event in &cursor_events {
state.apply(cursors, event);
}
events.extend(cursor_events);
}
match action {
Action::InsertChar(ch) => {
insert_char_events(
state,
cursors,
&mut events,
ch,
tab_size,
auto_indent,
auto_close,
auto_surround,
);
}
Action::InsertNewline => {
handle_insert_newline(
state,
cursors,
&mut events,
tab_size,
auto_indent,
auto_close,
estimated_line_length,
);
}
Action::DedentSelection => {
handle_dedent_selection(state, cursors, &mut events, tab_size, estimated_line_length);
}
Action::InsertTab => {
handle_insert_tab(state, cursors, &mut events, tab_size, estimated_line_length);
}
Action::MoveLeft => {
move_each_cursor(cursors, &mut events, |c| {
if c.deselect_on_move {
if let Some(range) = c.selection_range() {
return range.start;
}
}
let p = state.buffer.prev_grapheme_boundary(c.position);
adjust_position_for_crlf_left(&state.buffer, p)
});
}
Action::MoveRight => {
let max_pos = max_cursor_position(&state.buffer);
move_each_cursor(cursors, &mut events, |c| {
if c.deselect_on_move {
if let Some(range) = c.selection_range() {
return range.end.min(max_pos);
}
}
next_position_for_crlf(&state.buffer, c.position, max_pos)
});
}
Action::MoveUp => {
handle_move_up(state, cursors, &mut events, estimated_line_length);
}
Action::MoveDown => {
handle_move_down(state, cursors, &mut events, estimated_line_length);
}
Action::MoveLineStart => {
move_each_cursor(cursors, &mut events, |c| {
state
.buffer
.line_iterator(c.position, estimated_line_length)
.next_line()
.map(|(ls, _)| ls)
.unwrap_or(c.position)
});
}
Action::MoveLineEnd => {
move_each_cursor(cursors, &mut events, |c| {
state
.buffer
.line_iterator(c.position, estimated_line_length)
.next_line()
.map(|(ls, lc)| ls + content_len_without_line_ending(&lc))
.unwrap_or(c.position)
});
}
Action::MoveWordLeft => {
move_each_cursor(cursors, &mut events, |c| {
find_word_start_left(&state.buffer, c.position)
});
}
Action::MoveWordRight => {
move_each_cursor(cursors, &mut events, |c| {
find_word_start_right(&state.buffer, c.position)
});
}
Action::MoveWordEnd => {
move_each_cursor(cursors, &mut events, |c| {
find_word_end_right(&state.buffer, c.position)
});
}
Action::ViMoveWordEnd => {
move_each_cursor(cursors, &mut events, |c| {
find_vi_word_end(&state.buffer, c.position)
});
}
Action::MoveLeftInLine => {
move_each_cursor(cursors, &mut events, |c| {
let new_pos = state.buffer.prev_grapheme_boundary(c.position);
let new_pos = adjust_position_for_crlf_left(&state.buffer, new_pos);
let mut iter = state
.buffer
.line_iterator(c.position, estimated_line_length);
let line_start = iter.next_line().map(|(ls, _)| ls).unwrap_or(0);
new_pos.max(line_start)
});
}
Action::MoveRightInLine => {
let max_pos = max_cursor_position(&state.buffer);
move_each_cursor(cursors, &mut events, |c| {
let new_pos = next_position_for_crlf(&state.buffer, c.position, max_pos);
let mut iter = state
.buffer
.line_iterator(c.position, estimated_line_length);
let line_last_char = iter
.next_line()
.map(|(ls, lc)| {
let content_len = content_len_without_line_ending(&lc);
if content_len > 0 {
ls + content_len - 1
} else {
ls
}
})
.unwrap_or(max_pos);
new_pos.min(line_last_char)
});
}
Action::MoveDocumentStart => {
move_each_cursor(cursors, &mut events, |_| 0);
}
Action::MoveDocumentEnd => {
let max_pos = max_cursor_position(&state.buffer);
move_each_cursor(cursors, &mut events, |_| max_pos);
}
Action::MovePageUp => {
handle_move_page_up(
state,
cursors,
&mut events,
viewport_height,
estimated_line_length,
);
}
Action::MovePageDown => {
handle_move_page_down(
state,
cursors,
&mut events,
viewport_height,
estimated_line_length,
);
}
Action::SelectLeft => {
select_each_cursor(cursors, &mut events, |c| {
let p = state.buffer.prev_grapheme_boundary(c.position);
adjust_position_for_crlf_left(&state.buffer, p)
});
}
Action::SelectRight => {
let max_pos = max_cursor_position(&state.buffer);
select_each_cursor(cursors, &mut events, |c| {
next_position_for_crlf(&state.buffer, c.position, max_pos)
});
}
Action::SelectUp => {
for (cursor_id, cursor) in cursors.iter() {
let mut iter = state
.buffer
.line_iterator(cursor.position, estimated_line_length);
let current_line_start = iter.current_position();
let current_column = cursor.position - current_line_start;
let anchor = cursor.anchor.unwrap_or(cursor.position);
let goal_column = if cursor.sticky_column > 0 {
cursor.sticky_column
} else {
current_column
};
if let Some((prev_line_start, prev_line_content)) = iter.prev() {
let prev_line_len = prev_line_content.trim_end_matches('\n').len();
let new_pos = prev_line_start + goal_column.min(prev_line_len);
events.push(Event::MoveCursor {
cursor_id,
old_position: cursor.position,
new_position: new_pos,
old_anchor: cursor.anchor,
new_anchor: Some(anchor),
old_sticky_column: cursor.sticky_column,
new_sticky_column: goal_column, });
}
}
}
Action::SelectDown => {
for (cursor_id, cursor) in cursors.iter() {
let mut iter = state
.buffer
.line_iterator(cursor.position, estimated_line_length);
let current_line_start = iter.current_position();
let current_column = cursor.position - current_line_start;
let anchor = cursor.anchor.unwrap_or(cursor.position);
let goal_column = if cursor.sticky_column > 0 {
cursor.sticky_column
} else {
current_column
};
iter.next_line();
if let Some((next_line_start, next_line_content)) = iter.next_line() {
let next_line_len = next_line_content.trim_end_matches('\n').len();
let new_pos = next_line_start + goal_column.min(next_line_len);
events.push(Event::MoveCursor {
cursor_id,
old_position: cursor.position,
new_position: new_pos,
old_anchor: cursor.anchor,
new_anchor: Some(anchor),
old_sticky_column: cursor.sticky_column,
new_sticky_column: goal_column, });
}
}
}
Action::SelectToParagraphUp => {
select_each_cursor(cursors, &mut events, |c| {
let mut iter = state
.buffer
.line_iterator(c.position, estimated_line_length);
let mut found_pos = None;
while let Some((line_start, line_content)) = iter.prev() {
let trimmed = line_content.trim_end_matches(['\n', '\r']);
if trimmed.is_empty() || trimmed.chars().all(char::is_whitespace) {
found_pos = Some(line_start);
break;
}
}
found_pos.unwrap_or(0)
});
}
Action::SelectToParagraphDown => {
select_each_cursor(cursors, &mut events, |c| {
let mut iter = state
.buffer
.line_iterator(c.position, estimated_line_length);
iter.next_line();
let mut found_pos = None;
while let Some((line_start, line_content)) = iter.next_line() {
let trimmed = line_content.trim_end_matches(['\n', '\r']);
if trimmed.is_empty() || trimmed.chars().all(char::is_whitespace) {
found_pos = Some(line_start);
break;
}
}
found_pos.unwrap_or(state.buffer.len())
});
}
Action::SelectLineStart => {
select_each_cursor(cursors, &mut events, |c| {
state
.buffer
.line_iterator(c.position, estimated_line_length)
.next_line()
.map(|(ls, _)| ls)
.unwrap_or(c.position)
});
}
Action::SelectLineEnd => {
select_each_cursor(cursors, &mut events, |c| {
state
.buffer
.line_iterator(c.position, estimated_line_length)
.next_line()
.map(|(ls, lc)| ls + content_len_without_line_ending(&lc))
.unwrap_or(c.position)
});
}
Action::SelectWordLeft => {
select_each_cursor(cursors, &mut events, |c| {
find_word_start_left(&state.buffer, c.position)
});
}
Action::SelectWordRight => {
select_each_cursor(cursors, &mut events, |c| {
find_word_start_right(&state.buffer, c.position)
});
}
Action::SelectWordEnd => {
select_each_cursor(cursors, &mut events, |c| {
find_word_end_right(&state.buffer, c.position)
});
}
Action::ViSelectWordEnd => {
select_each_cursor(cursors, &mut events, |c| {
find_vi_word_end(&state.buffer, c.position)
});
}
Action::SelectDocumentStart => {
select_each_cursor(cursors, &mut events, |_| 0);
}
Action::SelectDocumentEnd => {
let max_pos = max_cursor_position(&state.buffer);
select_each_cursor(cursors, &mut events, |_| max_pos);
}
Action::SelectPageUp => {
handle_select_page_up(
state,
cursors,
&mut events,
viewport_height,
estimated_line_length,
);
}
Action::SelectPageDown => {
handle_select_page_down(
state,
cursors,
&mut events,
viewport_height,
estimated_line_length,
);
}
Action::SelectAll => {
let primary_id = cursors.primary_id();
let primary_cursor = cursors.primary();
let max_pos = max_cursor_position(&state.buffer);
add_move_cursor_event(
&mut events,
primary_id,
primary_cursor.position,
max_pos,
primary_cursor.anchor,
Some(0),
primary_cursor.sticky_column,
);
}
Action::SelectWord => {
for (cursor_id, cursor) in cursors.iter() {
let word_start = find_word_start(&state.buffer, cursor.position);
let word_end = find_word_end(&state.buffer, word_start);
if word_start < word_end {
add_move_cursor_event(
&mut events,
cursor_id,
cursor.position,
word_end,
cursor.anchor,
Some(word_start),
cursor.sticky_column,
);
}
}
}
Action::DeleteBackward => {
handle_delete_backward(
state,
cursors,
&mut events,
tab_size,
auto_close,
estimated_line_length,
);
}
Action::DeleteForward => {
let mut cursor_vec: Vec<_> = cursors.iter().collect();
cursor_vec.sort_by_key(|(_, c)| std::cmp::Reverse(c.position));
let buffer_len = state.buffer.len();
let deletions: Vec<_> = cursor_vec
.iter()
.filter_map(|(cursor_id, cursor)| {
if let Some(range) = cursor.selection_range() {
Some((*cursor_id, range))
} else if cursor.position < buffer_len {
let delete_to =
next_position_for_crlf(&state.buffer, cursor.position, buffer_len);
Some((*cursor_id, cursor.position..delete_to))
} else {
None
}
})
.collect();
apply_deletions(state, deletions, &mut events);
}
Action::DeleteWordBackward => {
let deletions: Vec<_> = cursors
.iter()
.filter_map(|(cursor_id, cursor)| {
if let Some(range) = cursor.selection_range() {
Some((cursor_id, range))
} else {
let word_start = find_word_start_left(&state.buffer, cursor.position);
if word_start < cursor.position {
Some((cursor_id, word_start..cursor.position))
} else {
None
}
}
})
.collect();
apply_deletions(state, deletions, &mut events);
}
Action::DeleteWordForward => {
let deletions: Vec<_> = cursors
.iter()
.filter_map(|(cursor_id, cursor)| {
if let Some(range) = cursor.selection_range() {
Some((cursor_id, range))
} else {
let word_end = find_word_start_right(&state.buffer, cursor.position);
if cursor.position < word_end {
Some((cursor_id, cursor.position..word_end))
} else {
None
}
}
})
.collect();
apply_deletions(state, deletions, &mut events);
}
Action::DeleteViWordEnd => {
let deletions: Vec<_> = cursors
.iter()
.filter_map(|(cursor_id, cursor)| {
if let Some(range) = cursor.selection_range() {
Some((cursor_id, range))
} else {
let word_end = find_vi_word_end(&state.buffer, cursor.position);
let end = (word_end + 1).min(state.buffer.len());
if cursor.position < end {
Some((cursor_id, cursor.position..end))
} else {
None
}
}
})
.collect();
apply_deletions(state, deletions, &mut events);
}
Action::DeleteLine => {
let deletions: Vec<_> = cursors
.iter()
.filter_map(|(cursor_id, cursor)| {
let mut iter = state
.buffer
.line_iterator(cursor.position, estimated_line_length);
let line_start = iter.current_position();
iter.next_line().map(|(_start, content)| {
let line_end = line_start + content.len();
(cursor_id, line_start..line_end)
})
})
.collect();
apply_deletions(state, deletions, &mut events);
}
Action::DeleteToLineEnd => {
let deletions: Vec<_> = cursors
.iter()
.filter_map(|(cursor_id, cursor)| {
let mut iter = state
.buffer
.line_iterator(cursor.position, estimated_line_length);
let line_start = iter.current_position();
iter.next_line().map(|(_start, content)| {
let line_end = line_start + content_len_without_line_ending(&content);
if cursor.position < line_end {
Some((cursor_id, cursor.position..line_end))
} else {
let full_line_end = line_start + content.len();
if cursor.position < full_line_end {
Some((cursor_id, cursor.position..full_line_end))
} else {
None
}
}
})?
})
.collect();
apply_deletions(state, deletions, &mut events);
}
Action::DeleteToLineStart => {
let deletions: Vec<_> = cursors
.iter()
.filter_map(|(cursor_id, cursor)| {
let iter = state
.buffer
.line_iterator(cursor.position, estimated_line_length);
let line_start = iter.current_position();
if cursor.position > line_start {
Some((cursor_id, line_start..cursor.position))
} else {
None
}
})
.collect();
apply_deletions(state, deletions, &mut events);
}
Action::MoveLineUp => {
move_lines(
state,
cursors,
&mut events,
LineMoveDirection::Up,
estimated_line_length,
);
}
Action::MoveLineDown => {
move_lines(
state,
cursors,
&mut events,
LineMoveDirection::Down,
estimated_line_length,
);
}
Action::TransposeChars => {
let cursor_positions: Vec<_> = cursors.iter().map(|(id, c)| (id, c.position)).collect();
for (cursor_id, pos) in cursor_positions {
if pos > 0 && pos < state.buffer.len() {
let text = state.get_text_range(pos - 1, pos + 1);
let chars: Vec<char> = text.chars().collect();
if chars.len() >= 2 {
events.push(Event::Delete {
range: (pos - 1)..(pos + 1),
deleted_text: text,
cursor_id,
});
let swapped = format!("{}{}", chars[1], chars[0]);
events.push(Event::Insert {
position: pos - 1,
text: swapped,
cursor_id,
});
}
}
}
}
Action::ToUpperCase => {
transform_case(state, cursors, &mut events, |s| s.to_uppercase());
}
Action::ToLowerCase => {
transform_case(state, cursors, &mut events, |s| s.to_lowercase());
}
Action::ToggleCase => {
handle_toggle_case(state, cursors, &mut events);
}
Action::SortLines => {
handle_sort_lines(state, cursors, &mut events);
}
Action::OpenLine => {
let line_ending = state.buffer.line_ending().as_str();
let len = line_ending.len();
for (cursor_id, cursor) in cursors.iter() {
events.push(Event::Insert {
position: cursor.position,
text: line_ending.to_string(),
cursor_id,
});
events.push(Event::MoveCursor {
cursor_id,
old_position: cursor.position + len,
new_position: cursor.position,
old_anchor: cursor.anchor,
new_anchor: cursor.anchor,
old_sticky_column: cursor.sticky_column,
new_sticky_column: cursor.sticky_column,
});
}
}
Action::DuplicateLine => {
handle_duplicate_line(state, cursors, &mut events, estimated_line_length);
}
Action::Recenter => {
events.push(Event::Recenter);
}
Action::SetMark => {
for (cursor_id, cursor) in cursors.iter() {
events.push(Event::SetAnchor {
cursor_id,
position: cursor.position,
});
}
}
Action::RemoveSecondaryCursors => {
let first_id = cursors
.iter()
.map(|(id, _)| id)
.min_by_key(|id| id.0)
.expect("Should have at least one cursor");
for (cursor_id, cursor) in cursors.iter() {
if cursor_id != first_id {
events.push(Event::RemoveCursor {
cursor_id,
position: cursor.position,
anchor: cursor.anchor,
});
}
events.push(Event::ClearAnchor { cursor_id });
}
}
Action::ScrollUp => {
events.push(Event::Scroll { line_offset: -1 });
}
Action::ScrollDown => {
events.push(Event::Scroll { line_offset: 1 });
}
Action::Quit
| Action::ForceQuit
| Action::Detach
| Action::Save
| Action::SaveAs
| Action::Open
| Action::SwitchProject
| Action::New
| Action::Close
| Action::CloseTab
| Action::GotoLine
| Action::ScanLineIndex
| Action::NextBuffer
| Action::PrevBuffer
| Action::SwitchToPreviousTab
| Action::SwitchToTabByName
| Action::NavigateBack
| Action::NavigateForward
| Action::SplitHorizontal
| Action::SplitVertical
| Action::CloseSplit
| Action::NextSplit
| Action::PrevSplit
| Action::Copy
| Action::CopyWithTheme(_)
| Action::CopyFilePath
| Action::CopyRelativeFilePath
| Action::Cut
| Action::Paste
| Action::YankWordForward
| Action::YankWordBackward
| Action::YankToLineEnd
| Action::YankToLineStart
| Action::YankViWordEnd
| Action::AddCursorNextMatch
| Action::AddCursorAbove
| Action::AddCursorBelow
| Action::CommandPalette
| Action::QuickOpen
| Action::QuickOpenBuffers
| Action::QuickOpenFiles
| Action::OpenLiveGrep
| Action::ResumeLiveGrep
| Action::LiveGrepExportQuickfix
| Action::ToggleUtilityDock
| Action::OpenTerminalInDock
| Action::CycleLiveGrepProvider
| Action::ShowHelp
| Action::ToggleLineWrap
| Action::ToggleCurrentLineHighlight
| Action::ToggleReadOnly
| Action::TogglePageView
| Action::SetPageWidth
| Action::IncreaseSplitSize
| Action::DecreaseSplitSize
| Action::ToggleMaximizeSplit
| Action::Undo
| Action::Redo
| Action::GoToMatchingBracket
| Action::JumpToNextError
| Action::JumpToPreviousError
| Action::ShowKeyboardShortcuts
| Action::ShowWarnings
| Action::ShowStatusLog
| Action::ShowLspStatus
| Action::ShowRemoteIndicatorMenu
| Action::ClearWarnings
| Action::SmartHome
| Action::ToggleComment
| Action::DabbrevExpand
| Action::ToggleFold
| Action::SetBookmark(_)
| Action::JumpToBookmark(_)
| Action::ClearBookmark(_)
| Action::ListBookmarks
| Action::ToggleSearchCaseSensitive
| Action::ToggleSearchWholeWord
| Action::ToggleSearchRegex
| Action::ToggleSearchConfirmEach
| Action::StartMacroRecording
| Action::StopMacroRecording
| Action::PlayMacro(_)
| Action::ToggleMacroRecording(_)
| Action::ShowMacro(_)
| Action::ListMacros
| Action::PromptRecordMacro
| Action::PromptPlayMacro
| Action::PlayLastMacro
| Action::PromptSetBookmark
| Action::PromptJumpToBookmark
| Action::PromptConfirm
| Action::PromptConfirmWithText(_)
| Action::PromptCancel
| Action::PromptBackspace
| Action::PromptDelete
| Action::PromptMoveLeft
| Action::PromptMoveRight
| Action::PromptMoveStart
| Action::PromptMoveEnd
| Action::PromptSelectPrev
| Action::PromptSelectNext
| Action::PromptPageUp
| Action::PromptPageDown
| Action::PromptAcceptSuggestion
| Action::PromptMoveWordLeft
| Action::PromptMoveWordRight
| Action::PromptDeleteWordForward
| Action::PromptDeleteWordBackward
| Action::PromptDeleteToLineEnd
| Action::PromptCopy
| Action::PromptCut
| Action::PromptPaste
| Action::PromptMoveLeftSelecting
| Action::PromptMoveRightSelecting
| Action::PromptMoveHomeSelecting
| Action::PromptMoveEndSelecting
| Action::PromptSelectWordLeft
| Action::PromptSelectWordRight
| Action::PromptSelectAll
| Action::FileBrowserToggleHidden
| Action::FileBrowserToggleDetectEncoding
| Action::PopupSelectNext
| Action::PopupSelectPrev
| Action::PopupPageUp
| Action::PopupPageDown
| Action::PopupConfirm
| Action::PopupCancel
| Action::PopupFocus
| Action::CompletionAccept
| Action::CompletionDismiss
| Action::ToggleFileExplorer
| Action::ToggleMenuBar
| Action::ToggleTabBar
| Action::ToggleStatusBar
| Action::TogglePromptLine
| Action::ToggleVerticalScrollbar
| Action::ToggleHorizontalScrollbar
| Action::FocusFileExplorer
| Action::FocusEditor
| Action::SetBackground
| Action::SetBackgroundBlend
| Action::FileExplorerUp
| Action::FileExplorerDown
| Action::FileExplorerPageUp
| Action::FileExplorerPageDown
| Action::FileExplorerExpand
| Action::FileExplorerCollapse
| Action::FileExplorerOpen
| Action::FileExplorerRefresh
| Action::FileExplorerNewFile
| Action::FileExplorerNewDirectory
| Action::FileExplorerDelete
| Action::FileExplorerRename
| Action::FileExplorerToggleHidden
| Action::FileExplorerToggleGitignored
| Action::FileExplorerSearchClear
| Action::FileExplorerSearchBackspace
| Action::FileExplorerCopy
| Action::FileExplorerCut
| Action::FileExplorerPaste
| Action::FileExplorerDuplicate
| Action::FileExplorerCopyFullPath
| Action::FileExplorerCopyRelativePath
| Action::FileExplorerExtendSelectionUp
| Action::FileExplorerExtendSelectionDown
| Action::FileExplorerToggleSelect
| Action::FileExplorerSelectAll
| Action::LspCompletion
| Action::LspGotoDefinition
| Action::LspReferences
| Action::LspRename
| Action::LspHover
| Action::LspSignatureHelp
| Action::LspCodeActions
| Action::LspRestart
| Action::LspStop
| Action::LspToggleForBuffer
| Action::ToggleInlayHints
| Action::ToggleMouseHover
| Action::ToggleLineNumbers
| Action::ToggleScrollSync
| Action::ToggleMouseCapture
| Action::DumpConfig
| Action::RedrawScreen
| Action::Search
| Action::FindInSelection
| Action::FindNext
| Action::FindPrevious
| Action::FindSelectionNext
| Action::FindSelectionPrevious
| Action::Replace
| Action::QueryReplace
| Action::MenuActivate
| Action::MenuClose
| Action::MenuLeft
| Action::MenuRight
| Action::MenuUp
| Action::MenuDown
| Action::MenuExecute
| Action::MenuOpen(_)
| Action::SwitchKeybindingMap(_)
| Action::PluginAction(_)
| Action::None
| Action::ScrollTabsLeft
| Action::ScrollTabsRight
| Action::InspectThemeAtCursor
| Action::SelectTheme
| Action::SelectKeybindingMap
| Action::SelectCursorStyle
| Action::SelectLocale
| Action::Revert
| Action::ToggleAutoRevert
| Action::FormatBuffer
| Action::TrimTrailingWhitespace
| Action::EnsureFinalNewline
| Action::OpenTerminal
| Action::CloseTerminal
| Action::FocusTerminal
| Action::TerminalEscape
| Action::ToggleKeyboardCapture
| Action::TerminalPaste
| Action::OpenSettings
| Action::CloseSettings
| Action::SettingsSave
| Action::SettingsReset
| Action::SettingsToggleFocus
| Action::SettingsActivate
| Action::SettingsSearch
| Action::SettingsHelp
| Action::SettingsIncrement
| Action::SettingsDecrement
| Action::SettingsInherit
| Action::SetTabSize
| Action::SetLineEnding
| Action::SetEncoding
| Action::ReloadWithEncoding
| Action::SetLanguage
| Action::ToggleIndentationStyle
| Action::ToggleTabIndicators
| Action::ToggleWhitespaceIndicators
| Action::ToggleDebugHighlights
| Action::ResetBufferSettings
| Action::ShellCommand
| Action::ShellCommandReplace
| Action::CalibrateInput
| Action::EventDebug
| Action::SuspendProcess
| Action::LoadPluginFromBuffer
| Action::InitReload
| Action::InitEdit
| Action::InitCheck
| Action::OpenKeybindingEditor
| Action::AddRuler
| Action::RemoveRuler
| Action::CompositeNextHunk
| Action::CompositePrevHunk => return None,
Action::BlockSelectLeft => {
block_select_action(state, cursors, &mut events, BlockDirection::Left);
}
Action::BlockSelectRight => {
block_select_action(state, cursors, &mut events, BlockDirection::Right);
}
Action::BlockSelectUp => {
block_select_action(state, cursors, &mut events, BlockDirection::Up);
}
Action::BlockSelectDown => {
block_select_action(state, cursors, &mut events, BlockDirection::Down);
}
Action::SelectLine => {
for (cursor_id, cursor) in cursors.iter() {
let mut iter = state
.buffer
.line_iterator(cursor.position, estimated_line_length);
if let Some((line_start, line_content)) = iter.next_line() {
let line_end = line_start + line_content.len();
add_move_cursor_event(
&mut events,
cursor_id,
cursor.position,
line_end,
cursor.anchor,
Some(line_start),
cursor.sticky_column,
);
}
}
}
Action::ExpandSelection => {
for (cursor_id, cursor) in cursors.iter() {
if let Some(anchor) = cursor.anchor {
let next_word_start = find_word_start_right(&state.buffer, cursor.position);
let new_end = find_word_end(&state.buffer, next_word_start);
add_move_cursor_event(
&mut events,
cursor_id,
cursor.position,
new_end,
cursor.anchor,
Some(anchor),
cursor.sticky_column,
);
} else {
let word_start = find_word_start(&state.buffer, cursor.position);
let word_end = find_word_end(&state.buffer, cursor.position);
let (final_start, final_end) =
if word_start == word_end || cursor.position == word_end {
let next_start = find_word_start_right(&state.buffer, cursor.position);
let next_end = find_word_end(&state.buffer, next_start);
(cursor.position, next_end)
} else {
(cursor.position, word_end)
};
add_move_cursor_event(
&mut events,
cursor_id,
cursor.position,
final_end,
cursor.anchor,
Some(final_start),
cursor.sticky_column,
);
}
}
}
}
Some(events)
}
#[cfg(test)]
mod tests {
use crate::model::filesystem::StdFileSystem;
use std::sync::Arc;
fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
Arc::new(StdFileSystem)
}
use super::*;
use crate::model::cursor::Cursors;
use crate::model::event::{CursorId, Event};
use crate::state::EditorState;
#[test]
fn test_backspace_deletes_newline() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "Hello\nWorld".to_string(),
cursor_id: CursorId(0),
},
);
assert_eq!(state.buffer.to_string().unwrap(), "Hello\nWorld");
assert_eq!(cursors.primary().position, 11);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 0,
new_position: 6,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
assert_eq!(cursors.primary().position, 6);
let events = action_to_events(
&mut state,
&mut cursors,
Action::DeleteBackward,
4,
false,
false,
true,
80,
24,
)
.unwrap();
println!("Generated events: {:?}", events);
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), "HelloWorld");
assert_eq!(cursors.primary().position, 5);
}
#[test]
fn test_move_down_basic() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "Line1\nLine2\nLine3".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 17,
new_position: 0,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
assert_eq!(cursors.primary().position, 0);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveDown,
4,
false,
false,
true,
80,
24,
)
.unwrap();
assert_eq!(events.len(), 1);
if let Event::MoveCursor { new_position, .. } = &events[0] {
assert_eq!(*new_position, 6, "Cursor should move to start of Line2");
} else {
panic!("Expected MoveCursor event");
}
state.apply(&mut cursors, &events[0]);
assert_eq!(cursors.primary().position, 6);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveDown,
4,
false,
false,
true,
80,
24,
)
.unwrap();
assert_eq!(events.len(), 1);
if let Event::MoveCursor { new_position, .. } = &events[0] {
assert_eq!(*new_position, 12, "Cursor should move to start of Line3");
} else {
panic!("Expected MoveCursor event");
}
}
#[test]
fn test_move_line_up_without_trailing_newline() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "A\nB".to_string(),
cursor_id: CursorId(0),
},
);
let pos = cursors.primary().position;
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: pos,
new_position: 2, old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveLineUp,
4,
false,
false,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), "B\nA");
assert_eq!(cursors.primary().position, 0);
}
#[test]
fn test_move_line_down_without_trailing_newline() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "A\nB".to_string(),
cursor_id: CursorId(0),
},
);
let pos = cursors.primary().position;
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: pos,
new_position: 0, old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveLineDown,
4,
false,
false,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), "B\nA");
assert_eq!(cursors.primary().position, 2);
}
#[test]
fn test_move_line_up_first_line_noop() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "A\nB".to_string(),
cursor_id: CursorId(0),
},
);
let pos = cursors.primary().position;
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: pos,
new_position: 0,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveLineUp,
4,
false,
false,
true,
80,
24,
)
.unwrap();
assert!(events.is_empty());
assert_eq!(state.buffer.to_string().unwrap(), "A\nB");
assert_eq!(cursors.primary().position, 0);
}
#[test]
fn test_move_line_down_last_line_noop() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "A\nB".to_string(),
cursor_id: CursorId(0),
},
);
let pos = cursors.primary().position;
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: pos,
new_position: 2, old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveLineDown,
4,
false,
false,
true,
80,
24,
)
.unwrap();
assert!(events.is_empty());
assert_eq!(state.buffer.to_string().unwrap(), "A\nB");
assert_eq!(cursors.primary().position, 2);
}
#[test]
fn test_move_line_up_multi_cursor_separate_lines() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "A\nB\nC\nD".to_string(),
cursor_id: CursorId(0),
},
);
let pos = cursors.primary().position;
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: pos,
new_position: 2, old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
state.apply(
&mut cursors,
&Event::AddCursor {
position: 6, cursor_id: CursorId(1),
anchor: None,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveLineUp,
4,
false,
false,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), "B\nA\nD\nC");
assert_eq!(cursors.get(CursorId(0)).unwrap().position, 0);
assert_eq!(cursors.get(CursorId(1)).unwrap().position, 4);
}
#[test]
fn test_move_line_up_large_file_unloaded_chunks() {
use crate::model::buffer::TextBuffer;
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let test_file = temp_dir.path().join("move_line_up_large_file.txt");
let mut content = String::new();
for i in 0..200 {
content.push_str(&format!("Line {i:04}\n"));
}
fs::write(&test_file, &content).unwrap();
let large_file_threshold = 500;
let buffer =
TextBuffer::load_from_file(&test_file, large_file_threshold, test_fs()).unwrap();
let mut state = EditorState::new(80, 24, large_file_threshold, test_fs());
let mut cursors = Cursors::new();
state.buffer = buffer;
let line_len = "Line 0000\n".len();
let target_line = 120usize;
let target_start = line_len * target_line;
let pos = cursors.primary().position;
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: pos,
new_position: target_start,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveLineUp,
4,
false,
false,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
let line_119_start = line_len * (target_line - 1);
let line_120_start = line_len * target_line;
let line_119 = state.get_text_range(line_119_start, line_119_start + line_len);
let line_120 = state.get_text_range(line_120_start, line_120_start + line_len);
assert_eq!(line_119, "Line 0120\n");
assert_eq!(line_120, "Line 0119\n");
}
#[test]
fn test_move_line_down_large_file_selection_block() {
use crate::model::buffer::TextBuffer;
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let test_file = temp_dir.path().join("move_line_down_large_file.txt");
let mut content = String::new();
for i in 0..200 {
content.push_str(&format!("Line {i:04}\n"));
}
fs::write(&test_file, &content).unwrap();
let large_file_threshold = 500;
let buffer =
TextBuffer::load_from_file(&test_file, large_file_threshold, test_fs()).unwrap();
let mut state = EditorState::new(80, 24, large_file_threshold, test_fs());
let mut cursors = Cursors::new();
state.buffer = buffer;
let line_len = "Line 0000\n".len();
let start_line = 50usize;
let end_line_exclusive = 53usize; let selection_start = line_len * start_line;
let selection_end = line_len * end_line_exclusive;
let pos = cursors.primary().position;
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: pos,
new_position: selection_end,
old_anchor: None,
new_anchor: Some(selection_start),
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveLineDown,
4,
false,
false,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
let line_50 = state.get_text_range(selection_start, selection_start + line_len);
let line_51 =
state.get_text_range(selection_start + line_len, selection_start + line_len * 2);
let line_52 = state.get_text_range(
selection_start + line_len * 2,
selection_start + line_len * 3,
);
let line_53 = state.get_text_range(
selection_start + line_len * 3,
selection_start + line_len * 4,
);
assert_eq!(line_50, "Line 0053\n");
assert_eq!(line_51, "Line 0050\n");
assert_eq!(line_52, "Line 0051\n");
assert_eq!(line_53, "Line 0052\n");
}
#[test]
fn test_move_up_basic() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "Line1\nLine2\nLine3".to_string(),
cursor_id: CursorId(0),
},
);
assert_eq!(cursors.primary().position, 17);
assert_eq!(state.buffer.to_string().unwrap(), "Line1\nLine2\nLine3");
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveUp,
4,
false,
false,
true,
80,
24,
)
.unwrap();
assert_eq!(events.len(), 1);
if let Event::MoveCursor { new_position, .. } = &events[0] {
assert_eq!(
*new_position, 11,
"Cursor should move to column 5 of Line2 (which is the newline)"
);
} else {
panic!("Expected MoveCursor event");
}
state.apply(&mut cursors, &events[0]);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveUp,
4,
false,
false,
true,
80,
24,
)
.unwrap();
assert_eq!(events.len(), 1);
if let Event::MoveCursor { new_position, .. } = &events[0] {
assert_eq!(
*new_position, 5,
"Cursor should move to column 5 of Line1 (the newline)"
);
} else {
panic!("Expected MoveCursor event");
}
}
#[test]
fn test_move_down_preserves_column() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "12345\n123\n12345".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 15,
new_position: 3,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
assert_eq!(cursors.primary().position, 3);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveDown,
4,
false,
false,
true,
80,
24,
)
.unwrap();
assert_eq!(events.len(), 1);
if let Event::MoveCursor {
new_position,
new_sticky_column,
..
} = &events[0]
{
assert_eq!(
*new_position, 9,
"Cursor should move to end of shorter line"
);
assert_eq!(
*new_sticky_column, 3,
"Sticky column should preserve original column"
);
} else {
panic!("Expected MoveCursor event");
}
state.apply(&mut cursors, &events[0]);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveDown,
4,
false,
false,
true,
80,
24,
)
.unwrap();
assert_eq!(events.len(), 1);
if let Event::MoveCursor {
new_position,
new_sticky_column,
..
} = &events[0]
{
assert_eq!(*new_position, 13, "Cursor should move back to column 3");
assert_eq!(*new_sticky_column, 3, "Sticky column should be preserved");
} else {
panic!("Expected MoveCursor event");
}
}
#[test]
fn test_move_up_preserves_column() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "12345\n123\n12345".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 15,
new_position: 13,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
assert_eq!(cursors.primary().position, 13);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveUp,
4,
false,
false,
true,
80,
24,
)
.unwrap();
assert_eq!(events.len(), 1);
if let Event::MoveCursor {
new_position,
new_sticky_column,
..
} = &events[0]
{
assert_eq!(
*new_position, 9,
"Cursor should move to end of shorter line"
);
assert_eq!(
*new_sticky_column, 3,
"Sticky column should preserve original column"
);
} else {
panic!("Expected MoveCursor event");
}
state.apply(&mut cursors, &events[0]);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveUp,
4,
false,
false,
true,
80,
24,
)
.unwrap();
assert_eq!(events.len(), 1);
if let Event::MoveCursor {
new_position,
new_sticky_column,
..
} = &events[0]
{
assert_eq!(*new_position, 3, "Cursor should move back to column 3");
assert_eq!(*new_sticky_column, 3, "Sticky column should be preserved");
} else {
panic!("Expected MoveCursor event");
}
}
#[test]
fn test_move_down_at_line_start() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "First\nSecond".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 12,
new_position: 0,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveDown,
4,
false,
false,
true,
80,
24,
)
.unwrap();
assert_eq!(events.len(), 1);
if let Event::MoveCursor { new_position, .. } = &events[0] {
assert_eq!(*new_position, 6, "Cursor should move to start of next line");
} else {
panic!("Expected MoveCursor event");
}
}
#[test]
fn test_move_up_at_line_start() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "First\nSecond".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 12,
new_position: 6,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveUp,
4,
false,
false,
true,
80,
24,
)
.unwrap();
assert_eq!(events.len(), 1);
if let Event::MoveCursor { new_position, .. } = &events[0] {
assert_eq!(
*new_position, 0,
"Cursor should move to start of previous line"
);
} else {
panic!("Expected MoveCursor event");
}
}
#[test]
fn test_move_down_with_empty_lines() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "Line1\n\nLine3".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 12,
new_position: 0,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveDown,
4,
false,
false,
true,
80,
24,
)
.unwrap();
if let Event::MoveCursor { new_position, .. } = &events[0] {
assert_eq!(*new_position, 6, "Cursor should move to empty line");
}
state.apply(&mut cursors, &events[0]);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveDown,
4,
false,
false,
true,
80,
24,
)
.unwrap();
if let Event::MoveCursor { new_position, .. } = &events[0] {
assert_eq!(*new_position, 7, "Cursor should move to Line3");
}
}
#[test]
fn test_column_calculation_doesnt_underflow() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "Hello".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 5,
new_position: 5,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveUp,
4,
false,
false,
true,
80,
24,
)
.unwrap();
assert_eq!(
events.len(),
0,
"Should not generate event when at first line"
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveDown,
4,
false,
false,
true,
80,
24,
)
.unwrap();
assert_eq!(
events.len(),
0,
"Should not generate event when at last line"
);
}
#[test]
fn test_line_iterator_positioning_for_cursor_movement() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "Line1\nLine2\nLine3".to_string(),
cursor_id: CursorId(0),
},
);
if let Some(pos) = state.buffer.offset_to_position(11) {
println!(
"offset_to_position(11) = line={}, column={}",
pos.line, pos.column
);
}
if let Some(pos) = state.buffer.offset_to_position(17) {
println!(
"offset_to_position(17) = line={}, column={}",
pos.line, pos.column
);
}
let iter = state.buffer.line_iterator(17, 80);
assert_eq!(
iter.current_position(),
12,
"Iterator at position 17 should be at line start 12"
);
let iter = state.buffer.line_iterator(9, 80);
assert_eq!(
iter.current_position(),
6,
"Iterator at position 9 should be at line start 6"
);
let iter = state.buffer.line_iterator(11, 80);
assert_eq!(
iter.current_position(),
6,
"Iterator at position 11 (newline) should be at line start 6"
);
let iter = state.buffer.line_iterator(6, 80);
assert_eq!(
iter.current_position(),
6,
"Iterator at position 6 should stay at 6"
);
}
#[test]
fn test_move_line_end_positioning() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "HelloNew Line\nWorld!".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 20,
new_position: 0,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveLineEnd,
4,
false,
false,
true,
80,
24,
)
.unwrap();
for event in events {
println!("MoveLineEnd event: {:?}", event);
state.apply(&mut cursors, &event);
}
println!(
"After MoveLineEnd: cursor at {}",
cursors.primary().position
);
assert_eq!(
cursors.primary().position,
13,
"MoveLineEnd should position at end of visible text"
);
}
#[test]
fn test_move_line_start_from_eof() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "HelloNew Line\nWorld!".to_string(),
cursor_id: CursorId(0),
},
);
assert_eq!(cursors.primary().position, 20);
println!("Starting at EOF: position 20");
let iter = state.buffer.line_iterator(20, 80);
println!(
"line_iterator(20).current_position() = {}",
iter.current_position()
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveLineStart,
4,
false,
false,
true,
80,
24,
)
.unwrap();
for event in events {
println!("MoveLineStart event from EOF: {:?}", event);
state.apply(&mut cursors, &event);
}
println!(
"After MoveLineStart from EOF: cursor at {}",
cursors.primary().position
);
assert_eq!(
cursors.primary().position,
14,
"MoveLineStart from EOF should go to start of last line"
);
}
#[test]
fn test_move_up_with_unloaded_chunks() {
use crate::model::buffer::TextBuffer;
use std::fs;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("test_large_file_move_up.txt");
let mut content = String::new();
for i in 0..100 {
content.push_str(&format!("This is line number {}\n", i));
}
fs::write(&test_file, &content).unwrap();
let large_file_threshold = 500;
let buffer =
TextBuffer::load_from_file(&test_file, large_file_threshold, test_fs()).unwrap();
let mut state = EditorState::new(80, 24, large_file_threshold, test_fs());
let mut cursors = Cursors::new();
state.buffer = buffer;
let target_line_start: usize = content.lines().take(90).map(|l| l.len() + 1).sum();
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 0,
new_position: target_line_start,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
println!(
"Cursor at line 90, position: {}",
cursors.primary().position
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveUp,
4,
false,
false,
true,
80,
24,
)
.unwrap();
println!("MoveUp events: {:?}", events);
assert!(
!events.is_empty(),
"MoveUp should generate events even with unloaded chunks"
);
for event in events {
state.apply(&mut cursors, &event);
}
println!("After MoveUp: cursor at {}", cursors.primary().position);
assert!(
cursors.primary().position < target_line_start,
"Cursor should have moved up"
);
fs::remove_file(&test_file).ok();
}
#[test]
fn test_move_down_from_newline_position() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "HelloNew Line\nWorld!".to_string(),
cursor_id: CursorId(0),
},
);
assert_eq!(state.buffer.to_string().unwrap(), "HelloNew Line\nWorld!");
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 20,
new_position: 13,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
assert_eq!(cursors.primary().position, 13);
println!("Starting position: 13 (on the newline)");
let iter = state.buffer.line_iterator(13, 80);
println!(
"line_iterator(13).current_position() = {}",
iter.current_position()
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveDown,
4,
false,
false,
true,
80,
24,
)
.unwrap();
println!("MoveDown events: {:?}", events);
if events.is_empty() {
panic!("MoveDown from position 13 generated no events!");
}
for event in events {
state.apply(&mut cursors, &event);
}
println!(
"After MoveDown from position 13: cursor at {}",
cursors.primary().position
);
assert!(
cursors.primary().position >= 14 && cursors.primary().position <= 20,
"After MoveDown from newline, cursor should be on the next line, not at EOF"
);
}
#[test]
fn test_move_down_then_home_backspace() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "HelloNew Line\nWorld!".to_string(),
cursor_id: CursorId(0),
},
);
assert_eq!(state.buffer.to_string().unwrap(), "HelloNew Line\nWorld!");
assert_eq!(cursors.primary().position, 20);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveUp,
4,
false,
false,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
println!("After MoveUp: cursor at {}", cursors.primary().position);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveLineEnd,
4,
false,
false,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(
cursors.primary().position,
13,
"Should be at end of first line (position 13, the newline)"
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveDown,
4,
false,
false,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
println!("After MoveDown: cursor at {}", cursors.primary().position);
let events = action_to_events(
&mut state,
&mut cursors,
Action::MoveLineStart,
4,
false,
false,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
println!("After Home: cursor at {}", cursors.primary().position);
assert_eq!(
cursors.primary().position,
14,
"Should be at start of second line (position 14)"
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::DeleteBackward,
4,
false,
false,
true,
80,
24,
)
.unwrap();
for event in events.iter() {
println!("Event: {:?}", event);
state.apply(&mut cursors, event);
}
println!(
"After backspace: buffer = {:?}",
state.buffer.to_string().unwrap()
);
println!("After backspace: cursor at {}", cursors.primary().position);
assert_eq!(
state.buffer.to_string().unwrap(),
"HelloNew LineWorld!",
"Lines should be joined"
);
assert_eq!(
cursors.primary().position,
13,
"Cursor should be at join point"
);
}
#[test]
fn test_bracket_auto_close_parenthesis() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
assert_eq!(cursors.primary().position, 0);
let events = action_to_events(
&mut state,
&mut cursors,
Action::InsertChar('('),
4,
true,
true,
true,
80,
24,
)
.unwrap();
println!("Events: {:?}", events);
assert_eq!(events.len(), 2, "Should have Insert and MoveCursor events");
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), "()");
assert_eq!(
cursors.primary().position,
1,
"Cursor should be between brackets"
);
}
#[test]
fn test_bracket_auto_close_curly_brace() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
let events = action_to_events(
&mut state,
&mut cursors,
Action::InsertChar('{'),
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), "{}");
assert_eq!(
cursors.primary().position,
1,
"Cursor should be between braces"
);
}
#[test]
fn test_bracket_auto_close_square_bracket() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
let events = action_to_events(
&mut state,
&mut cursors,
Action::InsertChar('['),
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), "[]");
assert_eq!(cursors.primary().position, 1);
}
#[test]
fn test_bracket_auto_close_double_quote() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.language = "rust".to_string();
let events = action_to_events(
&mut state,
&mut cursors,
Action::InsertChar('"'),
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), "\"\"");
assert_eq!(cursors.primary().position, 1);
}
#[test]
fn test_bracket_auto_close_disabled_when_auto_indent_false() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
let events = action_to_events(
&mut state,
&mut cursors,
Action::InsertChar('('),
4,
false,
false,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), "(");
assert_eq!(cursors.primary().position, 1);
}
#[test]
fn test_bracket_auto_close_not_before_alphanumeric() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "abc".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 3,
new_position: 0,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::InsertChar('('),
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), "(abc");
assert_eq!(cursors.primary().position, 1);
}
#[test]
fn test_bracket_auto_close_multiple_cursors() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "foo\nbar".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::AddCursor {
position: 0,
cursor_id: CursorId(1),
anchor: None,
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 7,
new_position: 7, old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(1),
old_position: 0,
new_position: 3, old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::InsertChar('('),
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), "foo()\nbar()");
}
#[test]
fn test_bracket_auto_close_multiple_cursors_with_skip_over() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "\n".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 1,
new_position: 0,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
state.apply(
&mut cursors,
&Event::AddCursor {
position: 1,
cursor_id: CursorId(1),
anchor: None,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::InsertChar('f'),
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
let events = action_to_events(
&mut state,
&mut cursors,
Action::InsertChar('o'),
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
let events = action_to_events(
&mut state,
&mut cursors,
Action::InsertChar('o'),
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(
state.buffer.to_string().unwrap(),
"foo\nfoo",
"Before typing '(' we should have just 'foo' on each line"
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::InsertChar('('),
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(
state.buffer.to_string().unwrap(),
"foo()\nfoo()",
"Auto-close should add closing paren: typing '(' should produce '()'"
);
let cursor_positions: Vec<_> = cursors.iter().map(|(_, c)| c.position).collect();
assert!(
cursor_positions.contains(&4) && cursor_positions.contains(&10),
"Cursors should be between parens at positions 4 and 10, got: {:?}",
cursor_positions
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::InsertChar(')'),
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(
state.buffer.to_string().unwrap(),
"foo()\nfoo()",
"Closing paren should skip over existing paren, not create 'foo())'"
);
}
#[test]
fn test_bracket_auto_close_three_cursors_with_skip_over() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "\n\n".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 2,
new_position: 0,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
state.apply(
&mut cursors,
&Event::AddCursor {
position: 1,
cursor_id: CursorId(1),
anchor: None,
},
);
state.apply(
&mut cursors,
&Event::AddCursor {
position: 2,
cursor_id: CursorId(2),
anchor: None,
},
);
for ch in ['f', 'o', 'o'] {
let events = action_to_events(
&mut state,
&mut cursors,
Action::InsertChar(ch),
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
}
assert_eq!(
state.buffer.to_string().unwrap(),
"foo\nfoo\nfoo",
"Before typing '(' we should have 'foo' on each line"
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::InsertChar('('),
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(
state.buffer.to_string().unwrap(),
"foo()\nfoo()\nfoo()",
"Auto-close should add closing paren on all three lines"
);
let cursor_positions: Vec<_> = cursors.iter().map(|(_, c)| c.position).collect();
assert!(
cursor_positions.contains(&4)
&& cursor_positions.contains(&10)
&& cursor_positions.contains(&16),
"Cursors should be between parens at positions 4, 10, and 16, got: {:?}",
cursor_positions
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::InsertChar(')'),
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(
state.buffer.to_string().unwrap(),
"foo()\nfoo()\nfoo()",
"Closing paren should skip over existing paren on ALL THREE lines"
);
}
#[test]
fn test_auto_pair_deletion_parenthesis() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "()".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 2,
new_position: 1,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
assert_eq!(state.buffer.to_string().unwrap(), "()");
assert_eq!(cursors.primary().position, 1);
let events = action_to_events(
&mut state,
&mut cursors,
Action::DeleteBackward,
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), "");
assert_eq!(cursors.primary().position, 0);
}
#[test]
fn test_auto_pair_deletion_curly_brace() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "{}".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 2,
new_position: 1,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::DeleteBackward,
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), "");
}
#[test]
fn test_auto_pair_deletion_double_quote() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "\"\"".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 2,
new_position: 1,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::DeleteBackward,
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), "");
}
#[test]
fn test_auto_pair_deletion_disabled_when_auto_indent_false() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "()".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 2,
new_position: 1,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::DeleteBackward,
4,
false,
false,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), ")");
assert_eq!(cursors.primary().position, 0);
}
#[test]
fn test_auto_pair_deletion_not_matching() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "(]".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 2,
new_position: 1,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::DeleteBackward,
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), "]");
assert_eq!(cursors.primary().position, 0);
}
#[test]
fn test_auto_pair_deletion_with_content() {
let mut state = EditorState::new(
80,
24,
crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "(abc)".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 5,
new_position: 2,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let events = action_to_events(
&mut state,
&mut cursors,
Action::DeleteBackward,
4,
true,
true,
true,
80,
24,
)
.unwrap();
for event in events {
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), "(bc)");
}
}
#[cfg(test)]
mod property_tests {
use crate::model::filesystem::StdFileSystem;
use std::sync::Arc;
fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
Arc::new(StdFileSystem)
}
use super::*;
use proptest::prelude::*;
fn text_with_newlines() -> impl Strategy<Value = Vec<u8>> {
prop::collection::vec(
prop_oneof![(b'a'..=b'z').prop_map(|c| c), Just(b'\n'),],
0..200,
)
}
proptest! {
#[test]
fn prop_collect_line_starts_returns_valid_positions(
text in text_with_newlines(),
start_frac in 0.0f64..=1.0,
end_frac in 0.0f64..=1.0,
) {
if text.is_empty() {
return Ok(());
}
let mut buffer = Buffer::from_bytes(text.clone(), test_fs());
let buffer_len = buffer.len();
let start_pos = (start_frac * buffer_len as f64) as usize;
let end_pos = (end_frac * buffer_len as f64) as usize;
let (start_pos, end_pos) = if start_pos <= end_pos {
(start_pos, end_pos)
} else {
(end_pos, start_pos)
};
let line_starts = collect_line_starts(&mut buffer, start_pos, end_pos, 80);
for &pos in &line_starts {
prop_assert!(pos <= end_pos, "Position {} exceeds end_pos {}", pos, end_pos);
prop_assert!(pos <= buffer_len, "Position {} exceeds buffer_len {}", pos, buffer_len);
}
for &pos in &line_starts {
if pos == 0 {
continue; }
let prev_byte = buffer.get_text_range_mut(pos - 1, 1).unwrap();
prop_assert_eq!(
prev_byte[0], b'\n',
"Position {} is not a valid line start (preceded by {:?})",
pos, prev_byte
);
}
for window in line_starts.windows(2) {
prop_assert!(
window[0] < window[1],
"Positions not strictly increasing: {} >= {}",
window[0], window[1]
);
}
let mut expected_line_starts: Vec<usize> = vec![0];
for (i, &byte) in text.iter().enumerate() {
if byte == b'\n' && i < buffer_len {
expected_line_starts.push(i + 1);
}
}
let first_line_start = expected_line_starts.iter()
.filter(|&&pos| pos <= start_pos)
.max()
.copied()
.unwrap_or(0);
let expected_in_range: Vec<usize> = expected_line_starts.iter()
.filter(|&&pos| {
pos >= first_line_start
&& (pos < end_pos || (pos == end_pos && pos == start_pos))
})
.copied()
.collect();
prop_assert_eq!(
line_starts, expected_in_range,
"Line starts mismatch for text {:?} with start={} end={}",
String::from_utf8_lossy(&text), start_pos, end_pos
);
}
#[test]
fn prop_collect_line_starts_edge_cases(
text in text_with_newlines(),
) {
if text.is_empty() {
return Ok(());
}
let mut buffer = Buffer::from_bytes(text.clone(), test_fs());
let buffer_len = buffer.len();
let mid = buffer_len / 2;
let line_starts = collect_line_starts(&mut buffer, mid, mid, 80);
prop_assert!(line_starts.len() <= 1, "Single position range should have at most 1 line start");
let line_starts = collect_line_starts(&mut buffer, 0, buffer_len, 80);
prop_assert!(!line_starts.is_empty(), "Full range should have at least one line start");
prop_assert_eq!(line_starts[0], 0, "First line start should be 0 for full range starting at 0");
if buffer_len > 0 {
let line_starts = collect_line_starts(&mut buffer, buffer_len - 1, buffer_len, 80);
prop_assert!(!line_starts.is_empty(), "End range should have at least one line start");
}
}
#[test]
fn prop_collect_line_starts_trailing_newline(
prefix in "[a-z]{0,20}",
num_trailing_newlines in 0usize..5,
) {
let text = format!("{}{}", prefix, "\n".repeat(num_trailing_newlines));
if text.is_empty() {
return Ok(());
}
let mut buffer = Buffer::from_bytes(text.as_bytes().to_vec(), test_fs());
let buffer_len = buffer.len();
let line_starts = collect_line_starts(&mut buffer, 0, buffer_len, 80);
let expected_count = if num_trailing_newlines > 0 {
num_trailing_newlines
} else {
1
};
prop_assert_eq!(
line_starts.len(), expected_count,
"Text {:?} (len={}) should have {} line starts, got {:?}",
text, buffer_len, expected_count, line_starts
);
}
}
}