Skip to main content

tmprl_ui/
lib.rs

1//! The window tree: splits, tabs and focus, expressed as rectangles.
2//!
3//! There are no ratatui types here and no dependencies at all. A layout is a tree plus some
4//! arithmetic, and keeping the terminal out of it is what lets the rules, where focus goes
5//! when you press `<C-w>l`, what happens to a split's siblings when you close it, be tested
6//! as plain functions.
7//!
8//! The model is vim's, because the people who want a Temporal client in their terminal
9//! mostly have vim's window commands in their fingers already. It also means the diff
10//! feature is not a feature: two workflow histories in a side-by-side split *is* the
11//! comparison, and it works for any two views rather than a pair somebody anticipated.
12
13mod tabs;
14mod tree;
15
16pub use tabs::Tabs;
17pub use tree::{Pane, Tree};
18
19/// Which way a split divides its children.
20///
21/// Named for what you *see* rather than for the cut, because "horizontal split" is ambiguous
22/// in exactly the way that produces transposed layouts: vim's `:split` is called horizontal
23/// and stacks windows vertically.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub enum Axis {
26    /// Side by side, divided along x. vim's `:vsplit`.
27    Columns,
28    /// One above another, divided along y. vim's `:split`.
29    Rows,
30}
31
32impl Axis {
33    pub fn other(self) -> Self {
34        match self {
35            Axis::Columns => Axis::Rows,
36            Axis::Rows => Axis::Columns,
37        }
38    }
39}
40
41/// Where to move focus, or which edge to drag.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum Direction {
44    Left,
45    Right,
46    Up,
47    Down,
48}
49
50impl Direction {
51    /// The axis a split must divide on for this direction to mean anything within it.
52    pub fn axis(self) -> Axis {
53        match self {
54            Direction::Left | Direction::Right => Axis::Columns,
55            Direction::Up | Direction::Down => Axis::Rows,
56        }
57    }
58
59    /// Whether this direction increases the coordinate.
60    pub fn is_forward(self) -> bool {
61        matches!(self, Direction::Right | Direction::Down)
62    }
63}
64
65/// What a pane is showing. Opaque here: this crate arranges rectangles and does not care
66/// what is drawn in them.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
68pub struct ViewId(pub u64);
69
70/// A rectangle in terminal cells.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72pub struct Rect {
73    pub x: u16,
74    pub y: u16,
75    pub width: u16,
76    pub height: u16,
77}
78
79impl Rect {
80    pub fn new(x: u16, y: u16, width: u16, height: u16) -> Self {
81        Self {
82            x,
83            y,
84            width,
85            height,
86        }
87    }
88
89    pub fn right(self) -> u16 {
90        self.x.saturating_add(self.width)
91    }
92
93    pub fn bottom(self) -> u16 {
94        self.y.saturating_add(self.height)
95    }
96
97    pub fn is_empty(self) -> bool {
98        self.width == 0 || self.height == 0
99    }
100
101    /// Length along an axis.
102    pub fn extent(self, axis: Axis) -> u16 {
103        match axis {
104            Axis::Columns => self.width,
105            Axis::Rows => self.height,
106        }
107    }
108
109    /// Whether the two overlap on the axis *perpendicular* to `axis`.
110    ///
111    /// This is what makes `<C-w>l` land somewhere sensible: of the windows to the right,
112    /// the ones worth considering are those that share some rows with this one.
113    pub fn overlaps_across(self, other: Rect, axis: Axis) -> bool {
114        match axis {
115            Axis::Columns => self.y < other.bottom() && other.y < self.bottom(),
116            Axis::Rows => self.x < other.right() && other.x < self.right(),
117        }
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn an_axis_has_an_opposite() {
127        assert_eq!(Axis::Columns.other(), Axis::Rows);
128        assert_eq!(Axis::Rows.other(), Axis::Columns);
129    }
130
131    #[test]
132    fn a_direction_knows_which_split_it_can_move_within() {
133        assert_eq!(Direction::Left.axis(), Axis::Columns);
134        assert_eq!(Direction::Right.axis(), Axis::Columns);
135        assert_eq!(Direction::Up.axis(), Axis::Rows);
136        assert_eq!(Direction::Down.axis(), Axis::Rows);
137        assert!(Direction::Right.is_forward() && Direction::Down.is_forward());
138        assert!(!Direction::Left.is_forward() && !Direction::Up.is_forward());
139    }
140
141    #[test]
142    fn rect_edges_saturate_rather_than_wrapping() {
143        let r = Rect::new(u16::MAX - 1, u16::MAX - 1, 10, 10);
144        assert_eq!(r.right(), u16::MAX);
145        assert_eq!(r.bottom(), u16::MAX);
146    }
147
148    #[test]
149    fn overlap_is_measured_across_the_axis_not_along_it() {
150        // Two panes side by side, sharing every row: they overlap across Columns.
151        let left = Rect::new(0, 0, 10, 20);
152        let right = Rect::new(10, 0, 10, 20);
153        assert!(left.overlaps_across(right, Axis::Columns));
154
155        // The same pair share no columns, so moving up or down between them is meaningless.
156        assert!(!left.overlaps_across(right, Axis::Rows));
157    }
158
159    #[test]
160    fn touching_edges_do_not_count_as_overlapping() {
161        // A pane ending at y=10 and one starting at y=10 share no row.
162        let top = Rect::new(0, 0, 10, 10);
163        let bottom = Rect::new(0, 10, 10, 10);
164        assert!(!top.overlaps_across(bottom, Axis::Columns));
165        assert!(
166            top.overlaps_across(bottom, Axis::Rows),
167            "they share columns"
168        );
169    }
170
171    #[test]
172    fn an_empty_rect_is_one_with_no_area() {
173        assert!(Rect::new(0, 0, 0, 5).is_empty());
174        assert!(Rect::new(0, 0, 5, 0).is_empty());
175        assert!(!Rect::new(0, 0, 1, 1).is_empty());
176    }
177
178    #[test]
179    fn extent_reads_the_axis_it_is_asked_for() {
180        let r = Rect::new(0, 0, 30, 10);
181        assert_eq!(r.extent(Axis::Columns), 30);
182        assert_eq!(r.extent(Axis::Rows), 10);
183    }
184}