Skip to main content

xei_core/
multi_cursor.rs

1//! Multi-cursor (v1) — primary + extra carets for insert/edit.
2
3use crate::buffer::{Buffer, Position};
4
5/// Extra carets beyond `Buffer.cursor` (the primary).
6#[derive(Debug, Clone, Default)]
7pub struct MultiCursor {
8    pub extras: Vec<Position>,
9}
10
11impl MultiCursor {
12    pub fn new() -> Self {
13        Self::default()
14    }
15
16    pub fn clear(&mut self) {
17        self.extras.clear();
18    }
19
20    pub fn is_active(&self) -> bool {
21        !self.extras.is_empty()
22    }
23
24    pub fn count(&self, _primary: Position) -> usize {
25        1 + self.extras.len()
26    }
27
28    /// All cursors including primary, sorted document order, deduped.
29    pub fn all(&self, primary: Position) -> Vec<Position> {
30        let mut v = vec![primary];
31        v.extend(self.extras.iter().copied());
32        v.sort_by(|a, b| a.row.cmp(&b.row).then(a.col.cmp(&b.col)));
33        v.dedup();
34        v
35    }
36
37    /// After an edit, replace set from new primary + extras (already sorted).
38    pub fn set_from_all(&mut self, mut all: Vec<Position>) {
39        all.sort_by(|a, b| a.row.cmp(&b.row).then(a.col.cmp(&b.col)));
40        all.dedup();
41        if all.is_empty() {
42            self.extras.clear();
43            return;
44        }
45        // Keep first as primary (caller assigns buffer.cursor)
46        self.extras = all.into_iter().skip(1).collect();
47    }
48
49    pub fn add(&mut self, primary: Position, pos: Position) {
50        if pos == primary {
51            return;
52        }
53        if !self.extras.iter().any(|p| *p == pos) {
54            self.extras.push(pos);
55            self.extras
56                .sort_by(|a, b| a.row.cmp(&b.row).then(a.col.cmp(&b.col)));
57        }
58    }
59
60    pub fn remove_last(&mut self) -> bool {
61        self.extras.pop().is_some()
62    }
63
64    /// Clamp every cursor to buffer bounds.
65    pub fn clamp_all(&mut self, buf: &Buffer) {
66        let max_row = buf.line_count().saturating_sub(1);
67        for p in &mut self.extras {
68            if p.row > max_row {
69                p.row = max_row;
70            }
71            let max_col = buf.line(p.row).chars().count();
72            if p.col > max_col {
73                p.col = max_col;
74            }
75        }
76        self.extras.retain(|p| p.row <= max_row);
77    }
78}
79
80/// Word under cursor for multi-cursor "select next".
81pub fn word_at(buf: &Buffer, pos: Position) -> Option<(Position, Position, String)> {
82    let line = buf.line(pos.row);
83    let chars: Vec<char> = line.chars().collect();
84    if chars.is_empty() {
85        return None;
86    }
87    let col = pos.col.min(chars.len().saturating_sub(1).max(0));
88    if col >= chars.len() && chars.is_empty() {
89        return None;
90    }
91    let c = if col < chars.len() {
92        chars[col]
93    } else if col > 0 {
94        chars[col - 1]
95    } else {
96        return None;
97    };
98    if !(c.is_alphanumeric() || c == '_') {
99        return None;
100    }
101    let mut start = col.min(chars.len().saturating_sub(1));
102    let mut end = start;
103    while start > 0 && (chars[start - 1].is_alphanumeric() || chars[start - 1] == '_') {
104        start -= 1;
105    }
106    while end + 1 < chars.len() && (chars[end + 1].is_alphanumeric() || chars[end + 1] == '_') {
107        end += 1;
108    }
109    let word: String = chars[start..=end].iter().collect();
110    Some((
111        Position {
112            row: pos.row,
113            col: start,
114        },
115        Position {
116            row: pos.row,
117            col: end + 1,
118        },
119        word,
120    ))
121}
122
123/// Find next occurrence of `word` after `from` (exclusive start position).
124pub fn find_next(buf: &Buffer, word: &str, from: Position) -> Option<Position> {
125    if word.is_empty() {
126        return None;
127    }
128    let n = buf.line_count();
129    // Search current line after col, then following lines
130    for row in from.row..n {
131        let line = buf.line(row);
132        let start_col = if row == from.row { from.col } else { 0 };
133        let chars: Vec<char> = line.chars().collect();
134        if start_col >= chars.len() {
135            continue;
136        }
137        let s: String = chars[start_col..].iter().collect();
138        if let Some(rel) = s.find(word) {
139            // Verify word boundary-ish: check not mid-identifier for alphanumeric words
140            let abs = start_col + rel;
141            if is_word_match(&chars, abs, word) {
142                return Some(Position {
143                    row,
144                    col: abs,
145                });
146            }
147            // keep searching same line for next
148            let mut search_from = abs + 1;
149            while search_from < chars.len() {
150                let rest: String = chars[search_from..].iter().collect();
151                if let Some(r2) = rest.find(word) {
152                    let abs2 = search_from + r2;
153                    if is_word_match(&chars, abs2, word) {
154                        return Some(Position {
155                            row,
156                            col: abs2,
157                        });
158                    }
159                    search_from = abs2 + 1;
160                } else {
161                    break;
162                }
163            }
164        }
165    }
166    // Wrap from top
167    for row in 0..=from.row {
168        let line = buf.line(row);
169        let chars: Vec<char> = line.chars().collect();
170        let limit = if row == from.row {
171            from.col.min(chars.len())
172        } else {
173            chars.len()
174        };
175        let s: String = chars[..limit].iter().collect();
176        if let Some(abs) = s.find(word) {
177            if is_word_match(&chars, abs, word) {
178                return Some(Position { row, col: abs });
179            }
180        }
181    }
182    None
183}
184
185fn is_word_match(chars: &[char], start: usize, word: &str) -> bool {
186    let wchars: Vec<char> = word.chars().collect();
187    if start + wchars.len() > chars.len() {
188        return false;
189    }
190    if chars[start..start + wchars.len()] != wchars[..] {
191        return false;
192    }
193    let before_ok = start == 0
194        || !(chars[start - 1].is_alphanumeric() || chars[start - 1] == '_');
195    let after = start + wchars.len();
196    let after_ok = after >= chars.len()
197        || !(chars[after].is_alphanumeric() || chars[after] == '_');
198    before_ok && after_ok
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::buffer::Buffer;
205
206    #[test]
207    fn find_next_word() {
208        let buf = Buffer::from_string("foo bar foo\nfoo");
209        let p = find_next(&buf, "foo", Position { row: 0, col: 1 }).unwrap();
210        assert_eq!(p, Position { row: 0, col: 8 });
211    }
212}