Multi-cursor editing allows users to edit text at multiple positions simultaneously. This implementation lives entirely within FerriteEditor and supports:
- Ctrl+Click: Add cursor at clicked position
- Simultaneous typing: Text inserted at all cursor positions
- Simultaneous deletion: Backspace/Delete affects all cursors
- Cursor navigation: Arrow keys move all cursors together
- Escape: Clear extra cursors, return to single cursor
| File | Purpose |
|---|---|
src/editor/ferrite/editor.rs |
Main implementation - selections: Vec<Selection>, edit methods |
src/editor/ferrite/cursor.rs |
Selection and Cursor types |
// In FerriteEditor struct
pub(crate) selections: Vec<Selection>,
pub(crate) primary_selection_index: usize,Replaced single selection: Selection with a vector of selections. The primary selection is tracked by index and used for:
- IME input positioning
- Bracket matching reference point
- Single-cursor fallback operations
add_cursor(cursor: Cursor)- Add a new cursor positionclear_extra_cursors()- Remove all but primary cursorhas_multiple_cursors()- Check if multi-cursor mode is activemerge_overlapping_selections()- Combine overlapping selections after edits
insert_text_at_all_cursors(text: &str)- Insert text at every cursorbackspace_at_all_cursors()- Delete char before each cursordelete_at_all_cursors()- Delete char after each cursormove_all_cursors(key, modifiers)- Apply navigation to all cursors
When editing with multiple cursors, changes at earlier positions affect the character offsets of later cursors. The solution has two critical parts:
Critical: Cursor positions must be captured as character offsets BEFORE any buffer modifications. After the buffer changes, the old Cursor line/column values become invalid and can cause panics.
// CORRECT: Capture positions first
let original_positions: Vec<(usize, usize)> = self.selections
.iter()
.enumerate()
.map(|(idx, s)| (idx, cursor_to_char_pos(&self.buffer, &s.head)))
.collect();
// Then modify buffer...
// Then recalculate cursor positions from char offsetsProcessing deletions from end to start ensures earlier positions remain valid:
- Capture all original char positions before any modifications
- Calculate delete targets (char_pos - 1 for backspace, char_pos for delete)
- Sort descending and deduplicate - process from end first
- Perform deletions - no offset adjustment needed when going backwards
- Recalculate cursor positions based on how many deletions occurred before each
// Example: backspace at all cursors
// 1. Capture original positions
let original_positions: Vec<(usize, usize)> = self.selections
.iter()
.enumerate()
.map(|(idx, s)| (idx, cursor_to_char_pos(&self.buffer, &s.head)))
.collect();
// 2. Get unique delete targets (char BEFORE cursor)
let mut delete_targets: Vec<usize> = original_positions
.iter()
.filter_map(|(_, pos)| if *pos > 0 { Some(*pos - 1) } else { None })
.collect();
delete_targets.sort_by(|a, b| b.cmp(a));
delete_targets.dedup();
// 3. Delete from end to start
for delete_at in &delete_targets {
self.buffer.remove(*delete_at, 1);
}
// 4. Recalculate positions
delete_targets.sort(); // ascending for offset calculation
for (idx, original_pos) in original_positions {
let deletions_before = delete_targets.iter()
.filter(|&&del_pos| del_pos < original_pos)
.count();
let new_pos = original_pos.saturating_sub(deletions_before);
// Convert new_pos back to Cursor...
}After edits or navigation, cursors may overlap. The merge algorithm:
- Sort selections by start position
- Iterate through, merging adjacent/overlapping selections
- Update
primary_selection_indexif it becomes invalid
When multiple cursors exist, navigation keys (arrows, Home, End) move ALL cursors:
if is_navigation && self.has_multiple_cursors() {
self.move_all_cursors(*key, modifiers);
continue;
}Each cursor moves independently based on its position (e.g., ArrowUp from different columns results in different final columns).
- Ctrl+Click: Add cursor at clicked position
- Cursors are rendered as vertical lines (same as primary cursor)
- Selections are rendered with semi-transparent background
- Type normally - text appears at all cursor positions
- Backspace/Delete - works at all positions
- Arrow keys - all cursors move together
- Escape: Return to single cursor mode
- Clicking without Ctrl: Also clears extra cursors
cargo run --release- Ctrl+Click 3+ different positions
- Type "hello" - should appear at all positions
- Press Backspace 3x - should delete "llo" from all
- Press ArrowRight - all cursors move right
- Press Escape - return to single cursor
- Cursors at same position: Deduplicated, single edit
- Cursors that merge after deletion: Combined automatically
- Cursor at buffer start: Backspace is no-op for that cursor
- Cursor at buffer end: Delete is no-op for that cursor
- Lines deleted during edit: Cursor positions recalculated from char offsets (no panic)
- Multiple cursors on same line: Each deletes independently, positions tracked correctly
- Cursor operations are O(n) where n = number of cursors
- Sorting for offset adjustment is O(n log n)
- Merge is O(n) with single pass
- Typical use: 2-10 cursors, so performance is not a concern
- No "Ctrl+D" for select next occurrence (future enhancement)
- No column selection mode (future enhancement)
- Undo/redo doesn't track multi-cursor state (uses single selection)