Skip to main content

escriba_ui/
lib.rs

1//! `escriba-ui` — Layout, Window, Viewport, StatusLine. Pure state; rendering lives in escriba-render.
2
3extern crate self as escriba_ui;
4
5/// Theme → concrete chrome colors. The ONE place a `FleetTheme` becomes
6/// paintable values, shared by the TUI and GPU renderers so they cannot
7/// drift apart (or off the fleet baseline) again.
8pub mod chrome;
9
10/// The start screen — one laid-out model every face paints, rather than
11/// three copies of the same centering arithmetic.
12pub mod splash;
13
14/// Syntax colours resolved through ishou, so a theme change recolours the
15/// CODE and not just the frame. hikari ships one hardcoded Nord table; this
16/// reproduces it on the fleet default and extends it to every theme.
17pub mod syntax;
18
19/// 仕切り — the container tree behind `:sp` / `:vsp`. Pane geometry is
20/// DERIVED by `solve(tree, frame)`; scroll position stays on the window.
21pub mod shikiri;
22
23/// The picker — a filtered list of candidates that holds keys while open.
24/// The narrowing machine is `egaku::FuzzyPicker`; escriba owns the SOURCE
25/// (what accepting means) and the key translation.
26pub mod picker;
27
28/// The gutter — line numbers and finding marks, composed once. The ratatui
29/// face built its own inline and the GPU face had none at all; this is the
30/// same "one model, N faces" repair the status line and splash already had.
31pub mod gutter;
32
33use escriba_core::{BufferId, Position, WindowId};
34use schemars::JsonSchema;
35use serde::{Deserialize, Serialize};
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
38pub struct Viewport {
39    pub top_line: u32,
40    pub left_column: u32,
41    pub visible_lines: u32,
42    pub visible_columns: u32,
43}
44
45impl Viewport {
46    /// Scroll both axes so `p` is visible within this viewport, keeping at
47    /// least `margin` lines/columns of context on each edge where room
48    /// allows. The same `margin` applies to both axes. All arithmetic is
49    /// saturating, so the viewport never scrolls past `0` and a position
50    /// at the very top/left is clamped flush to the origin.
51    ///
52    /// Pairing this with the editor's single cursor-mutation path makes
53    /// "cursor outside its viewport" an unrepresentable state: every move
54    /// re-derives the viewport from the (clamped) cursor.
55    #[must_use]
56    pub fn scroll_to_contain(mut self, p: Position, margin: u32) -> Self {
57        // ── Vertical axis (top_line / visible_lines). ──
58        let bot = p.line.saturating_add(margin);
59        if p.line < self.top_line {
60            self.top_line = p.line.saturating_sub(margin);
61        }
62        if bot >= self.top_line.saturating_add(self.visible_lines) {
63            self.top_line = bot.saturating_sub(self.visible_lines.saturating_sub(1));
64        }
65
66        // ── Horizontal axis (left_column / visible_columns). ──
67        let right = p.column.saturating_add(margin);
68        if p.column < self.left_column {
69            self.left_column = p.column.saturating_sub(margin);
70        }
71        if right >= self.left_column.saturating_add(self.visible_columns) {
72            self.left_column = right.saturating_sub(self.visible_columns.saturating_sub(1));
73        }
74        self
75    }
76}
77
78/// A window lives in the tree that owns it. Re-exported so the paths every
79/// face already uses keep working.
80pub use shikiri::Window;
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
83pub struct Layout {
84    /// The container tree. Owns every window; there is no second collection
85    /// that could disagree with it about which windows exist.
86    tree: shikiri::Shikiri,
87    /// The focused window.
88    active: WindowId,
89    /// The last frame a FACE reported, in cells. The only retained size in
90    /// the model — every pane rect is derived from it by `solve`.
91    frame: shikiri::Rect,
92    next_id: u64,
93    pub statusline: bool,
94    pub tabbar: bool,
95}
96
97impl Layout {
98    #[must_use]
99    pub fn single(window: Window) -> Self {
100        Self {
101            active: window.id,
102            next_id: window.id.0 + 1,
103            tree: shikiri::Shikiri::Pane(window),
104            frame: shikiri::Rect::default(),
105            statusline: true,
106            tabbar: true,
107        }
108    }
109
110    #[must_use]
111    pub const fn active(&self) -> WindowId {
112        self.active
113    }
114
115    /// Focus `id` if it is in the tree. Returns whether it moved.
116    pub fn focus(&mut self, id: WindowId) -> bool {
117        if self.windows().any(|w| w.id == id) {
118            self.active = id;
119            true
120        } else {
121            false
122        }
123    }
124
125    /// Tell the layout how big its frame is. A face calls this; nothing else
126    /// stores a size.
127    pub fn set_frame(&mut self, frame: shikiri::Rect) {
128        self.frame = frame;
129    }
130
131    #[must_use]
132    pub const fn frame(&self) -> shikiri::Rect {
133        self.frame
134    }
135
136    /// Pane geometry, DERIVED. Never stored, so it cannot go stale.
137    #[must_use]
138    pub fn solved(&self) -> shikiri::Solved {
139        shikiri::solve(&self.tree, self.frame)
140    }
141
142    /// Every window, in layout order.
143    pub fn windows(&self) -> impl Iterator<Item = &Window> {
144        fn walk<'a>(n: &'a shikiri::Shikiri, out: &mut Vec<&'a Window>) {
145            match n {
146                shikiri::Shikiri::Pane(w) => out.push(w),
147                shikiri::Shikiri::Split(s) => s.children().for_each(|c| walk(c, out)),
148            }
149        }
150        let mut v = Vec::new();
151        walk(&self.tree, &mut v);
152        v.into_iter()
153    }
154
155    /// Every window, mutably. Used to push per-pane viewport sizes down.
156    pub fn windows_mut(&mut self) -> Vec<&mut Window> {
157        fn walk<'a>(n: &'a mut shikiri::Shikiri, out: &mut Vec<&'a mut Window>) {
158            match n {
159                shikiri::Shikiri::Pane(w) => out.push(w),
160                shikiri::Shikiri::Split(s) => s.children_mut().for_each(|c| walk(c, out)),
161            }
162        }
163        let mut v = Vec::new();
164        walk(&mut self.tree, &mut v);
165        v
166    }
167
168    #[must_use]
169    pub fn active_window(&self) -> Option<&Window> {
170        let id = self.active;
171        self.windows().find(|w| w.id == id)
172    }
173
174    pub fn active_window_mut(&mut self) -> Option<&mut Window> {
175        let id = self.active;
176        self.windows_mut().into_iter().find(|w| w.id == id)
177    }
178
179    #[must_use]
180    pub fn count(&self) -> usize {
181        self.windows().count()
182    }
183
184    /// Split the ACTIVE window along `axis`, showing the same buffer.
185    ///
186    /// The new window is focused and returned. vim's default is
187    /// `splitbelow`/`splitright` OFF, so the NEW window goes above (`:sp`) or
188    /// left (`:vsp`) — it becomes the FIRST child. Scroll position is copied,
189    /// so both panes start showing the same thing, which is what makes `:sp`
190    /// feel like "look at this file in two places" rather than a jump.
191    pub fn split_active(&mut self, axis: shikiri::Axis) -> WindowId {
192        let id = WindowId(self.next_id);
193        self.next_id += 1;
194        let active = self.active;
195        let fresh = self
196            .active_window()
197            .map(|w| Window {
198                id,
199                buffer_id: w.buffer_id,
200                viewport: w.viewport,
201            })
202            .unwrap_or(Window {
203                id,
204                buffer_id: BufferId(0),
205                viewport: Viewport::default(),
206            });
207        split_at(&mut self.tree, active, axis, fresh);
208        self.active = id;
209        id
210    }
211
212    /// Close `id`, collapsing its parent. The LAST window never closes —
213    /// vim refuses too ("E444: Cannot close last window").
214    pub fn close(&mut self, id: WindowId) -> bool {
215        if self.count() <= 1 {
216            return false;
217        }
218        // Focus a survivor BEFORE removing, so `active` is never dangling.
219        if self.active == id {
220            let next = self
221                .windows()
222                .map(|w| w.id)
223                .find(|w| *w != id)
224                .unwrap_or(id);
225            self.active = next;
226        }
227        remove(&mut self.tree, id)
228    }
229
230    /// The window nearest the active one in `dir` — `<C-w>hjkl`.
231    ///
232    /// Geometric, not tree-structural: it compares SOLVED rects, so the
233    /// answer is what the operator sees rather than an artefact of which
234    /// split happened first.
235    #[must_use]
236    pub fn neighbour(&self, dir: Dir) -> Option<WindowId> {
237        let solved = self.solved();
238        let here = solved.rect_of(self.active)?;
239        solved
240            .panes
241            .iter()
242            .filter(|(id, _)| *id != self.active)
243            .filter(|(_, r)| match dir {
244                Dir::Left => r.x + r.w <= here.x,
245                Dir::Right => r.x >= here.x + here.w,
246                Dir::Up => r.y + r.h <= here.y,
247                Dir::Down => r.y >= here.y + here.h,
248            })
249            // Nearest along the axis of travel, then nearest across it, so a
250            // column of stacked panes picks the one beside the cursor rather
251            // than whichever the tree happens to list first.
252            .min_by_key(|(_, r)| match dir {
253                Dir::Left => (here.x.saturating_sub(r.x + r.w), here.y.abs_diff(r.y)),
254                Dir::Right => (r.x.saturating_sub(here.x + here.w), here.y.abs_diff(r.y)),
255                Dir::Up => (here.y.saturating_sub(r.y + r.h), here.x.abs_diff(r.x)),
256                Dir::Down => (r.y.saturating_sub(here.y + here.h), here.x.abs_diff(r.x)),
257            })
258            .map(|(id, _)| *id)
259    }
260}
261
262/// A direction to move focus in.
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub enum Dir {
265    Left,
266    Right,
267    Up,
268    Down,
269}
270
271/// Replace the pane holding `target` with a split of `(fresh, that pane)`.
272fn split_at(
273    node: &mut shikiri::Shikiri,
274    target: WindowId,
275    axis: shikiri::Axis,
276    fresh: Window,
277) -> bool {
278    match node {
279        shikiri::Shikiri::Pane(w) if w.id == target => {
280            let existing = std::mem::replace(node, shikiri::Shikiri::Pane(fresh.clone()));
281            *node = shikiri::Shikiri::Split(shikiri::Split::new(
282                axis,
283                shikiri::Shikiri::Pane(fresh),
284                existing,
285            ));
286            true
287        }
288        shikiri::Shikiri::Pane(_) => false,
289        shikiri::Shikiri::Split(s) => s
290            .children_mut()
291            .any(|c| split_at(c, target, axis, fresh.clone())),
292    }
293}
294
295/// Remove the pane holding `id`, collapsing a split left with one child.
296fn remove(node: &mut shikiri::Shikiri, id: WindowId) -> bool {
297    let shikiri::Shikiri::Split(s) = node else {
298        return false;
299    };
300    if let Some(rest) = s.without(id) {
301        *node = rest;
302        return true;
303    }
304    let shikiri::Shikiri::Split(s) = node else {
305        return false;
306    };
307    s.children_mut().any(|c| remove(c, id))
308}
309
310#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
311pub struct StatusLine {
312    pub mode: String,
313    pub path: Option<String>,
314    pub cursor: Position,
315    pub modified: bool,
316    pub line_count: u32,
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    fn small() -> Viewport {
324        // 5 visible lines × 10 visible columns — a tight window so the
325        // scroll-to-contain logic is exercised on small inputs.
326        Viewport {
327            top_line: 0,
328            left_column: 0,
329            visible_lines: 5,
330            visible_columns: 10,
331        }
332    }
333
334    #[test]
335    fn viewport_scrolls_down() {
336        let v = Viewport {
337            top_line: 0,
338            left_column: 0,
339            visible_lines: 20,
340            visible_columns: 80,
341        };
342        let v2 = v.scroll_to_contain(Position::new(30, 0), 2);
343        assert!(v2.top_line > 0);
344    }
345
346    #[test]
347    fn scroll_noop_when_already_visible() {
348        let v = small();
349        // (line 2, col 4) is well inside a 0..5 × 0..10 window.
350        let v2 = v.scroll_to_contain(Position::new(2, 4), 2);
351        assert_eq!(v2, v, "an in-window position must not move the viewport");
352    }
353
354    #[test]
355    fn scroll_down_keeps_cursor_visible() {
356        let v = small();
357        let v2 = v.scroll_to_contain(Position::new(30, 0), 2);
358        assert!(
359            v2.top_line <= 30 && 30 < v2.top_line + v2.visible_lines,
360            "cursor line must be within [top_line, top_line+visible_lines): {v2:?}"
361        );
362    }
363
364    #[test]
365    fn scroll_right_keeps_cursor_visible() {
366        let v = small();
367        // Cursor past the right edge of a 10-wide window.
368        let v2 = v.scroll_to_contain(Position::new(0, 50), 2);
369        assert!(
370            v2.left_column <= 50 && 50 < v2.left_column + v2.visible_columns,
371            "cursor column must be within [left_column, left_column+visible_columns): {v2:?}"
372        );
373    }
374
375    #[test]
376    fn scroll_up_left_returns_toward_origin() {
377        // Start scrolled away from the origin, then ask for a position
378        // above and to the left of the current window.
379        let v = Viewport {
380            top_line: 20,
381            left_column: 30,
382            visible_lines: 5,
383            visible_columns: 10,
384        };
385        let v2 = v.scroll_to_contain(Position::new(2, 3), 2);
386        assert!(v2.top_line <= 2, "must scroll up to reveal line 2: {v2:?}");
387        assert!(
388            v2.left_column <= 3,
389            "must scroll left to reveal col 3: {v2:?}"
390        );
391    }
392
393    #[test]
394    fn scroll_saturates_at_origin() {
395        // Position (0,0) with a margin can't push the viewport negative.
396        let v = small();
397        let v2 = v.scroll_to_contain(Position::ZERO, 2);
398        assert_eq!(v2.top_line, 0);
399        assert_eq!(v2.left_column, 0);
400    }
401
402    #[test]
403    fn scroll_respects_margin_on_both_axes() {
404        // From the origin, jump just past the bottom-right corner; both
405        // axes should leave `margin` of context past the cursor where the
406        // window size allows.
407        let v = small(); // 5 lines, 10 cols
408        let v2 = v.scroll_to_contain(Position::new(4, 9), 2);
409        // bot = line+margin = 6 >= top+visible(5) → top = 6 - 4 = 2
410        assert_eq!(v2.top_line, 2, "{v2:?}");
411        // right = col+margin = 11 >= left+visible(10) → left = 11 - 9 = 2
412        assert_eq!(v2.left_column, 2, "{v2:?}");
413        // and the cursor is still inside the window
414        assert!(v2.top_line <= 4 && 4 < v2.top_line + v2.visible_lines);
415        assert!(v2.left_column <= 9 && 9 < v2.left_column + v2.visible_columns);
416    }
417
418    #[test]
419    fn layout_active_resolves() {
420        let w = Window {
421            id: WindowId(1),
422            buffer_id: BufferId(1),
423            viewport: Viewport::default(),
424        };
425        let layout = Layout::single(w);
426        assert_eq!(layout.active_window().unwrap().id, WindowId(1));
427    }
428}