fresh/input/multi_cursor.rs
1//! Multi-cursor operations for adding cursors at various positions
2
3use crate::model::cursor::{Cursor, Cursors};
4use crate::primitives::word_navigation::{find_word_end, find_word_start};
5use crate::state::EditorState;
6
7/// Result of attempting to add a cursor
8pub enum AddCursorResult {
9 /// Cursor was added successfully
10 Success {
11 cursor: Cursor,
12 total_cursors: usize,
13 },
14 /// Word was selected (no new cursor added, but primary cursor selection changed)
15 /// This happens when Ctrl+D is pressed with no selection - it selects the current word
16 WordSelected { word_start: usize, word_end: usize },
17 /// Operation failed with a message
18 Failed { message: String },
19}
20
21/// Information about a cursor's position within its line
22struct CursorLineInfo {
23 /// Byte offset of the line start
24 line_start: usize,
25 /// Column offset from line start
26 col_offset: usize,
27}
28
29/// Get line info for a cursor position
30fn get_cursor_line_info(state: &mut EditorState, position: usize) -> Option<CursorLineInfo> {
31 let mut iter = state.buffer.line_iterator(position, 80);
32 let (line_start, _) = iter.next_line()?;
33 Some(CursorLineInfo {
34 line_start,
35 col_offset: position.saturating_sub(line_start),
36 })
37}
38
39/// Calculate cursor position on a line, clamping to line length (excluding newline)
40fn cursor_position_on_line(line_start: usize, line_content: &str, target_col: usize) -> usize {
41 let line_len = line_content.trim_end_matches('\n').len();
42 line_start + target_col.min(line_len)
43}
44
45/// Create a successful AddCursorResult
46fn success_result(cursor: Cursor, cursors: &Cursors) -> AddCursorResult {
47 AddCursorResult::Success {
48 cursor,
49 total_cursors: cursors.iter().count() + 1,
50 }
51}
52
53/// Adjust cursor position if it's on a newline character
54/// Returns position + 1 if cursor is at a newline, otherwise returns position unchanged
55fn adjust_position_for_newline(state: &mut EditorState, position: usize) -> usize {
56 if position < state.buffer.len() {
57 if let Ok(byte_at_cursor) = state.buffer.get_text_range_mut(position, 1) {
58 if byte_at_cursor.first() == Some(&b'\n') {
59 return position + 1;
60 }
61 }
62 }
63 position
64}
65
66/// Add a cursor at the next occurrence of the selected text
67/// If no selection, selects the entire word at cursor position first
68pub fn add_cursor_at_next_match(state: &mut EditorState, cursors: &Cursors) -> AddCursorResult {
69 // Get the selected text from the primary cursor
70 let primary = cursors.primary();
71 let selection_range = match primary.selection_range() {
72 Some(range) => range,
73 None => {
74 // No selection - select the entire word at cursor position
75 let cursor_pos = primary.position;
76 let word_start = find_word_start(&state.buffer, cursor_pos);
77
78 // Determine word_end: if we're just past a word (at a non-word char but
79 // word_start < cursor_pos), use cursor_pos as the end. This handles the
80 // case where cursor is at the space right after a word.
81 let word_end = if word_start < cursor_pos {
82 // Check if we're at a word character
83 let at_word_char = if cursor_pos < state.buffer.len() {
84 if let Ok(bytes) = state.buffer.get_text_range_mut(cursor_pos, 1) {
85 bytes
86 .first()
87 .map(|&b| crate::primitives::word_navigation::is_word_char(b))
88 .unwrap_or(false)
89 } else {
90 false
91 }
92 } else {
93 false
94 };
95
96 if at_word_char {
97 // We're in the middle of a word, find the actual end
98 find_word_end(&state.buffer, cursor_pos)
99 } else {
100 // We're just past a word, use cursor position as end
101 cursor_pos
102 }
103 } else {
104 // word_start == cursor_pos, find the end normally
105 find_word_end(&state.buffer, cursor_pos)
106 };
107
108 // If cursor is on whitespace or punctuation (word_start == word_end), fail
109 if word_start == word_end {
110 return AddCursorResult::Failed {
111 message: "No word at cursor position".to_string(),
112 };
113 }
114
115 // Return WordSelected so caller can update the cursor's selection
116 return AddCursorResult::WordSelected {
117 word_start,
118 word_end,
119 };
120 }
121 };
122
123 // Determine if the original selection is "backward" (cursor at start of selection)
124 let cursor_at_start = primary.position == selection_range.start;
125
126 // Extract the selected text
127 let pattern = state.get_text_range(selection_range.start, selection_range.end);
128 let pattern_len = pattern.len();
129
130 // Start searching from the end of the current selection
131 let mut search_start = selection_range.end;
132 let _ign = search_start; // To prevent infinite loops (unused now)
133
134 // Loop until we find a match that isn't already occupied by a cursor
135 loop {
136 let match_pos = match state.buffer.find_next(&pattern, search_start) {
137 Some(pos) => pos,
138 None => {
139 // If finding next failed even with wrap-around (implied by buffer.find_next usually),
140 // then truly no matches exist.
141 return AddCursorResult::Failed {
142 message: "No more matches".to_string(),
143 };
144 }
145 };
146
147 // Calculate the range of the found match
148 let match_range = match_pos..(match_pos + pattern_len);
149
150 // Check if any existing cursor overlaps with this match
151 let is_occupied = cursors.iter().any(|(_, c)| {
152 if let Some(r) = c.selection_range() {
153 r == match_range
154 } else {
155 false
156 }
157 });
158
159 if !is_occupied {
160 // Found a free match!
161 let match_start = match_pos;
162 let match_end = match_pos + pattern_len;
163 let new_cursor = if cursor_at_start {
164 let mut cursor = Cursor::new(match_start);
165 cursor.set_anchor(match_end);
166 cursor
167 } else {
168 Cursor::with_selection(match_start, match_end)
169 };
170 return success_result(new_cursor, cursors);
171 }
172
173 // If we wrapped around and came back to where we started searching (or past it), stop to avoid infinite loop
174 // We need to handle the case where find_next wraps around.
175 // Assuming buffer.find_next does wrap around:
176 // If match_pos <= search_start and we haven't wrapped explicitly, it means we wrapped.
177
178 // Let's refine the search start. We want to search *after* this occupied match.
179 // If match_pos is behind us, we wrapped.
180
181 let next_start = match_pos + pattern_len;
182
183 // Simple cycle detection: if we are stuck on the same spot or have cycled through the whole buffer
184 // Ideally we check if we've visited this match_pos before, but checking if we passed initial_start again is a decent proxy
185 // provided we handle the wrap-around logic correctly.
186
187 // If find_next scans the whole buffer, it might return the same spot if it's the only match.
188 // If it's occupied, we are done.
189
190 // To be safe against infinite loops if all matches are occupied:
191 if match_pos == selection_range.start {
192 // We wrapped all the way back to the primary cursor without finding a free spot
193 return AddCursorResult::Failed {
194 message: "All matches are already selected".to_string(),
195 };
196 }
197
198 search_start = next_start;
199 }
200}
201
202/// Add a cursor above the primary cursor at the same column
203pub fn add_cursor_above(state: &mut EditorState, cursors: &Cursors) -> AddCursorResult {
204 let position = cursors.primary().position;
205
206 // Adjust position if cursor is at a newline character
207 // This handles cases where add_cursor_above/below places cursor at same column
208 let adjusted_position = adjust_position_for_newline(state, position);
209
210 // Get current line info
211 let Some(info) = get_cursor_line_info(state, adjusted_position) else {
212 return AddCursorResult::Failed {
213 message: "Unable to find current line".to_string(),
214 };
215 };
216
217 // Check if we're on the first line
218 if info.line_start == 0 {
219 return AddCursorResult::Failed {
220 message: "Already at first line".to_string(),
221 };
222 }
223
224 // Navigate to previous line using iterator
225 let mut iter = state.buffer.line_iterator(adjusted_position, 80);
226 iter.next_line(); // Consume current line
227 iter.prev(); // Move back to current line
228
229 // Get the previous line
230 if let Some((prev_line_start, prev_line_content)) = iter.prev() {
231 let new_pos = cursor_position_on_line(prev_line_start, &prev_line_content, info.col_offset);
232 success_result(Cursor::new(new_pos), cursors)
233 } else {
234 AddCursorResult::Failed {
235 message: "Already at first line".to_string(),
236 }
237 }
238}
239
240/// Add a cursor below the primary cursor at the same column
241pub fn add_cursor_below(state: &mut EditorState, cursors: &Cursors) -> AddCursorResult {
242 let position = cursors.primary().position;
243
244 // Get current line info
245 let Some(info) = get_cursor_line_info(state, position) else {
246 return AddCursorResult::Failed {
247 message: "Unable to find current line".to_string(),
248 };
249 };
250
251 // Navigate to next line using iterator
252 let mut iter = state.buffer.line_iterator(position, 80);
253 iter.next_line(); // Consume current line
254
255 // Get next line
256 if let Some((next_line_start, next_line_content)) = iter.next_line() {
257 let new_pos = cursor_position_on_line(next_line_start, &next_line_content, info.col_offset);
258 success_result(Cursor::new(new_pos), cursors)
259 } else {
260 AddCursorResult::Failed {
261 message: "Already at last line".to_string(),
262 }
263 }
264}