Skip to main content

strop_engine/editor/
cursor.rs

1//! Selection-facing helpers (0014 wave 2): head/anchor accessors,
2//! clamps, the multicursor cascade bookkeeping, scroll, flash.
3
4use strop_core::Range;
5
6use super::{Editor, Mode, FLASH_FOR};
7
8impl Editor {
9    /// The primary cursor's byte offset (was the `cursor` field).
10    #[inline]
11    pub fn head(&self) -> usize {
12        self.sels().primary().head
13    }
14
15    #[inline]
16    pub fn set_head(&mut self, pos: impl Into<strop_core::id::ByteOffset>) {
17        self.view_mut().desired_column = None;
18        self.sels_mut().set_head(pos.into().get());
19    }
20
21    /// The visual anchor (== head when not in visual mode).
22    #[inline]
23    pub fn anchor(&self) -> usize {
24        self.sels().primary().anchor
25    }
26
27    /// Extra selections beyond the primary (0013).
28    pub fn extra_selections(&self) -> &[strop_core::selection::Selection] {
29        self.sels().extra_heads()
30    }
31
32    pub(crate) fn flash(&mut self, range: Range) {
33        self.flash = Some((range, self.tape.now()));
34    }
35
36    pub fn flash_range(&self) -> Option<Range> {
37        self.flash.and_then(|(range, at)| {
38            (self.tape.now().monotonic_ms.saturating_sub(at.monotonic_ms)
39                < FLASH_FOR.as_millis() as u64)
40                .then_some(range)
41        })
42    }
43
44    pub fn clamp_cursor(&mut self) {
45        let line = self.buf().line_of(self.head());
46        let start = self.buf().line_start(line);
47        let end = self.buf().line_end(line);
48        let max = if self.mode == Mode::Insert {
49            end
50        } else {
51            end.max(start + 1) - 1
52        };
53        let pos = self
54            .buf()
55            .clamp_boundary(self.head().clamp(start, max.max(start)));
56        self.set_head(pos);
57    }
58
59    /// Clamp one position the way clamp_cursor clamps the primary.
60    pub(crate) fn clamp_pos(&self, pos: usize) -> usize {
61        let line = self.buf().line_of(pos);
62        let start = self.buf().line_start(line);
63        let end = self.buf().line_end(line);
64        let max = if self.mode == Mode::Insert {
65            end
66        } else {
67            end.max(start + 1) - 1
68        };
69        self.buf().clamp_boundary(pos.clamp(start, max.max(start)))
70    }
71
72    /// The charwise-inclusive `[start, end)` byte span a selection
73    /// covers (visual mode's rule: the head char is inside, 0049 §7).
74    pub(crate) fn selection_span(
75        &self,
76        selection: strop_core::selection::Selection,
77    ) -> (usize, usize) {
78        let (start, last) = selection.range();
79        let end = self
80            .buf()
81            .ceil_boundary((last + 1).min(self.buf().len_bytes()));
82        (start, end.min(self.buf().len_bytes()))
83    }
84
85    /// Every cursor position, primary first (0013 §3).
86    pub(crate) fn all_cursors(&self) -> Vec<usize> {
87        self.sels().heads()
88    }
89
90    /// Restore the invariant after any cascade: sorted and deduped. An
91    /// extra MAY sit on the primary (Q plants there, then you move) —
92    /// edit cascades dedupe positions before applying.
93    pub(crate) fn normalize_cursors(&mut self) {
94        self.sels_mut().normalize();
95    }
96
97    /// Remap cursors after a mirrored edit of `delta` bytes at each of
98    /// `positions` (pre-edit, sorted, deduped): every cursor shifts by
99    /// its own edit plus every edit below it (0013 §3).
100    pub(crate) fn remap_after_mirrored_edit(&mut self, positions: &[usize], delta: isize) {
101        let map = |old: usize| -> usize {
102            let below = positions.partition_point(|&p| p < old);
103            let own = usize::from(positions.contains(&old));
104            (old as isize + delta * (below + own) as isize).max(0) as usize
105        };
106        // remap every endpoint: stretched selections (occurrences, 0049
107        // §7.4) keep their anchors and direction through mirrored edits
108        self.sels_mut().map_positions(map);
109    }
110
111    /// `Q`: drop the cursor under point when one exists, else plant one.
112    pub(crate) fn toggle_cursor(&mut self) {
113        if self.buf().readonly {
114            self.message = "readonly buffer".into();
115            return;
116        }
117        self.sels_mut().toggle_extra();
118        let n = self.sels().count();
119        self.message = format!("{n} cursor{}", if n > 1 { "s" } else { "" });
120    }
121
122    /// `Space c` (helix's `C`): copy the primary cursor onto the same
123    /// column of the next line — how vertical cursor stacks are built.
124    pub(crate) fn add_cursor_next_line(&mut self) {
125        if self.buf().readonly {
126            self.message = "readonly buffer".into();
127            return;
128        }
129        // stack from the bottom-most cursor (helix C semantics: repeated
130        // presses walk down the buffer)
131        let base = self
132            .extra_selections()
133            .last()
134            .map(|s| s.head)
135            .unwrap_or_else(|| self.head());
136        let line = self.buf().line_of(base);
137        // the phantom line past a trailing newline is not a cursor home
138        if line + 1 >= self.buf().len_lines()
139            || self.buf().line_start(line + 1) >= self.buf().len_bytes()
140        {
141            self.message = "no line below".into();
142            return;
143        }
144        let col = self.buf().col_of(base);
145        let start = self.buf().line_start(line + 1);
146        let end = self.buf().line_end(line + 1);
147        let pos = (start + col).min(end.saturating_sub(1).max(start));
148        self.sels_mut().plant_extra(pos);
149        let n = self.sels().count();
150        self.message = format!("{n} cursors");
151    }
152
153    /// Normal-mode Esc: collapse to the primary cursor (0013 §3) and
154    /// end any occurrence session (0049 §7) — the stretched seed
155    /// collapses with it.
156    pub(crate) fn collapse_cursors(&mut self) {
157        let occurrence = self.occurrence.take().is_some();
158        if occurrence {
159            let head = self.head();
160            self.sels_mut().collapse_primary(head);
161        }
162        if self.sels().count() > 1 {
163            self.sels_mut().collapse_extras();
164            self.message = "1 cursor".into();
165        } else if occurrence {
166            self.message = "occurrence selection cleared".into();
167        }
168    }
169
170    /// Keep the cursor on screen; `rows` = text area height. The
171    /// render loop calls this every frame, so it doubles as the
172    /// viewport-height feed for the H/M/L/zz/ctrl-d family.
173    pub fn scroll_to_cursor(&mut self, rows: usize) {
174        self.view_rows = rows;
175        let line = self.buf().line_of(self.head());
176        if line < self.view_top() {
177            self.view_mut().view_top = line;
178        } else if line >= self.view_top() + rows {
179            self.view_mut().view_top = line + 1 - rows;
180        }
181    }
182}