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    /// The container tree, for a face that wants to solve against its own
137    /// area rather than the last reported frame.
138    #[must_use]
139    pub const fn tree(&self) -> &shikiri::Shikiri {
140        &self.tree
141    }
142
143    /// Pane geometry, DERIVED. Never stored, so it cannot go stale.
144    #[must_use]
145    pub fn solved(&self) -> shikiri::Solved {
146        shikiri::solve(&self.tree, self.frame)
147    }
148
149    /// Every window, in layout order.
150    pub fn windows(&self) -> impl Iterator<Item = &Window> {
151        fn walk<'a>(n: &'a shikiri::Shikiri, out: &mut Vec<&'a Window>) {
152            match n {
153                shikiri::Shikiri::Pane(w) => out.push(w),
154                shikiri::Shikiri::Split(s) => s.children().for_each(|c| walk(c, out)),
155            }
156        }
157        let mut v = Vec::new();
158        walk(&self.tree, &mut v);
159        v.into_iter()
160    }
161
162    /// Every window, mutably. Used to push per-pane viewport sizes down.
163    pub fn windows_mut(&mut self) -> Vec<&mut Window> {
164        fn walk<'a>(n: &'a mut shikiri::Shikiri, out: &mut Vec<&'a mut Window>) {
165            match n {
166                shikiri::Shikiri::Pane(w) => out.push(w),
167                shikiri::Shikiri::Split(s) => s.children_mut().for_each(|c| walk(c, out)),
168            }
169        }
170        let mut v = Vec::new();
171        walk(&mut self.tree, &mut v);
172        v
173    }
174
175    #[must_use]
176    pub fn active_window(&self) -> Option<&Window> {
177        let id = self.active;
178        self.windows().find(|w| w.id == id)
179    }
180
181    pub fn active_window_mut(&mut self) -> Option<&mut Window> {
182        let id = self.active;
183        self.windows_mut().into_iter().find(|w| w.id == id)
184    }
185
186    #[must_use]
187    pub fn count(&self) -> usize {
188        self.windows().count()
189    }
190
191    /// Split the ACTIVE window along `axis`, showing the same buffer.
192    ///
193    /// The new window is focused and returned. vim's default is
194    /// `splitbelow`/`splitright` OFF, so the NEW window goes above (`:sp`) or
195    /// left (`:vsp`) — it becomes the FIRST child. Scroll position is copied,
196    /// so both panes start showing the same thing, which is what makes `:sp`
197    /// feel like "look at this file in two places" rather than a jump.
198    pub fn split_active(&mut self, axis: shikiri::Axis) -> WindowId {
199        let id = WindowId(self.next_id);
200        self.next_id += 1;
201        let active = self.active;
202        let fresh = self
203            .active_window()
204            .map(|w| Window {
205                id,
206                buffer_id: w.buffer_id,
207                viewport: w.viewport,
208            })
209            .unwrap_or(Window {
210                id,
211                buffer_id: BufferId(0),
212                viewport: Viewport::default(),
213            });
214        split_at(&mut self.tree, active, axis, fresh);
215        self.active = id;
216        id
217    }
218
219    /// Close `id`, collapsing its parent. The LAST window never closes —
220    /// vim refuses too ("E444: Cannot close last window").
221    pub fn close(&mut self, id: WindowId) -> bool {
222        if self.count() <= 1 {
223            return false;
224        }
225        // Focus a survivor BEFORE removing, so `active` is never dangling.
226        if self.active == id {
227            let next = self
228                .windows()
229                .map(|w| w.id)
230                .find(|w| *w != id)
231                .unwrap_or(id);
232            self.active = next;
233        }
234        remove(&mut self.tree, id)
235    }
236
237    /// The window nearest the active one in `dir` — `<C-w>hjkl`.
238    ///
239    /// Geometric, not tree-structural: it compares SOLVED rects, so the
240    /// answer is what the operator sees rather than an artefact of which
241    /// split happened first.
242    #[must_use]
243    pub fn neighbour(&self, dir: Dir) -> Option<WindowId> {
244        let solved = self.solved();
245        let here = solved.rect_of(self.active)?;
246        solved
247            .panes
248            .iter()
249            .filter(|(id, _)| *id != self.active)
250            .filter(|(_, r)| match dir {
251                Dir::Left => r.x + r.w <= here.x,
252                Dir::Right => r.x >= here.x + here.w,
253                Dir::Up => r.y + r.h <= here.y,
254                Dir::Down => r.y >= here.y + here.h,
255            })
256            // Nearest along the axis of travel, then nearest across it, so a
257            // column of stacked panes picks the one beside the cursor rather
258            // than whichever the tree happens to list first.
259            .min_by_key(|(_, r)| match dir {
260                Dir::Left => (here.x.saturating_sub(r.x + r.w), here.y.abs_diff(r.y)),
261                Dir::Right => (r.x.saturating_sub(here.x + here.w), here.y.abs_diff(r.y)),
262                Dir::Up => (here.y.saturating_sub(r.y + r.h), here.x.abs_diff(r.x)),
263                Dir::Down => (r.y.saturating_sub(here.y + here.h), here.x.abs_diff(r.x)),
264            })
265            .map(|(id, _)| *id)
266    }
267}
268
269/// A direction to move focus in.
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum Dir {
272    Left,
273    Right,
274    Up,
275    Down,
276}
277
278/// Replace the pane holding `target` with a split of `(fresh, that pane)`.
279fn split_at(
280    node: &mut shikiri::Shikiri,
281    target: WindowId,
282    axis: shikiri::Axis,
283    fresh: Window,
284) -> bool {
285    match node {
286        shikiri::Shikiri::Pane(w) if w.id == target => {
287            let existing = std::mem::replace(node, shikiri::Shikiri::Pane(fresh.clone()));
288            *node = shikiri::Shikiri::Split(shikiri::Split::new(
289                axis,
290                shikiri::Shikiri::Pane(fresh),
291                existing,
292            ));
293            true
294        }
295        shikiri::Shikiri::Pane(_) => false,
296        shikiri::Shikiri::Split(s) => s
297            .children_mut()
298            .any(|c| split_at(c, target, axis, fresh.clone())),
299    }
300}
301
302/// Remove the pane holding `id`, collapsing a split left with one child.
303fn remove(node: &mut shikiri::Shikiri, id: WindowId) -> bool {
304    let shikiri::Shikiri::Split(s) = node else {
305        return false;
306    };
307    if let Some(rest) = s.without(id) {
308        *node = rest;
309        return true;
310    }
311    let shikiri::Shikiri::Split(s) = node else {
312        return false;
313    };
314    s.children_mut().any(|c| remove(c, id))
315}
316
317#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
318pub struct StatusLine {
319    pub mode: String,
320    pub path: Option<String>,
321    pub cursor: Position,
322    pub modified: bool,
323    pub line_count: u32,
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    fn small() -> Viewport {
331        // 5 visible lines × 10 visible columns — a tight window so the
332        // scroll-to-contain logic is exercised on small inputs.
333        Viewport {
334            top_line: 0,
335            left_column: 0,
336            visible_lines: 5,
337            visible_columns: 10,
338        }
339    }
340
341    #[test]
342    fn viewport_scrolls_down() {
343        let v = Viewport {
344            top_line: 0,
345            left_column: 0,
346            visible_lines: 20,
347            visible_columns: 80,
348        };
349        let v2 = v.scroll_to_contain(Position::new(30, 0), 2);
350        assert!(v2.top_line > 0);
351    }
352
353    #[test]
354    fn scroll_noop_when_already_visible() {
355        let v = small();
356        // (line 2, col 4) is well inside a 0..5 × 0..10 window.
357        let v2 = v.scroll_to_contain(Position::new(2, 4), 2);
358        assert_eq!(v2, v, "an in-window position must not move the viewport");
359    }
360
361    #[test]
362    fn scroll_down_keeps_cursor_visible() {
363        let v = small();
364        let v2 = v.scroll_to_contain(Position::new(30, 0), 2);
365        assert!(
366            v2.top_line <= 30 && 30 < v2.top_line + v2.visible_lines,
367            "cursor line must be within [top_line, top_line+visible_lines): {v2:?}"
368        );
369    }
370
371    #[test]
372    fn scroll_right_keeps_cursor_visible() {
373        let v = small();
374        // Cursor past the right edge of a 10-wide window.
375        let v2 = v.scroll_to_contain(Position::new(0, 50), 2);
376        assert!(
377            v2.left_column <= 50 && 50 < v2.left_column + v2.visible_columns,
378            "cursor column must be within [left_column, left_column+visible_columns): {v2:?}"
379        );
380    }
381
382    #[test]
383    fn scroll_up_left_returns_toward_origin() {
384        // Start scrolled away from the origin, then ask for a position
385        // above and to the left of the current window.
386        let v = Viewport {
387            top_line: 20,
388            left_column: 30,
389            visible_lines: 5,
390            visible_columns: 10,
391        };
392        let v2 = v.scroll_to_contain(Position::new(2, 3), 2);
393        assert!(v2.top_line <= 2, "must scroll up to reveal line 2: {v2:?}");
394        assert!(
395            v2.left_column <= 3,
396            "must scroll left to reveal col 3: {v2:?}"
397        );
398    }
399
400    #[test]
401    fn scroll_saturates_at_origin() {
402        // Position (0,0) with a margin can't push the viewport negative.
403        let v = small();
404        let v2 = v.scroll_to_contain(Position::ZERO, 2);
405        assert_eq!(v2.top_line, 0);
406        assert_eq!(v2.left_column, 0);
407    }
408
409    #[test]
410    fn scroll_respects_margin_on_both_axes() {
411        // From the origin, jump just past the bottom-right corner; both
412        // axes should leave `margin` of context past the cursor where the
413        // window size allows.
414        let v = small(); // 5 lines, 10 cols
415        let v2 = v.scroll_to_contain(Position::new(4, 9), 2);
416        // bot = line+margin = 6 >= top+visible(5) → top = 6 - 4 = 2
417        assert_eq!(v2.top_line, 2, "{v2:?}");
418        // right = col+margin = 11 >= left+visible(10) → left = 11 - 9 = 2
419        assert_eq!(v2.left_column, 2, "{v2:?}");
420        // and the cursor is still inside the window
421        assert!(v2.top_line <= 4 && 4 < v2.top_line + v2.visible_lines);
422        assert!(v2.left_column <= 9 && 9 < v2.left_column + v2.visible_columns);
423    }
424
425    #[test]
426    fn layout_active_resolves() {
427        let w = Window {
428            id: WindowId(1),
429            buffer_id: BufferId(1),
430            viewport: Viewport::default(),
431        };
432        let layout = Layout::single(w);
433        assert_eq!(layout.active_window().unwrap().id, WindowId(1));
434    }
435}