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    /// Every cursor position, primary first (0013 §3).
73    pub(crate) fn all_cursors(&self) -> Vec<usize> {
74        self.sels().heads()
75    }
76
77    /// Restore the invariant after any cascade: sorted and deduped. An
78    /// extra MAY sit on the primary (Q plants there, then you move) —
79    /// edit cascades dedupe positions before applying.
80    pub(crate) fn normalize_cursors(&mut self) {
81        self.sels_mut().normalize();
82    }
83
84    /// Remap cursors after a mirrored edit of `delta` bytes at each of
85    /// `positions` (pre-edit, sorted, deduped): every cursor shifts by
86    /// its own edit plus every edit below it (0013 §3).
87    pub(crate) fn remap_after_mirrored_edit(&mut self, positions: &[usize], delta: isize) {
88        let map = |old: usize| -> usize {
89            let below = positions.partition_point(|&p| p < old);
90            let own = usize::from(positions.contains(&old));
91            (old as isize + delta * (below + own) as isize).max(0) as usize
92        };
93        self.set_head(map(self.head()));
94        let extras: Vec<usize> = self
95            .extra_selections()
96            .iter()
97            .map(|s| map(s.head))
98            .collect();
99        self.sels_mut().set_extras(extras);
100    }
101
102    /// `Q`: drop the cursor under point when one exists, else plant one.
103    pub(crate) fn toggle_cursor(&mut self) {
104        if self.buf().readonly {
105            self.message = "readonly buffer".into();
106            return;
107        }
108        self.sels_mut().toggle_extra();
109        let n = self.sels().count();
110        self.message = format!("{n} cursor{}", if n > 1 { "s" } else { "" });
111    }
112
113    /// `Space c` (helix's `C`): copy the primary cursor onto the same
114    /// column of the next line — how vertical cursor stacks are built.
115    pub(crate) fn add_cursor_next_line(&mut self) {
116        if self.buf().readonly {
117            self.message = "readonly buffer".into();
118            return;
119        }
120        // stack from the bottom-most cursor (helix C semantics: repeated
121        // presses walk down the buffer)
122        let base = self
123            .extra_selections()
124            .last()
125            .map(|s| s.head)
126            .unwrap_or_else(|| self.head());
127        let line = self.buf().line_of(base);
128        // the phantom line past a trailing newline is not a cursor home
129        if line + 1 >= self.buf().len_lines()
130            || self.buf().line_start(line + 1) >= self.buf().len_bytes()
131        {
132            self.message = "no line below".into();
133            return;
134        }
135        let col = self.buf().col_of(base);
136        let start = self.buf().line_start(line + 1);
137        let end = self.buf().line_end(line + 1);
138        let pos = (start + col).min(end.saturating_sub(1).max(start));
139        self.sels_mut().plant_extra(pos);
140        let n = self.sels().count();
141        self.message = format!("{n} cursors");
142    }
143
144    /// Normal-mode Esc: collapse to the primary cursor (0013 §3).
145    pub(crate) fn collapse_cursors(&mut self) {
146        if self.sels().count() > 1 {
147            self.sels_mut().collapse_extras();
148            self.message = "1 cursor".into();
149        }
150    }
151
152    /// Keep the cursor on screen; `rows` = text area height. The
153    /// render loop calls this every frame, so it doubles as the
154    /// viewport-height feed for the H/M/L/zz/ctrl-d family.
155    pub fn scroll_to_cursor(&mut self, rows: usize) {
156        self.view_rows = rows;
157        let line = self.buf().line_of(self.head());
158        if line < self.view_top() {
159            self.view_mut().view_top = line;
160        } else if line >= self.view_top() + rows {
161            self.view_mut().view_top = line + 1 - rows;
162        }
163    }
164}