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