Skip to main content

heatsync_core/
vi.rs

1//! vi navigation over the chat ring: a cursor, a linewise visual selection,
2//! counts, and search. no rendering and no I/O — main.rs owns the keymap and the
3//! drawing, this owns the model, which is what makes the motions testable.
4//!
5//! COORDINATES. everything here is "index back from newest": 0 is the newest
6//! message, larger is older. that reads backwards until you picture the pane as
7//! a file — oldest at the top, newest at the bottom, which is how chat is drawn.
8//! then it lines up with vi exactly: `j` moves DOWN the file toward newer (index
9//! shrinks), `k` moves UP toward older (index grows), `G` is the newest line at
10//! the bottom, `gg` is the oldest one loaded.
11
12use std::cell::Cell;
13
14/// what the last draw actually fit on screen. only the layout knows, because
15/// messages are 1 or 2 rows tall depending on whether they carry an emote — so
16/// paging and keeping the cursor visible have to read it back from the frame.
17#[derive(Clone, Copy, Default)]
18pub struct View {
19    pub count: usize,
20}
21
22/// which way a search runs. forward = down the file = toward newer messages.
23#[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    /// selected message. only meaningful once the user has started navigating.
41    pub cursor: usize,
42    /// index of the newest message on screen — the viewport anchor.
43    pub scroll: usize,
44    /// visual-mode anchor; the selection runs between this and the cursor.
45    pub visual: Option<usize>,
46    /// pending motion count (`5j`). 0 means none typed.
47    pub count: usize,
48    /// saw a bare `g`, waiting to see whether it's `gg` / `gt` / `gT`.
49    pub g_pending: bool,
50    /// last search pattern and direction, for `n` / `N`.
51    pub pattern: String,
52    pub dir: Option<Dir>,
53    pub view: Cell<View>,
54}
55
56impl Vi {
57    /// back to following live chat: no selection, pinned to the newest message.
58    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    /// are we pinned to the bottom with nothing selected? while true the cursor
67    /// bar stays hidden and new messages scroll in, so live chat reads clean and
68    /// the first `k` is what reveals the cursor (less/copy-mode behaviour).
69    pub fn following(&self) -> bool {
70        self.cursor == 0 && self.scroll == 0 && self.visual.is_none()
71    }
72
73    /// consume the pending count, defaulting to 1.
74    pub fn take_count(&mut self) -> usize {
75        let n = self.count.max(1);
76        self.count = 0;
77        n
78    }
79
80    /// accumulate a count digit. `0` only counts as a digit mid-number, matching
81    /// vi (a bare `0` is a motion, and here there is no column to move to).
82    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    /// move toward newer (down the file). saturates at the newest message.
93    pub fn down(&mut self, n: usize) {
94        self.cursor = self.cursor.saturating_sub(n);
95    }
96
97    /// move toward older (up the file). saturates at the oldest one loaded.
98    pub fn up(&mut self, n: usize, last: usize) {
99        self.cursor = self.cursor.saturating_add(n).min(last);
100    }
101
102    /// `G` — the newest message.
103    pub fn to_newest(&mut self) {
104        self.cursor = 0;
105    }
106
107    /// `gg` — the oldest message still in the ring.
108    pub fn to_oldest(&mut self, last: usize) {
109        self.cursor = last;
110    }
111
112    /// the selected range as (newest_index, oldest_index), inclusive. `None`
113    /// outside visual mode.
114    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    /// is this message inside the visual selection?
120    pub fn selected(&self, idx: usize) -> bool {
121        self.selection()
122            .is_some_and(|(lo, hi)| (lo..=hi).contains(&idx))
123    }
124
125    /// pull the viewport so the cursor is on screen. `count` is what the last
126    /// frame fit; heights vary, so this can be a row out for one frame and then
127    /// settles — far cheaper than re-running layout inside the key handler.
128    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    /// new messages arrived. while the user is reading scrollback we shift the
138    /// viewport and cursor by the same amount, so the pane holds still and the
139    /// cursor stays on the message it was on instead of sliding underneath.
140    /// once they're back at the bottom, chat follows live again.
141    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    /// clamp to a ring that may have shrunk (channel switched, messages evicted).
153    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
162/// find the next match from `from`, exclusive, wrapping once (vi's `wrapscan`).
163/// `is_match(i)` tests the message at index-back-from-newest `i`. returns the
164/// index, or None if nothing in the ring matches.
165pub 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    // walk every other index once, in order, starting one step past the cursor.
175    for step in 1..=len {
176        let i = match dir {
177            // forward = toward newer = decreasing index
178            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); // k — toward older, up the pane
208        assert_eq!(v.cursor, 3);
209        v.down(1); // j — back toward newer
210        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); // already newest
218        assert_eq!(v.cursor, 0);
219        v.up(999, 9); // only 10 messages loaded
220        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    // ---- counts ----------------------------------------------------------
233
234    #[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    // ---- viewport --------------------------------------------------------
254
255    #[test]
256    fn viewport_follows_the_cursor_up_and_back_down() {
257        let mut v = vi_with(10);
258        v.up(15, 100); // cursor older than the top of the view
259        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); // back to the newest
264        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    // ---- live chat arriving under the reader ------------------------------
277
278    #[test]
279    fn scrollback_holds_still_when_messages_arrive() {
280        let mut v = vi_with(10);
281        v.up(20, 500); // reading history
282        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    // ---- visual selection -------------------------------------------------
313
314    #[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; // dragged past the anchor
326        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    // ---- search -----------------------------------------------------------
340
341    #[test]
342    fn forward_search_walks_toward_newer() {
343        // 10 messages; 2 and 7 match. from 8, forward (toward newer) hits 7.
344        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        // going forward from 5 runs out of newer messages and wraps to the oldest
360        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); // switched to a channel with 11 messages
389        assert_eq!((v.cursor, v.scroll, v.visual), (10, 10, Some(10)));
390    }
391}