Skip to main content

strop_engine/editor/
view.rs

1//! Viewport motions and the jump-back cluster (the vim canon wave):
2//! `Ctrl-d/u/f/b` scroll, `zz/zt/zb` + `H/M/L` viewport placement,
3//! `ZZ` write-quit, `Ctrl-^` alternate buffer, `gv`/`gi` re-entry,
4//! `g;`/`g,` the change list. Viewport geometry comes from
5//! `scroll_to_cursor`, which the render loop feeds every frame — the
6//! pane's `view_rows` is the editor's single source of truth for
7//! "how tall is the screen".
8
9use super::{Editor, Mode};
10
11impl Editor {
12    /// Half-page scroll (vim ctrl-d/ctrl-u): the cursor moves with the
13    /// view so its screen row holds.
14    pub(crate) fn scroll_half_page(&mut self, down: bool) {
15        self.scroll_by(self.view_rows().max(2) / 2, down);
16    }
17
18    /// Full-page scroll (vim ctrl-f/ctrl-b).
19    pub(crate) fn scroll_full_page(&mut self, down: bool) {
20        self.scroll_by(self.view_rows().saturating_sub(1).max(1), down);
21    }
22
23    pub(crate) fn scroll_by(&mut self, rows: usize, down: bool) {
24        let line = self.buf().line_of(self.head());
25        let last = self.buf().last_content_line();
26        let line = if down {
27            (line + rows).min(last)
28        } else {
29            line.saturating_sub(rows)
30        };
31        let col = self.buf().col_of(self.head());
32        let start = self.buf().line_start(line);
33        let end = self.buf().line_end(line);
34        self.set_head((start + col).min(end));
35        self.clamp_cursor();
36        self.scroll_to_cursor(self.view_rows());
37    }
38
39    /// `zz`: center the cursor line; `zt`: to the top; `zb`: bottom.
40    pub(crate) fn view_place(&mut self, place: char) {
41        let line = self.buf().line_of(self.head());
42        let rows = self.view_rows();
43        let last = self.buf().last_content_line();
44        let top = match place {
45            't' => line,
46            'b' => (line + 1).saturating_sub(rows),
47            _ => (line + 1).saturating_sub(rows.max(2) / 2 + 1),
48        };
49        // never scroll past the content end (vim keeps the tail full)
50        let max_top = (last + 1).saturating_sub(rows);
51        self.view_mut().view_top = top.min(max_top);
52    }
53
54    /// `H`/`M`/`L`: jump to the top/middle/bottom VISIBLE line (vim:
55    /// first non-blank of that line; a count on H/L offsets).
56    pub(crate) fn jump_visible(&mut self, which: char, count: usize) {
57        let rows = self.view_rows();
58        let last = self.buf().last_content_line();
59        let line = match which {
60            'H' => self.view_top() + count.saturating_sub(1),
61            'L' => {
62                (self.view_top() + rows.saturating_sub(1)).saturating_sub(count.saturating_sub(1))
63            }
64            _ => self.view_top() + rows.max(2) / 2,
65        }
66        .min(last);
67        let s = self.buf().line_start(line);
68        let e = self.buf().line_end(line);
69        // first non-blank, like vim
70        let mut p = s;
71        while p < e
72            && self
73                .buf()
74                .byte_at(p)
75                .is_some_and(|b| b == b' ' || b == b'\t')
76        {
77            p += 1;
78        }
79        self.set_head(p.min(e));
80        self.clamp_cursor();
81    }
82
83    /// Counted scroll dispatch (vim: <count>ctrl-d scrolls count lines).
84    pub(crate) fn scroll_counted(&mut self, which: char, n: usize) {
85        let counted = n > 1;
86        match (which, counted) {
87            ('d', true) => self.scroll_by(n, true),
88            ('u', true) => self.scroll_by(n, false),
89            ('d', false) => self.scroll_half_page(true),
90            ('u', false) => self.scroll_half_page(false),
91            ('f', _) => self.scroll_full_page(true),
92            _ => self.scroll_full_page(false),
93        }
94    }
95
96    /// `ZZ`: write + close the window (vim's :x — only writes when
97    /// dirty; a failed save keeps everything open).
98    pub(crate) fn write_quit(&mut self) {
99        if self.buf().dirty {
100            self.request_save(None, false, true);
101            return;
102        }
103        self.close_pane_or_buffer(false);
104    }
105
106    /// `Ctrl-^`: the alternate buffer (most-recently-used other doc).
107    pub(crate) fn alternate_buffer(&mut self) {
108        let cur = self.current();
109        let Some(&alt) = self.mru.iter().find(|&&id| id != cur) else {
110            self.message = "no alternate buffer".into();
111            return;
112        };
113        self.switch_to(alt);
114        self.clamp_cursor();
115    }
116
117    /// `gv`: reselect the last visual range exactly (vim).
118    pub(crate) fn reselect_visual(&mut self) {
119        let Some((anchor, head)) = self.last_visual else {
120            self.message = "no previous visual selection".into();
121            return;
122        };
123        let max = self.buf().len_bytes();
124        let (a, h) = (anchor.min(max), head.min(max));
125        let a = self.buf().clamp_boundary(a);
126        let h = self.buf().clamp_boundary(h);
127        self.sels_mut().stretch_primary(a, h);
128        self.mode = Mode::Visual;
129    }
130
131    /// `gi`: insert where the last insert session ended (vim).
132    pub(crate) fn insert_at_last(&mut self) {
133        let Some(pos) = self.last_insert_pos else {
134            self.message = "no previous insert".into();
135            return;
136        };
137        let pos = self.buf().clamp_boundary(pos.min(self.buf().len_bytes()));
138        self.set_head(pos);
139        self.mode = Mode::Insert;
140    }
141
142    /// `g;` / `g,`: walk the change list — derived from the undo
143    /// history's ancestor chain, no parallel recording (0015 doctrine:
144    /// one source of truth).
145    pub(crate) fn change_jump(&mut self, back: bool) {
146        let positions = self.change_positions();
147        if positions.is_empty() {
148            self.message = "change list is empty".into();
149            return;
150        }
151        // g; starts at the newest change and walks older; g, back
152        // newer. A new commit invalidates the walk (the idx is only
153        // meaningful against the depth it was taken at).
154        let depth = self.buf().history().depth();
155        let idx = match self.change_idx.filter(|(_, d)| *d == depth) {
156            None => {
157                if back {
158                    0
159                } else {
160                    self.message = "at the newest change".into();
161                    return;
162                }
163            }
164            Some((i, _)) => {
165                if back {
166                    (i + 1).min(positions.len() - 1)
167                } else {
168                    i.saturating_sub(1)
169                }
170            }
171        };
172        self.change_idx = Some((idx, depth));
173        let pos = positions[idx];
174        let pos = self.buf().clamp_boundary(pos.min(self.buf().len_bytes()));
175        self.set_head(pos);
176        self.clamp_cursor();
177        self.scroll_to_cursor(self.view_rows());
178    }
179
180    /// Ancestor-chain change positions, newest first.
181    fn change_positions(&self) -> Vec<usize> {
182        self.buf().history().change_positions()
183    }
184}