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