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