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