1use std::cell::Cell;
13
14#[derive(Clone, Copy, Default)]
18pub struct View {
19 pub count: usize,
20}
21
22#[derive(Clone, Copy, PartialEq)]
24pub enum Dir {
25 Fwd,
26 Back,
27}
28
29impl Dir {
30 pub fn flip(self) -> Dir {
31 match self {
32 Dir::Fwd => Dir::Back,
33 Dir::Back => Dir::Fwd,
34 }
35 }
36}
37
38#[derive(Default)]
39pub struct Vi {
40 pub cursor: usize,
42 pub scroll: usize,
44 pub visual: Option<usize>,
46 pub count: usize,
48 pub g_pending: bool,
50 pub pattern: String,
52 pub dir: Option<Dir>,
53 pub view: Cell<View>,
54}
55
56impl Vi {
57 pub fn reset(&mut self) {
59 self.cursor = 0;
60 self.scroll = 0;
61 self.visual = None;
62 self.count = 0;
63 self.g_pending = false;
64 }
65
66 pub fn following(&self) -> bool {
70 self.cursor == 0 && self.scroll == 0 && self.visual.is_none()
71 }
72
73 pub fn take_count(&mut self) -> usize {
75 let n = self.count.max(1);
76 self.count = 0;
77 n
78 }
79
80 pub fn push_digit(&mut self, d: u32) -> bool {
83 if d == 0 && self.count == 0 {
84 return false;
85 }
86 self.count = (self.count.saturating_mul(10))
87 .saturating_add(d as usize)
88 .min(1_000_000);
89 true
90 }
91
92 pub fn down(&mut self, n: usize) {
94 self.cursor = self.cursor.saturating_sub(n);
95 }
96
97 pub fn up(&mut self, n: usize, last: usize) {
99 self.cursor = self.cursor.saturating_add(n).min(last);
100 }
101
102 pub fn to_newest(&mut self) {
104 self.cursor = 0;
105 }
106
107 pub fn to_oldest(&mut self, last: usize) {
109 self.cursor = last;
110 }
111
112 pub fn selection(&self) -> Option<(usize, usize)> {
115 let a = self.visual?;
116 Some((a.min(self.cursor), a.max(self.cursor)))
117 }
118
119 pub fn selected(&self, idx: usize) -> bool {
121 self.selection()
122 .is_some_and(|(lo, hi)| (lo..=hi).contains(&idx))
123 }
124
125 pub fn keep_visible(&mut self) {
129 let count = self.view.get().count.max(1);
130 if self.cursor < self.scroll {
131 self.scroll = self.cursor;
132 } else if self.cursor >= self.scroll + count {
133 self.scroll = self.cursor + 1 - count;
134 }
135 }
136
137 pub fn absorb_new(&mut self, added: usize, last: usize) {
142 if added == 0 || self.following() {
143 return;
144 }
145 self.cursor = self.cursor.saturating_add(added).min(last);
146 self.scroll = self.scroll.saturating_add(added).min(last);
147 if let Some(a) = self.visual {
148 self.visual = Some(a.saturating_add(added).min(last));
149 }
150 }
151
152 pub fn clamp(&mut self, last: usize) {
154 self.cursor = self.cursor.min(last);
155 self.scroll = self.scroll.min(last);
156 if let Some(a) = self.visual {
157 self.visual = Some(a.min(last));
158 }
159 }
160}
161
162pub fn search(
166 len: usize,
167 from: usize,
168 dir: Dir,
169 is_match: impl Fn(usize) -> bool,
170) -> Option<usize> {
171 if len == 0 {
172 return None;
173 }
174 for step in 1..=len {
176 let i = match dir {
177 Dir::Fwd => (from + len - (step % len)) % len,
179 Dir::Back => (from + step) % len,
180 };
181 if is_match(i) {
182 return Some(i);
183 }
184 }
185 None
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 fn vi_with(count: usize) -> Vi {
193 let v = Vi::default();
194 v.view.set(View { count });
195 v
196 }
197
198 #[test]
199 fn starts_following_live() {
200 let v = Vi::default();
201 assert!(v.following(), "a fresh pane pins to the newest message");
202 }
203
204 #[test]
205 fn j_and_k_read_as_down_and_up_the_file() {
206 let mut v = Vi::default();
207 v.up(3, 100); assert_eq!(v.cursor, 3);
209 v.down(1); assert_eq!(v.cursor, 2);
211 assert!(!v.following());
212 }
213
214 #[test]
215 fn motions_saturate_at_both_ends() {
216 let mut v = Vi::default();
217 v.down(50); assert_eq!(v.cursor, 0);
219 v.up(999, 9); assert_eq!(v.cursor, 9);
221 }
222
223 #[test]
224 fn g_and_shift_g_jump_to_the_ends() {
225 let mut v = Vi::default();
226 v.to_oldest(42);
227 assert_eq!(v.cursor, 42);
228 v.to_newest();
229 assert_eq!(v.cursor, 0);
230 }
231
232 #[test]
235 fn counts_accumulate_and_are_consumed_once() {
236 let mut v = Vi::default();
237 assert!(v.push_digit(1));
238 assert!(v.push_digit(2));
239 assert_eq!(v.take_count(), 12);
240 assert_eq!(v.take_count(), 1, "count resets after use");
241 }
242
243 #[test]
244 fn leading_zero_is_not_a_count() {
245 let mut v = Vi::default();
246 assert!(!v.push_digit(0), "bare 0 is a motion in vi, not a count");
247 assert_eq!(v.count, 0);
248 v.push_digit(1);
249 assert!(v.push_digit(0), "0 counts once a number is under way");
250 assert_eq!(v.take_count(), 10);
251 }
252
253 #[test]
256 fn viewport_follows_the_cursor_up_and_back_down() {
257 let mut v = vi_with(10);
258 v.up(15, 100); v.keep_visible();
260 assert_eq!(v.scroll, 6, "pulls the window so cursor 15 is the top row");
261 assert!(v.cursor >= v.scroll && v.cursor < v.scroll + 10);
262
263 v.down(15); v.keep_visible();
265 assert_eq!(v.scroll, 0);
266 }
267
268 #[test]
269 fn viewport_does_not_move_while_the_cursor_is_on_screen() {
270 let mut v = vi_with(10);
271 v.up(5, 100);
272 v.keep_visible();
273 assert_eq!(v.scroll, 0, "cursor 5 already fits in a 10-row view");
274 }
275
276 #[test]
279 fn scrollback_holds_still_when_messages_arrive() {
280 let mut v = vi_with(10);
281 v.up(20, 500); v.keep_visible();
283 let (c, s) = (v.cursor, v.scroll);
284 v.absorb_new(3, 500);
285 assert_eq!(v.cursor, c + 3, "cursor stays on the same message");
286 assert_eq!(v.scroll, s + 3, "and the pane does not jump");
287 }
288
289 #[test]
290 fn live_view_keeps_following_when_messages_arrive() {
291 let mut v = vi_with(10);
292 v.absorb_new(5, 500);
293 assert!(
294 v.following(),
295 "at the bottom, new chat scrolls in as normal"
296 );
297 assert_eq!(v.cursor, 0);
298 }
299
300 #[test]
301 fn visual_anchor_travels_with_the_selection() {
302 let mut v = vi_with(10);
303 v.up(10, 500);
304 v.visual = Some(v.cursor);
305 v.up(2, 500);
306 let before = v.selection().unwrap();
307 v.absorb_new(4, 500);
308 let after = v.selection().unwrap();
309 assert_eq!((after.0 - before.0, after.1 - before.1), (4, 4));
310 }
311
312 #[test]
315 fn selection_covers_both_directions() {
316 let mut v = Vi {
317 cursor: 5,
318 visual: Some(2),
319 ..Vi::default()
320 };
321 assert_eq!(v.selection(), Some((2, 5)));
322 assert!(v.selected(2) && v.selected(4) && v.selected(5));
323 assert!(!v.selected(1) && !v.selected(6));
324
325 v.cursor = 1; assert_eq!(v.selection(), Some((1, 2)));
327 }
328
329 #[test]
330 fn no_selection_outside_visual_mode() {
331 let v = Vi {
332 cursor: 3,
333 ..Vi::default()
334 };
335 assert_eq!(v.selection(), None);
336 assert!(!v.selected(3));
337 }
338
339 #[test]
342 fn forward_search_walks_toward_newer() {
343 let hit = |i: usize| i == 2 || i == 7;
345 assert_eq!(search(10, 8, Dir::Fwd, hit), Some(7));
346 assert_eq!(search(10, 7, Dir::Fwd, hit), Some(2));
347 }
348
349 #[test]
350 fn backward_search_walks_toward_older() {
351 let hit = |i: usize| i == 2 || i == 7;
352 assert_eq!(search(10, 0, Dir::Back, hit), Some(2));
353 assert_eq!(search(10, 2, Dir::Back, hit), Some(7));
354 }
355
356 #[test]
357 fn search_wraps_once_like_wrapscan() {
358 let hit = |i: usize| i == 9;
359 assert_eq!(search(10, 5, Dir::Fwd, hit), Some(9));
361 }
362
363 #[test]
364 fn search_finds_the_cursor_message_only_after_a_full_lap() {
365 let hit = |i: usize| i == 4;
366 assert_eq!(
367 search(10, 4, Dir::Fwd, hit),
368 Some(4),
369 "wraps all the way round"
370 );
371 assert_eq!(search(10, 3, Dir::Fwd, hit), Some(4));
372 }
373
374 #[test]
375 fn search_reports_no_match_and_never_hangs() {
376 assert_eq!(search(10, 3, Dir::Fwd, |_| false), None);
377 assert_eq!(search(0, 0, Dir::Fwd, |_| true), None, "empty ring");
378 }
379
380 #[test]
381 fn clamp_survives_a_shrinking_ring() {
382 let mut v = Vi {
383 cursor: 90,
384 scroll: 80,
385 visual: Some(95),
386 ..Vi::default()
387 };
388 v.clamp(10); assert_eq!((v.cursor, v.scroll, v.visual), (10, 10, Some(10)));
390 }
391}