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