fresh/input/
multi_cursor.rs

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