Skip to main content

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/// Compute end-of-line byte positions for every line covered by ANY existing
241/// cursor's selection (or the cursor's current line when there is no
242/// selection). Used to place cursors at the end of each line, matching
243/// VSCode's "Add Cursor to Line Ends" / Sublime's "Split Selection into Lines"
244/// semantics where every existing cursor contributes, not just the primary.
245///
246/// "End of line" is the byte offset immediately before the trailing `\n`
247/// (or `\r\n`), or the position past the last byte for the final line of a
248/// buffer without a trailing newline.
249///
250/// Returned positions are sorted in document order and deduplicated, so two
251/// cursors on the same line collapse to a single line-end.
252pub fn line_end_positions_in_selection(state: &mut EditorState, cursors: &Cursors) -> Vec<usize> {
253    let mut positions = Vec::new();
254    for (_id, cursor) in cursors.iter() {
255        let (range_start, range_end) = match cursor.selection_range() {
256            Some(range) => (range.start, range.end),
257            None => (cursor.position, cursor.position),
258        };
259        let mut iter = state.buffer.line_iterator(range_start, 80);
260        while let Some((line_start, line_content)) = iter.next_line() {
261            // Stop when we've moved past the selection's end line.
262            // The line we want to include is the one containing range_end;
263            // a line strictly after that has line_start > range_end.
264            if line_start > range_end {
265                break;
266            }
267            let trimmed = line_content
268                .trim_end_matches('\n')
269                .trim_end_matches('\r')
270                .len();
271            positions.push(line_start + trimmed);
272        }
273    }
274    positions.sort_unstable();
275    positions.dedup();
276    positions
277}
278
279/// Add a cursor below the primary cursor at the same column
280pub fn add_cursor_below(state: &mut EditorState, cursors: &Cursors) -> AddCursorResult {
281    let position = cursors.primary().position;
282
283    // Get current line info
284    let Some(info) = get_cursor_line_info(state, position) else {
285        return AddCursorResult::Failed {
286            message: "Unable to find current line".to_string(),
287        };
288    };
289
290    // Navigate to next line using iterator
291    let mut iter = state.buffer.line_iterator(position, 80);
292    iter.next_line(); // Consume current line
293
294    // Get next line
295    if let Some((next_line_start, next_line_content)) = iter.next_line() {
296        let new_pos = cursor_position_on_line(next_line_start, &next_line_content, info.col_offset);
297        success_result(Cursor::new(new_pos), cursors)
298    } else {
299        AddCursorResult::Failed {
300            message: "Already at last line".to_string(),
301        }
302    }
303}