Skip to main content

editor_core/
cursor_ops.rs

1//! Cursor, selection, and word-boundary command helpers.
2
3use super::*;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub(super) enum TextBoundary {
7    Grapheme,
8    Word,
9}
10
11/// Word boundary configuration used by editor-friendly "word" operations (selection expansion,
12/// double-click selection, etc.).
13///
14/// This intentionally follows a **code-editor** notion of "word":
15/// - By default, **ASCII identifier-like runs** are treated as words.
16/// - Non-ASCII characters are treated as word boundaries (so they form single-character "word units").
17/// - Whitespace always separates words.
18#[derive(Debug, Clone)]
19pub struct WordBoundaryConfig {
20    ascii_is_boundary: [bool; 128],
21}
22
23impl Default for WordBoundaryConfig {
24    fn default() -> Self {
25        let mut ascii_is_boundary = [true; 128];
26        for b in 0u8..=127 {
27            let ch = b as char;
28            if ch.is_ascii_alphanumeric() || ch == '_' {
29                ascii_is_boundary[b as usize] = false;
30            }
31        }
32        // Always treat ASCII whitespace as boundary.
33        ascii_is_boundary[b' ' as usize] = true;
34        ascii_is_boundary[b'\t' as usize] = true;
35        ascii_is_boundary[b'\n' as usize] = true;
36        ascii_is_boundary[b'\r' as usize] = true;
37        Self { ascii_is_boundary }
38    }
39}
40
41impl WordBoundaryConfig {
42    /// Override the ASCII "word boundary" character set.
43    ///
44    /// - Non-ASCII characters are always treated as boundaries (and therefore form single-character word units).
45    /// - ASCII whitespace is always treated as boundary.
46    ///
47    /// This is similar in spirit to VSCode's `wordSeparators`.
48    pub fn set_ascii_boundary_chars(&mut self, boundary_chars: &str) {
49        self.ascii_is_boundary = [false; 128];
50        // Always treat ASCII whitespace as boundary.
51        self.ascii_is_boundary[b' ' as usize] = true;
52        self.ascii_is_boundary[b'\t' as usize] = true;
53        self.ascii_is_boundary[b'\n' as usize] = true;
54        self.ascii_is_boundary[b'\r' as usize] = true;
55
56        for ch in boundary_chars.chars() {
57            if ch.is_ascii() {
58                self.ascii_is_boundary[ch as usize] = true;
59            }
60        }
61    }
62
63    pub(super) fn is_ascii_word_char(&self, ch: char) -> bool {
64        if !ch.is_ascii() {
65            return false;
66        }
67        !self.ascii_is_boundary[ch as usize]
68    }
69
70    pub(super) fn is_word_token_char(&self, ch: char) -> bool {
71        if ch.is_whitespace() {
72            return false;
73        }
74        if ch.is_ascii() {
75            self.is_ascii_word_char(ch)
76        } else {
77            // Treat non-ASCII as "word units" of length 1.
78            true
79        }
80    }
81}
82
83pub(super) fn byte_offset_for_char_column(text: &str, column: usize) -> usize {
84    if column == 0 {
85        return 0;
86    }
87
88    text.char_indices()
89        .nth(column)
90        .map(|(byte, _)| byte)
91        .unwrap_or_else(|| text.len())
92}
93
94pub(super) fn char_column_for_byte_offset(text: &str, byte_offset: usize) -> usize {
95    text.get(..byte_offset).unwrap_or(text).chars().count()
96}
97
98pub(super) fn leading_horizontal_whitespace(text: &str) -> (usize, usize) {
99    let mut column = 0usize;
100    for (byte, ch) in text.char_indices() {
101        if ch != ' ' && ch != '\t' {
102            return (column, byte);
103        }
104        column += 1;
105    }
106
107    (column, text.len())
108}
109
110pub(super) fn prev_boundary_column(text: &str, column: usize, boundary: TextBoundary) -> usize {
111    let byte_pos = byte_offset_for_char_column(text, column);
112
113    let mut prev = 0usize;
114    match boundary {
115        TextBoundary::Grapheme => {
116            for (b, _) in text.grapheme_indices(true) {
117                if b >= byte_pos {
118                    break;
119                }
120                prev = b;
121            }
122        }
123        TextBoundary::Word => {
124            for (b, _) in text.split_word_bound_indices() {
125                if b >= byte_pos {
126                    break;
127                }
128                prev = b;
129            }
130        }
131    }
132
133    char_column_for_byte_offset(text, prev)
134}
135
136pub(super) fn next_boundary_column(text: &str, column: usize, boundary: TextBoundary) -> usize {
137    let byte_pos = byte_offset_for_char_column(text, column);
138
139    let mut next = text.len();
140    match boundary {
141        TextBoundary::Grapheme => {
142            for (b, _) in text.grapheme_indices(true) {
143                if b > byte_pos {
144                    next = b;
145                    break;
146                }
147            }
148        }
149        TextBoundary::Word => {
150            for (b, _) in text.split_word_bound_indices() {
151                if b > byte_pos {
152                    next = b;
153                    break;
154                }
155            }
156        }
157    }
158
159    char_column_for_byte_offset(text, next)
160}
161
162impl CommandExecutor {
163    pub(super) fn execute_select_line_command(&mut self) -> Result<CommandResult, CommandError> {
164        let snapshot = self.snapshot_selection_set();
165        let selections = snapshot.selections;
166        let primary_index = snapshot.primary_index;
167
168        let line_count = self.editor.line_index.line_count();
169        if line_count == 0 {
170            return Ok(CommandResult::Success);
171        }
172
173        let mut next: Vec<Selection> = Vec::with_capacity(selections.len());
174        for sel in selections {
175            let (min_pos, max_pos) = crate::selection_set::selection_min_max(&sel);
176            let start_line = min_pos.line.min(line_count.saturating_sub(1));
177            let end_line = max_pos.line.min(line_count.saturating_sub(1));
178
179            let start = Position::new(start_line, 0);
180            let end = if end_line + 1 < line_count {
181                Position::new(end_line + 1, 0)
182            } else {
183                let line_text = self
184                    .editor
185                    .line_index
186                    .get_line_text(end_line)
187                    .unwrap_or_default();
188                Position::new(end_line, line_text.chars().count())
189            };
190
191            next.push(Selection {
192                start,
193                end,
194                direction: SelectionDirection::Forward,
195            });
196        }
197
198        self.execute_cursor(CursorCommand::SetSelections {
199            selections: next,
200            primary_index,
201        })?;
202        Ok(CommandResult::Success)
203    }
204
205    pub(super) fn execute_select_word_command(&mut self) -> Result<CommandResult, CommandError> {
206        let snapshot = self.snapshot_selection_set();
207        let selections = snapshot.selections;
208        let primary_index = snapshot.primary_index;
209
210        let line_count = self.editor.line_index.line_count();
211        if line_count == 0 {
212            return Ok(CommandResult::Success);
213        }
214
215        let mut next: Vec<Selection> = Vec::with_capacity(selections.len());
216
217        for sel in selections {
218            // If already a non-empty selection, keep it.
219            if sel.start != sel.end {
220                next.push(sel);
221                continue;
222            }
223
224            let caret = sel.end;
225            let line = caret.line.min(line_count.saturating_sub(1));
226            let line_text = self
227                .editor
228                .line_index
229                .get_line_text(line)
230                .unwrap_or_default();
231            let col = caret.column.min(line_text.chars().count());
232
233            let Some((start_col, end_col)) = self.word_token_range_in_line(&line_text, col) else {
234                next.push(sel);
235                continue;
236            };
237
238            let start = Position::new(line, start_col);
239            let end = Position::new(line, end_col);
240
241            next.push(Selection {
242                start,
243                end,
244                direction: SelectionDirection::Forward,
245            });
246        }
247
248        self.execute_cursor(CursorCommand::SetSelections {
249            selections: next,
250            primary_index,
251        })?;
252        Ok(CommandResult::Success)
253    }
254
255    pub(super) fn execute_expand_selection_command(
256        &mut self,
257    ) -> Result<CommandResult, CommandError> {
258        // Basic expand policy:
259        // - empty selection => select word
260        // - non-empty selection => select line(s)
261        let snapshot = self.snapshot_selection_set();
262        if snapshot.selections.iter().any(|s| s.start != s.end) {
263            self.execute_select_line_command()
264        } else {
265            self.execute_select_word_command()
266        }
267    }
268
269    pub(super) fn execute_expand_selection_by_command(
270        &mut self,
271        unit: ExpandSelectionUnit,
272        count: usize,
273        direction: ExpandSelectionDirection,
274    ) -> Result<CommandResult, CommandError> {
275        if count == 0 {
276            return Ok(CommandResult::Success);
277        }
278
279        let snapshot = self.snapshot_selection_set();
280        let selections = snapshot.selections;
281        let primary_index = snapshot.primary_index;
282
283        let line_count = self.editor.line_index.line_count();
284        if line_count == 0 {
285            return Ok(CommandResult::Success);
286        }
287
288        let mut next: Vec<Selection> = Vec::with_capacity(selections.len());
289        for sel in selections {
290            let (min_pos, max_pos) = crate::selection_set::selection_min_max(&sel);
291            let mut start = min_pos;
292            let mut end = max_pos;
293
294            match direction {
295                ExpandSelectionDirection::Backward => {
296                    start = self.expand_position_by_unit(start, unit, count, direction);
297                }
298                ExpandSelectionDirection::Forward => {
299                    end = self.expand_position_by_unit(end, unit, count, direction);
300                }
301            }
302
303            next.push(Selection {
304                start,
305                end,
306                direction: SelectionDirection::Forward,
307            });
308        }
309
310        // Delegate to SetSelections so normalization rules are shared.
311        self.execute_cursor(CursorCommand::SetSelections {
312            selections: next,
313            primary_index,
314        })?;
315        Ok(CommandResult::Success)
316    }
317
318    pub(super) fn expand_position_by_unit(
319        &self,
320        pos: Position,
321        unit: ExpandSelectionUnit,
322        count: usize,
323        direction: ExpandSelectionDirection,
324    ) -> Position {
325        match unit {
326            ExpandSelectionUnit::Character => self.expand_position_by_char(pos, count, direction),
327            ExpandSelectionUnit::Word => self.expand_position_by_word(pos, count, direction),
328            ExpandSelectionUnit::Line => self.expand_position_by_line(pos, count, direction),
329        }
330    }
331
332    pub(super) fn expand_position_by_char(
333        &self,
334        pos: Position,
335        count: usize,
336        direction: ExpandSelectionDirection,
337    ) -> Position {
338        let line_index = &self.editor.line_index;
339        let mut offset = line_index.position_to_char_offset(pos.line, pos.column);
340        let char_count = self.editor.char_count();
341
342        offset = match direction {
343            ExpandSelectionDirection::Backward => offset.saturating_sub(count),
344            ExpandSelectionDirection::Forward => offset.saturating_add(count).min(char_count),
345        };
346
347        let (line, col) = line_index.char_offset_to_position(offset);
348        Position::new(line, col)
349    }
350
351    pub(super) fn expand_position_by_line(
352        &self,
353        pos: Position,
354        count: usize,
355        direction: ExpandSelectionDirection,
356    ) -> Position {
357        let line_index = &self.editor.line_index;
358        let line_count = line_index.line_count();
359        if line_count == 0 {
360            return Position::new(0, 0);
361        }
362
363        let mut line = pos.line.min(line_count.saturating_sub(1));
364        line = match direction {
365            ExpandSelectionDirection::Backward => line.saturating_sub(count),
366            ExpandSelectionDirection::Forward => line.saturating_add(count),
367        };
368
369        if line >= line_count {
370            let line_text = line_index
371                .get_line_text(line_count.saturating_sub(1))
372                .unwrap_or_default();
373            return Position::new(line_count.saturating_sub(1), line_text.chars().count());
374        }
375
376        Position::new(line, 0)
377    }
378
379    pub(super) fn expand_position_by_word(
380        &self,
381        mut pos: Position,
382        count: usize,
383        direction: ExpandSelectionDirection,
384    ) -> Position {
385        for _ in 0..count {
386            let next = match direction {
387                ExpandSelectionDirection::Backward => self.prev_word_boundary_position(pos),
388                ExpandSelectionDirection::Forward => self.next_word_boundary_position(pos),
389            };
390            if next == pos {
391                break;
392            }
393            pos = next;
394        }
395        pos
396    }
397
398    pub(super) fn next_word_boundary_position(&self, pos: Position) -> Position {
399        let line_index = &self.editor.line_index;
400        let line_count = line_index.line_count();
401        if line_count == 0 {
402            return Position::new(0, 0);
403        }
404
405        let mut line = pos.line.min(line_count.saturating_sub(1));
406        let mut col = pos.column;
407
408        loop {
409            let line_text = line_index.get_line_text(line).unwrap_or_default();
410            let chars: Vec<char> = line_text.chars().collect();
411            let len = chars.len();
412            col = col.min(len);
413
414            // Find next token start (a "word unit") in this line.
415            let mut i = col;
416            while i < len && !self.editor.word_boundary.is_word_token_char(chars[i]) {
417                i += 1;
418            }
419
420            if i < len {
421                // Consume one token.
422                if chars[i].is_ascii() && self.editor.word_boundary.is_ascii_word_char(chars[i]) {
423                    let mut end = i + 1;
424                    while end < len
425                        && chars[end].is_ascii()
426                        && self.editor.word_boundary.is_ascii_word_char(chars[end])
427                    {
428                        end += 1;
429                    }
430                    return Position::new(line, end);
431                }
432                return Position::new(line, i + 1);
433            }
434
435            // No token on this line - move to next line.
436            if line + 1 >= line_count {
437                return Position::new(line, len);
438            }
439            line += 1;
440            col = 0;
441        }
442    }
443
444    pub(super) fn prev_word_boundary_position(&self, pos: Position) -> Position {
445        let line_index = &self.editor.line_index;
446        let line_count = line_index.line_count();
447        if line_count == 0 {
448            return Position::new(0, 0);
449        }
450
451        let mut line = pos.line.min(line_count.saturating_sub(1));
452        let mut col = pos.column;
453
454        loop {
455            let line_text = line_index.get_line_text(line).unwrap_or_default();
456            let chars: Vec<char> = line_text.chars().collect();
457            let len = chars.len();
458            col = col.min(len);
459
460            if col == 0 {
461                if line == 0 {
462                    return Position::new(0, 0);
463                }
464                line -= 1;
465                col = usize::MAX;
466                continue;
467            }
468
469            let mut i = col.saturating_sub(1).min(len.saturating_sub(1));
470            while i < len && !self.editor.word_boundary.is_word_token_char(chars[i]) {
471                if i == 0 {
472                    break;
473                }
474                i -= 1;
475            }
476
477            if self.editor.word_boundary.is_word_token_char(chars[i]) {
478                // If ASCII word token, walk to token start.
479                if chars[i].is_ascii() && self.editor.word_boundary.is_ascii_word_char(chars[i]) {
480                    while i > 0
481                        && chars[i - 1].is_ascii()
482                        && self.editor.word_boundary.is_ascii_word_char(chars[i - 1])
483                    {
484                        i -= 1;
485                    }
486                    return Position::new(line, i);
487                }
488                return Position::new(line, i);
489            }
490
491            // No token found on this line - move to previous line.
492            if line == 0 {
493                return Position::new(0, 0);
494            }
495            line -= 1;
496            col = usize::MAX;
497        }
498    }
499
500    pub(super) fn execute_add_cursor_vertical_command(
501        &mut self,
502        above: bool,
503    ) -> Result<CommandResult, CommandError> {
504        let snapshot = self.snapshot_selection_set();
505        let mut selections = snapshot.selections;
506        let primary_index = snapshot.primary_index;
507
508        let line_count = self.editor.line_index.line_count();
509        if line_count == 0 {
510            return Ok(CommandResult::Success);
511        }
512
513        let mut extra: Vec<Selection> = Vec::new();
514        for sel in &selections {
515            let caret = sel.end;
516            let target_line = if above {
517                if caret.line == 0 {
518                    continue;
519                }
520                caret.line - 1
521            } else {
522                let next = caret.line + 1;
523                if next >= line_count {
524                    continue;
525                }
526                next
527            };
528
529            let col = self.clamp_column_for_line(target_line, caret.column);
530            let pos = Position::new(target_line, col);
531            extra.push(Selection {
532                start: pos,
533                end: pos,
534                direction: SelectionDirection::Forward,
535            });
536        }
537
538        if extra.is_empty() {
539            return Ok(CommandResult::Success);
540        }
541
542        selections.extend(extra);
543
544        self.execute_cursor(CursorCommand::SetSelections {
545            selections,
546            primary_index,
547        })?;
548        Ok(CommandResult::Success)
549    }
550
551    pub(super) fn selection_query(
552        &self,
553        selections: &[Selection],
554        primary_index: usize,
555    ) -> Option<(String, Option<SearchMatch>)> {
556        let primary = selections.get(primary_index)?;
557        let range = self.selection_char_range(primary);
558
559        if range.start != range.end {
560            let len = range.end - range.start;
561            return Some((self.editor.text_range(range.start, len), Some(range)));
562        }
563
564        let caret = primary.end;
565        let line_text = self
566            .editor
567            .line_index
568            .get_line_text(caret.line)
569            .unwrap_or_default();
570        let col = caret.column.min(line_text.chars().count());
571        let (start_col, end_col) = self.word_token_range_in_line(&line_text, col)?;
572        if start_col == end_col {
573            return None;
574        }
575
576        let start = self
577            .editor
578            .line_index
579            .position_to_char_offset(caret.line, start_col);
580        let end = self
581            .editor
582            .line_index
583            .position_to_char_offset(caret.line, end_col);
584        let range = SearchMatch {
585            start,
586            end: end.max(start),
587        };
588        Some((
589            self.editor
590                .text_range(range.start, range.end.saturating_sub(range.start)),
591            Some(range),
592        ))
593    }
594
595    pub(super) fn execute_add_next_occurrence_command(
596        &mut self,
597        options: SearchOptions,
598    ) -> Result<CommandResult, CommandError> {
599        let snapshot = self.snapshot_selection_set();
600        let mut selections = snapshot.selections;
601        let primary_index = snapshot.primary_index;
602
603        let Some((query, primary_range)) = self.selection_query(&selections, primary_index) else {
604            return Ok(CommandResult::Success);
605        };
606        if query.is_empty() {
607            return Ok(CommandResult::Success);
608        }
609
610        // VSCode-like: if there is no active selection, first select the current word occurrence.
611        if let Some(primary_range) = primary_range
612            && primary_range.start != primary_range.end
613        {
614            let current = selections
615                .get(primary_index)
616                .map(|s| self.selection_char_range(s))
617                .unwrap_or(SearchMatch { start: 0, end: 0 });
618            if current.start == current.end {
619                let (start_line, start_col) = self
620                    .editor
621                    .line_index
622                    .char_offset_to_position(primary_range.start);
623                let (end_line, end_col) = self
624                    .editor
625                    .line_index
626                    .char_offset_to_position(primary_range.end);
627                if let Some(sel) = selections.get_mut(primary_index) {
628                    *sel = Selection {
629                        start: Position::new(start_line, start_col),
630                        end: Position::new(end_line, end_col),
631                        direction: SelectionDirection::Forward,
632                    };
633                }
634            }
635        }
636
637        let text = self.editor.get_text();
638
639        let mut ranges: Vec<SearchMatch> = selections
640            .iter()
641            .map(|s| self.selection_char_range(s))
642            .filter(|r| r.start != r.end)
643            .collect();
644
645        if let Some(primary_range) = primary_range
646            && primary_range.start != primary_range.end
647            && !ranges
648                .iter()
649                .any(|r| r.start == primary_range.start && r.end == primary_range.end)
650        {
651            ranges.push(primary_range);
652        }
653
654        let mut existing: Vec<(usize, usize)> = ranges
655            .iter()
656            .map(|r| (r.start.min(r.end), r.end.max(r.start)))
657            .collect();
658        existing.sort_unstable();
659
660        let from = existing.iter().map(|(_, end)| *end).max().unwrap_or(0);
661
662        let mut search_from = from;
663        let mut wrapped = false;
664        let mut found: Option<SearchMatch> = None;
665
666        loop {
667            let next = find_next(&text, &query, options, search_from)
668                .map_err(|err| CommandError::Other(err.to_string()))?;
669
670            let Some(m) = next else {
671                if wrapped {
672                    break;
673                }
674                wrapped = true;
675                search_from = 0;
676                continue;
677            };
678
679            let overlaps = existing.iter().any(|(s, e)| m.start < *e && m.end > *s);
680
681            if overlaps {
682                if m.end >= text.chars().count() {
683                    break;
684                }
685                search_from = m.end + 1;
686                continue;
687            }
688
689            found = Some(m);
690            break;
691        }
692
693        let Some(m) = found else {
694            return Ok(CommandResult::Success);
695        };
696
697        let (start_line, start_col) = self.editor.line_index.char_offset_to_position(m.start);
698        let (end_line, end_col) = self.editor.line_index.char_offset_to_position(m.end);
699
700        selections.push(Selection {
701            start: Position::new(start_line, start_col),
702            end: Position::new(end_line, end_col),
703            direction: SelectionDirection::Forward,
704        });
705
706        let new_primary_index = selections.len().saturating_sub(1);
707        self.execute_cursor(CursorCommand::SetSelections {
708            selections,
709            primary_index: new_primary_index,
710        })?;
711
712        Ok(CommandResult::Success)
713    }
714
715    pub(super) fn execute_add_all_occurrences_command(
716        &mut self,
717        options: SearchOptions,
718    ) -> Result<CommandResult, CommandError> {
719        let snapshot = self.snapshot_selection_set();
720        let selections = snapshot.selections;
721        let primary_index = snapshot.primary_index;
722
723        let Some((query, primary_range)) = self.selection_query(&selections, primary_index) else {
724            return Ok(CommandResult::Success);
725        };
726        if query.is_empty() {
727            return Ok(CommandResult::Success);
728        }
729
730        let text = self.editor.get_text();
731        let matches =
732            find_all(&text, &query, options).map_err(|err| CommandError::Other(err.to_string()))?;
733
734        if matches.is_empty() {
735            return Ok(CommandResult::Success);
736        }
737
738        let mut out: Vec<Selection> = Vec::with_capacity(matches.len());
739        let mut next_primary = 0usize;
740        let primary_range = primary_range.filter(|r| r.start != r.end);
741
742        for (idx, m) in matches.iter().enumerate() {
743            let (start_line, start_col) = self.editor.line_index.char_offset_to_position(m.start);
744            let (end_line, end_col) = self.editor.line_index.char_offset_to_position(m.end);
745            out.push(Selection {
746                start: Position::new(start_line, start_col),
747                end: Position::new(end_line, end_col),
748                direction: SelectionDirection::Forward,
749            });
750
751            if let Some(pr) = primary_range
752                && pr.start == m.start
753                && pr.end == m.end
754            {
755                next_primary = idx;
756            }
757        }
758
759        self.execute_cursor(CursorCommand::SetSelections {
760            selections: out,
761            primary_index: next_primary,
762        })?;
763
764        Ok(CommandResult::Success)
765    }
766}
767
768impl CommandExecutor {
769    pub(super) fn execute_cursor(
770        &mut self,
771        command: CursorCommand,
772    ) -> Result<CommandResult, CommandError> {
773        match command {
774            CursorCommand::MoveTo { line, column } => {
775                if line >= self.editor.line_index.line_count() {
776                    return Err(CommandError::InvalidPosition { line, column });
777                }
778
779                let clamped_column = self.clamp_column_for_line(line, column);
780                self.editor.cursor_position = Position::new(line, clamped_column);
781                self.preferred_x_cells = self
782                    .editor
783                    .logical_position_to_visual(line, clamped_column)
784                    .map(|(_, x)| x);
785                // VSCode-like: moving the primary caret to an absolute position collapses multi-cursor.
786                self.editor.secondary_selections.clear();
787                Ok(CommandResult::Success)
788            }
789            CursorCommand::MoveBy {
790                delta_line,
791                delta_column,
792            } => {
793                let new_line = if delta_line >= 0 {
794                    self.editor.cursor_position.line + delta_line as usize
795                } else {
796                    self.editor
797                        .cursor_position
798                        .line
799                        .saturating_sub((-delta_line) as usize)
800                };
801
802                let new_column = if delta_column >= 0 {
803                    self.editor.cursor_position.column + delta_column as usize
804                } else {
805                    self.editor
806                        .cursor_position
807                        .column
808                        .saturating_sub((-delta_column) as usize)
809                };
810
811                if new_line >= self.editor.line_index.line_count() {
812                    return Err(CommandError::InvalidPosition {
813                        line: new_line,
814                        column: new_column,
815                    });
816                }
817
818                let clamped_column = self.clamp_column_for_line(new_line, new_column);
819                self.editor.cursor_position = Position::new(new_line, clamped_column);
820                self.preferred_x_cells = self
821                    .editor
822                    .logical_position_to_visual(new_line, clamped_column)
823                    .map(|(_, x)| x);
824                Ok(CommandResult::Success)
825            }
826            CursorCommand::MoveGraphemeLeft => {
827                let line_count = self.editor.line_index.line_count();
828                if line_count == 0 {
829                    return Ok(CommandResult::Success);
830                }
831
832                let mut line = self
833                    .editor
834                    .cursor_position
835                    .line
836                    .min(line_count.saturating_sub(1));
837                let mut line_text = self
838                    .editor
839                    .line_index
840                    .get_line_text(line)
841                    .unwrap_or_default();
842                let mut line_char_len = line_text.chars().count();
843                let mut col = self.editor.cursor_position.column.min(line_char_len);
844
845                if col == 0 {
846                    if line == 0 {
847                        return Ok(CommandResult::Success);
848                    }
849                    line = line.saturating_sub(1);
850                    line_text = self
851                        .editor
852                        .line_index
853                        .get_line_text(line)
854                        .unwrap_or_default();
855                    line_char_len = line_text.chars().count();
856                    col = line_char_len;
857                } else {
858                    col = prev_boundary_column(&line_text, col, TextBoundary::Grapheme);
859                }
860
861                self.editor.cursor_position = Position::new(line, col);
862                self.preferred_x_cells = self
863                    .editor
864                    .logical_position_to_visual(line, col)
865                    .map(|(_, x)| x);
866                Ok(CommandResult::Success)
867            }
868            CursorCommand::MoveGraphemeRight => {
869                let line_count = self.editor.line_index.line_count();
870                if line_count == 0 {
871                    return Ok(CommandResult::Success);
872                }
873
874                let line = self
875                    .editor
876                    .cursor_position
877                    .line
878                    .min(line_count.saturating_sub(1));
879                let line_text = self
880                    .editor
881                    .line_index
882                    .get_line_text(line)
883                    .unwrap_or_default();
884                let line_char_len = line_text.chars().count();
885                let col = self.editor.cursor_position.column.min(line_char_len);
886
887                let (line, col) = if col >= line_char_len {
888                    if line + 1 >= line_count {
889                        return Ok(CommandResult::Success);
890                    }
891                    (line + 1, 0)
892                } else {
893                    (
894                        line,
895                        next_boundary_column(&line_text, col, TextBoundary::Grapheme),
896                    )
897                };
898
899                self.editor.cursor_position = Position::new(line, col);
900                self.preferred_x_cells = self
901                    .editor
902                    .logical_position_to_visual(line, col)
903                    .map(|(_, x)| x);
904                Ok(CommandResult::Success)
905            }
906            CursorCommand::MoveWordLeft => {
907                let line_count = self.editor.line_index.line_count();
908                if line_count == 0 {
909                    return Ok(CommandResult::Success);
910                }
911
912                let mut line = self
913                    .editor
914                    .cursor_position
915                    .line
916                    .min(line_count.saturating_sub(1));
917                let mut line_text = self
918                    .editor
919                    .line_index
920                    .get_line_text(line)
921                    .unwrap_or_default();
922                let mut line_char_len = line_text.chars().count();
923                let mut col = self.editor.cursor_position.column.min(line_char_len);
924
925                if col == 0 {
926                    if line == 0 {
927                        return Ok(CommandResult::Success);
928                    }
929                    line = line.saturating_sub(1);
930                    line_text = self
931                        .editor
932                        .line_index
933                        .get_line_text(line)
934                        .unwrap_or_default();
935                    line_char_len = line_text.chars().count();
936                    col = line_char_len;
937                } else {
938                    col = prev_boundary_column(&line_text, col, TextBoundary::Word);
939                }
940
941                self.editor.cursor_position = Position::new(line, col);
942                self.preferred_x_cells = self
943                    .editor
944                    .logical_position_to_visual(line, col)
945                    .map(|(_, x)| x);
946                Ok(CommandResult::Success)
947            }
948            CursorCommand::MoveWordRight => {
949                let line_count = self.editor.line_index.line_count();
950                if line_count == 0 {
951                    return Ok(CommandResult::Success);
952                }
953
954                let line = self
955                    .editor
956                    .cursor_position
957                    .line
958                    .min(line_count.saturating_sub(1));
959                let line_text = self
960                    .editor
961                    .line_index
962                    .get_line_text(line)
963                    .unwrap_or_default();
964                let line_char_len = line_text.chars().count();
965                let col = self.editor.cursor_position.column.min(line_char_len);
966
967                let (line, col) = if col >= line_char_len {
968                    if line + 1 >= line_count {
969                        return Ok(CommandResult::Success);
970                    }
971                    (line + 1, 0)
972                } else {
973                    (
974                        line,
975                        next_boundary_column(&line_text, col, TextBoundary::Word),
976                    )
977                };
978
979                self.editor.cursor_position = Position::new(line, col);
980                self.preferred_x_cells = self
981                    .editor
982                    .logical_position_to_visual(line, col)
983                    .map(|(_, x)| x);
984                Ok(CommandResult::Success)
985            }
986            CursorCommand::MoveVisualBy { delta_rows } => {
987                let Some((current_row, current_x)) = self.editor.logical_position_to_visual(
988                    self.editor.cursor_position.line,
989                    self.editor.cursor_position.column,
990                ) else {
991                    return Ok(CommandResult::Success);
992                };
993
994                let preferred_x = self.preferred_x_cells.unwrap_or(current_x);
995                self.preferred_x_cells = Some(preferred_x);
996
997                let total_visual = self.editor.visual_line_count();
998                if total_visual == 0 {
999                    return Ok(CommandResult::Success);
1000                }
1001
1002                let target_row = if delta_rows >= 0 {
1003                    current_row.saturating_add(delta_rows as usize)
1004                } else {
1005                    current_row.saturating_sub((-delta_rows) as usize)
1006                }
1007                .min(total_visual.saturating_sub(1));
1008
1009                let Some(pos) = self
1010                    .editor
1011                    .visual_position_to_logical(target_row, preferred_x)
1012                else {
1013                    return Ok(CommandResult::Success);
1014                };
1015
1016                self.editor.cursor_position = pos;
1017                Ok(CommandResult::Success)
1018            }
1019            CursorCommand::MoveToVisual { row, x_cells } => {
1020                let Some(pos) = self.editor.visual_position_to_logical(row, x_cells) else {
1021                    return Ok(CommandResult::Success);
1022                };
1023
1024                self.editor.cursor_position = pos;
1025                self.preferred_x_cells = Some(x_cells);
1026                // Treat as an absolute move (similar to `MoveTo`).
1027                self.editor.secondary_selections.clear();
1028                Ok(CommandResult::Success)
1029            }
1030            CursorCommand::MoveToLineStart => {
1031                let line = self.editor.cursor_position.line;
1032                self.editor.cursor_position = Position::new(line, 0);
1033                self.preferred_x_cells = Some(0);
1034                self.editor.secondary_selections.clear();
1035                Ok(CommandResult::Success)
1036            }
1037            CursorCommand::MoveToLineEnd => {
1038                let line = self.editor.cursor_position.line;
1039                let end_col = self.clamp_column_for_line(line, usize::MAX);
1040                self.editor.cursor_position = Position::new(line, end_col);
1041                self.preferred_x_cells = self
1042                    .editor
1043                    .logical_position_to_visual(line, end_col)
1044                    .map(|(_, x)| x);
1045                self.editor.secondary_selections.clear();
1046                Ok(CommandResult::Success)
1047            }
1048            CursorCommand::MoveToVisualLineStart => {
1049                let line = self.editor.cursor_position.line;
1050                let Some(layout) = self.editor.layout_engine.get_line_layout(line) else {
1051                    return Ok(CommandResult::Success);
1052                };
1053
1054                let line_text = self
1055                    .editor
1056                    .line_index
1057                    .get_line_text(line)
1058                    .unwrap_or_default();
1059                let line_char_len = line_text.chars().count();
1060                let column = self.editor.cursor_position.column.min(line_char_len);
1061
1062                let mut seg_start = 0usize;
1063                for wp in &layout.wrap_points {
1064                    if column >= wp.char_index {
1065                        seg_start = wp.char_index;
1066                    } else {
1067                        break;
1068                    }
1069                }
1070
1071                self.editor.cursor_position = Position::new(line, seg_start);
1072                self.preferred_x_cells = self
1073                    .editor
1074                    .logical_position_to_visual(line, seg_start)
1075                    .map(|(_, x)| x);
1076                self.editor.secondary_selections.clear();
1077                Ok(CommandResult::Success)
1078            }
1079            CursorCommand::MoveToVisualLineEnd => {
1080                let line = self.editor.cursor_position.line;
1081                let Some(layout) = self.editor.layout_engine.get_line_layout(line) else {
1082                    return Ok(CommandResult::Success);
1083                };
1084
1085                let line_text = self
1086                    .editor
1087                    .line_index
1088                    .get_line_text(line)
1089                    .unwrap_or_default();
1090                let line_char_len = line_text.chars().count();
1091                let column = self.editor.cursor_position.column.min(line_char_len);
1092
1093                let mut seg_end = line_char_len;
1094                for wp in &layout.wrap_points {
1095                    if column < wp.char_index {
1096                        seg_end = wp.char_index;
1097                        break;
1098                    }
1099                }
1100
1101                self.editor.cursor_position = Position::new(line, seg_end);
1102                self.preferred_x_cells = self
1103                    .editor
1104                    .logical_position_to_visual(line, seg_end)
1105                    .map(|(_, x)| x);
1106                self.editor.secondary_selections.clear();
1107                Ok(CommandResult::Success)
1108            }
1109            CursorCommand::SetSelection { start, end } => {
1110                if start.line >= self.editor.line_index.line_count()
1111                    || end.line >= self.editor.line_index.line_count()
1112                {
1113                    return Err(CommandError::InvalidPosition {
1114                        line: start.line.max(end.line),
1115                        column: start.column.max(end.column),
1116                    });
1117                }
1118
1119                let start = Position::new(
1120                    start.line,
1121                    self.clamp_column_for_line(start.line, start.column),
1122                );
1123                let end = Position::new(end.line, self.clamp_column_for_line(end.line, end.column));
1124
1125                let direction = if start.line < end.line
1126                    || (start.line == end.line && start.column <= end.column)
1127                {
1128                    SelectionDirection::Forward
1129                } else {
1130                    SelectionDirection::Backward
1131                };
1132
1133                self.editor.selection = Some(Selection {
1134                    start,
1135                    end,
1136                    direction,
1137                });
1138                Ok(CommandResult::Success)
1139            }
1140            CursorCommand::ExtendSelection { to } => {
1141                if to.line >= self.editor.line_index.line_count() {
1142                    return Err(CommandError::InvalidPosition {
1143                        line: to.line,
1144                        column: to.column,
1145                    });
1146                }
1147
1148                let to = Position::new(to.line, self.clamp_column_for_line(to.line, to.column));
1149
1150                if let Some(ref mut selection) = self.editor.selection {
1151                    selection.end = to;
1152                    selection.direction = if selection.start.line < to.line
1153                        || (selection.start.line == to.line && selection.start.column <= to.column)
1154                    {
1155                        SelectionDirection::Forward
1156                    } else {
1157                        SelectionDirection::Backward
1158                    };
1159                } else {
1160                    // If no selection, create selection from current cursor
1161                    self.editor.selection = Some(Selection {
1162                        start: self.editor.cursor_position,
1163                        end: to,
1164                        direction: if self.editor.cursor_position.line < to.line
1165                            || (self.editor.cursor_position.line == to.line
1166                                && self.editor.cursor_position.column <= to.column)
1167                        {
1168                            SelectionDirection::Forward
1169                        } else {
1170                            SelectionDirection::Backward
1171                        },
1172                    });
1173                }
1174                Ok(CommandResult::Success)
1175            }
1176            CursorCommand::ClearSelection => {
1177                self.editor.selection = None;
1178                Ok(CommandResult::Success)
1179            }
1180            CursorCommand::SetSelections {
1181                selections,
1182                primary_index,
1183            } => {
1184                let line_count = self.editor.line_index.line_count();
1185                if selections.is_empty() {
1186                    return Err(CommandError::Other(
1187                        "SetSelections requires a non-empty selection list".to_string(),
1188                    ));
1189                }
1190                if primary_index >= selections.len() {
1191                    return Err(CommandError::Other(format!(
1192                        "Invalid primary_index {} for {} selections",
1193                        primary_index,
1194                        selections.len()
1195                    )));
1196                }
1197
1198                for sel in &selections {
1199                    if sel.start.line >= line_count || sel.end.line >= line_count {
1200                        return Err(CommandError::InvalidPosition {
1201                            line: sel.start.line.max(sel.end.line),
1202                            column: sel.start.column.max(sel.end.column),
1203                        });
1204                    }
1205                }
1206
1207                let (selections, primary_index) =
1208                    crate::selection_set::normalize_selections(selections, primary_index);
1209
1210                let primary = selections
1211                    .get(primary_index)
1212                    .cloned()
1213                    .ok_or_else(|| CommandError::Other("Invalid primary selection".to_string()))?;
1214
1215                self.editor.cursor_position = primary.end;
1216                self.editor.selection = if primary.start == primary.end {
1217                    None
1218                } else {
1219                    Some(primary.clone())
1220                };
1221
1222                self.editor.secondary_selections = selections
1223                    .into_iter()
1224                    .enumerate()
1225                    .filter_map(|(idx, sel)| {
1226                        if idx == primary_index {
1227                            None
1228                        } else {
1229                            Some(sel)
1230                        }
1231                    })
1232                    .collect();
1233
1234                Ok(CommandResult::Success)
1235            }
1236            CursorCommand::ClearSecondarySelections => {
1237                self.editor.secondary_selections.clear();
1238                Ok(CommandResult::Success)
1239            }
1240            CursorCommand::SetRectSelection { anchor, active } => {
1241                let line_count = self.editor.line_index.line_count();
1242                if anchor.line >= line_count || active.line >= line_count {
1243                    return Err(CommandError::InvalidPosition {
1244                        line: anchor.line.max(active.line),
1245                        column: anchor.column.max(active.column),
1246                    });
1247                }
1248
1249                let (selections, primary_index) =
1250                    crate::selection_set::rect_selections(anchor, active);
1251
1252                // Delegate to SetSelections so normalization rules are shared.
1253                self.execute_cursor(CursorCommand::SetSelections {
1254                    selections,
1255                    primary_index,
1256                })?;
1257                Ok(CommandResult::Success)
1258            }
1259            CursorCommand::SelectLine => self.execute_select_line_command(),
1260            CursorCommand::SelectWord => self.execute_select_word_command(),
1261            CursorCommand::ExpandSelection => self.execute_expand_selection_command(),
1262            CursorCommand::ExpandSelectionBy {
1263                unit,
1264                count,
1265                direction,
1266            } => self.execute_expand_selection_by_command(unit, count, direction),
1267            CursorCommand::AddCursorAbove => self.execute_add_cursor_vertical_command(true),
1268            CursorCommand::AddCursorBelow => self.execute_add_cursor_vertical_command(false),
1269            CursorCommand::AddNextOccurrence { options } => {
1270                self.execute_add_next_occurrence_command(options)
1271            }
1272            CursorCommand::AddAllOccurrences { options } => {
1273                self.execute_add_all_occurrences_command(options)
1274            }
1275            CursorCommand::MoveToMatchingBracket => self.execute_move_to_matching_bracket_command(),
1276            CursorCommand::SnippetNextPlaceholder => self.execute_snippet_navigation_command(true),
1277            CursorCommand::SnippetPrevPlaceholder => self.execute_snippet_navigation_command(false),
1278            CursorCommand::FindNext { query, options } => {
1279                self.execute_find_command(query, options, true)
1280            }
1281            CursorCommand::FindPrev { query, options } => {
1282                self.execute_find_command(query, options, false)
1283            }
1284        }
1285    }
1286
1287    // Private method: execute view command
1288}
1289
1290impl CommandExecutor {
1291    pub(super) fn position_to_char_offset_clamped(&self, pos: Position) -> usize {
1292        let line_count = self.editor.line_index.line_count();
1293        if line_count == 0 {
1294            return 0;
1295        }
1296
1297        let line = pos.line.min(line_count.saturating_sub(1));
1298        let line_text = self
1299            .editor
1300            .line_index
1301            .get_line_text(line)
1302            .unwrap_or_default();
1303        let line_char_len = line_text.chars().count();
1304        let column = pos.column.min(line_char_len);
1305        self.editor.line_index.position_to_char_offset(line, column)
1306    }
1307
1308    pub(super) fn position_to_char_offset_and_virtual_pad(&self, pos: Position) -> (usize, usize) {
1309        let line_count = self.editor.line_index.line_count();
1310        if line_count == 0 {
1311            return (0, 0);
1312        }
1313
1314        let line = pos.line.min(line_count.saturating_sub(1));
1315        let line_text = self
1316            .editor
1317            .line_index
1318            .get_line_text(line)
1319            .unwrap_or_default();
1320        let line_char_len = line_text.chars().count();
1321        let clamped_col = pos.column.min(line_char_len);
1322        let offset = self
1323            .editor
1324            .line_index
1325            .position_to_char_offset(line, clamped_col);
1326        let pad = pos.column.saturating_sub(clamped_col);
1327        (offset, pad)
1328    }
1329
1330    pub(super) fn normalize_cursor_and_selection(&mut self) {
1331        let line_index = &self.editor.line_index;
1332        let line_count = line_index.line_count();
1333        if line_count == 0 {
1334            self.editor.cursor_position = Position::new(0, 0);
1335            self.editor.selection = None;
1336            self.editor.secondary_selections.clear();
1337            return;
1338        }
1339
1340        self.editor.cursor_position =
1341            Self::clamp_position_lenient_with_index(line_index, self.editor.cursor_position);
1342
1343        if let Some(ref mut selection) = self.editor.selection {
1344            selection.start = Self::clamp_position_lenient_with_index(line_index, selection.start);
1345            selection.end = Self::clamp_position_lenient_with_index(line_index, selection.end);
1346            selection.direction = if selection.start.line < selection.end.line
1347                || (selection.start.line == selection.end.line
1348                    && selection.start.column <= selection.end.column)
1349            {
1350                SelectionDirection::Forward
1351            } else {
1352                SelectionDirection::Backward
1353            };
1354        }
1355
1356        for selection in &mut self.editor.secondary_selections {
1357            selection.start = Self::clamp_position_lenient_with_index(line_index, selection.start);
1358            selection.end = Self::clamp_position_lenient_with_index(line_index, selection.end);
1359            selection.direction = if selection.start.line < selection.end.line
1360                || (selection.start.line == selection.end.line
1361                    && selection.start.column <= selection.end.column)
1362            {
1363                SelectionDirection::Forward
1364            } else {
1365                SelectionDirection::Backward
1366            };
1367        }
1368    }
1369
1370    pub(super) fn clamp_column_for_line(&self, line: usize, column: usize) -> usize {
1371        Self::clamp_column_for_line_with_index(&self.editor.line_index, line, column)
1372    }
1373
1374    pub(super) fn clamp_position_lenient_with_index(
1375        line_index: &LineIndex,
1376        pos: Position,
1377    ) -> Position {
1378        let line_count = line_index.line_count();
1379        if line_count == 0 {
1380            return Position::new(0, 0);
1381        }
1382
1383        let clamped_line = pos.line.min(line_count.saturating_sub(1));
1384        // Note: do NOT clamp column here. Virtual columns (box selection) are allowed.
1385        Position::new(clamped_line, pos.column)
1386    }
1387
1388    pub(super) fn clamp_column_for_line_with_index(
1389        line_index: &LineIndex,
1390        line: usize,
1391        column: usize,
1392    ) -> usize {
1393        let line_start = line_index.position_to_char_offset(line, 0);
1394        let line_end = line_index.position_to_char_offset(line, usize::MAX);
1395        let line_len = line_end.saturating_sub(line_start);
1396        column.min(line_len)
1397    }
1398}