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 picker — a filtered list of candidates that holds keys while open.
20/// The narrowing machine is `egaku::FuzzyPicker`; escriba owns the SOURCE
21/// (what accepting means) and the key translation.
22pub mod picker;
23
24/// The gutter — line numbers and finding marks, composed once. The ratatui
25/// face built its own inline and the GPU face had none at all; this is the
26/// same "one model, N faces" repair the status line and splash already had.
27pub mod gutter;
28
29use escriba_core::{BufferId, Position, WindowId};
30use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
34pub struct Viewport {
35    pub top_line: u32,
36    pub left_column: u32,
37    pub visible_lines: u32,
38    pub visible_columns: u32,
39}
40
41impl Viewport {
42    /// Scroll both axes so `p` is visible within this viewport, keeping at
43    /// least `margin` lines/columns of context on each edge where room
44    /// allows. The same `margin` applies to both axes. All arithmetic is
45    /// saturating, so the viewport never scrolls past `0` and a position
46    /// at the very top/left is clamped flush to the origin.
47    ///
48    /// Pairing this with the editor's single cursor-mutation path makes
49    /// "cursor outside its viewport" an unrepresentable state: every move
50    /// re-derives the viewport from the (clamped) cursor.
51    #[must_use]
52    pub fn scroll_to_contain(mut self, p: Position, margin: u32) -> Self {
53        // ── Vertical axis (top_line / visible_lines). ──
54        let bot = p.line.saturating_add(margin);
55        if p.line < self.top_line {
56            self.top_line = p.line.saturating_sub(margin);
57        }
58        if bot >= self.top_line.saturating_add(self.visible_lines) {
59            self.top_line = bot.saturating_sub(self.visible_lines.saturating_sub(1));
60        }
61
62        // ── Horizontal axis (left_column / visible_columns). ──
63        let right = p.column.saturating_add(margin);
64        if p.column < self.left_column {
65            self.left_column = p.column.saturating_sub(margin);
66        }
67        if right >= self.left_column.saturating_add(self.visible_columns) {
68            self.left_column = right.saturating_sub(self.visible_columns.saturating_sub(1));
69        }
70        self
71    }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
75pub struct Window {
76    pub id: WindowId,
77    pub buffer_id: BufferId,
78    pub viewport: Viewport,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
82pub struct Layout {
83    pub windows: Vec<Window>,
84    pub active: WindowId,
85    pub statusline: bool,
86    pub tabbar: bool,
87}
88
89impl Layout {
90    #[must_use]
91    pub fn single(window: Window) -> Self {
92        Self {
93            active: window.id,
94            windows: vec![window],
95            statusline: true,
96            tabbar: true,
97        }
98    }
99
100    #[must_use]
101    pub fn active_window(&self) -> Option<&Window> {
102        self.windows.iter().find(|w| w.id == self.active)
103    }
104}
105
106#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
107pub struct StatusLine {
108    pub mode: String,
109    pub path: Option<String>,
110    pub cursor: Position,
111    pub modified: bool,
112    pub line_count: u32,
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    fn small() -> Viewport {
120        // 5 visible lines × 10 visible columns — a tight window so the
121        // scroll-to-contain logic is exercised on small inputs.
122        Viewport {
123            top_line: 0,
124            left_column: 0,
125            visible_lines: 5,
126            visible_columns: 10,
127        }
128    }
129
130    #[test]
131    fn viewport_scrolls_down() {
132        let v = Viewport {
133            top_line: 0,
134            left_column: 0,
135            visible_lines: 20,
136            visible_columns: 80,
137        };
138        let v2 = v.scroll_to_contain(Position::new(30, 0), 2);
139        assert!(v2.top_line > 0);
140    }
141
142    #[test]
143    fn scroll_noop_when_already_visible() {
144        let v = small();
145        // (line 2, col 4) is well inside a 0..5 × 0..10 window.
146        let v2 = v.scroll_to_contain(Position::new(2, 4), 2);
147        assert_eq!(v2, v, "an in-window position must not move the viewport");
148    }
149
150    #[test]
151    fn scroll_down_keeps_cursor_visible() {
152        let v = small();
153        let v2 = v.scroll_to_contain(Position::new(30, 0), 2);
154        assert!(
155            v2.top_line <= 30 && 30 < v2.top_line + v2.visible_lines,
156            "cursor line must be within [top_line, top_line+visible_lines): {v2:?}"
157        );
158    }
159
160    #[test]
161    fn scroll_right_keeps_cursor_visible() {
162        let v = small();
163        // Cursor past the right edge of a 10-wide window.
164        let v2 = v.scroll_to_contain(Position::new(0, 50), 2);
165        assert!(
166            v2.left_column <= 50 && 50 < v2.left_column + v2.visible_columns,
167            "cursor column must be within [left_column, left_column+visible_columns): {v2:?}"
168        );
169    }
170
171    #[test]
172    fn scroll_up_left_returns_toward_origin() {
173        // Start scrolled away from the origin, then ask for a position
174        // above and to the left of the current window.
175        let v = Viewport {
176            top_line: 20,
177            left_column: 30,
178            visible_lines: 5,
179            visible_columns: 10,
180        };
181        let v2 = v.scroll_to_contain(Position::new(2, 3), 2);
182        assert!(v2.top_line <= 2, "must scroll up to reveal line 2: {v2:?}");
183        assert!(
184            v2.left_column <= 3,
185            "must scroll left to reveal col 3: {v2:?}"
186        );
187    }
188
189    #[test]
190    fn scroll_saturates_at_origin() {
191        // Position (0,0) with a margin can't push the viewport negative.
192        let v = small();
193        let v2 = v.scroll_to_contain(Position::ZERO, 2);
194        assert_eq!(v2.top_line, 0);
195        assert_eq!(v2.left_column, 0);
196    }
197
198    #[test]
199    fn scroll_respects_margin_on_both_axes() {
200        // From the origin, jump just past the bottom-right corner; both
201        // axes should leave `margin` of context past the cursor where the
202        // window size allows.
203        let v = small(); // 5 lines, 10 cols
204        let v2 = v.scroll_to_contain(Position::new(4, 9), 2);
205        // bot = line+margin = 6 >= top+visible(5) → top = 6 - 4 = 2
206        assert_eq!(v2.top_line, 2, "{v2:?}");
207        // right = col+margin = 11 >= left+visible(10) → left = 11 - 9 = 2
208        assert_eq!(v2.left_column, 2, "{v2:?}");
209        // and the cursor is still inside the window
210        assert!(v2.top_line <= 4 && 4 < v2.top_line + v2.visible_lines);
211        assert!(v2.left_column <= 9 && 9 < v2.left_column + v2.visible_columns);
212    }
213
214    #[test]
215    fn layout_active_resolves() {
216        let w = Window {
217            id: WindowId(1),
218            buffer_id: BufferId(1),
219            viewport: Viewport::default(),
220        };
221        let layout = Layout::single(w);
222        assert_eq!(layout.active_window().unwrap().id, WindowId(1));
223    }
224}