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/// 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}