Skip to main content

ratatui_toolkit/
resizable_split.rs

1use ratatui::layout::Rect;
2use ratatui::Frame;
3
4/// Direction of the split
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum SplitDirection {
7    /// Vertical split (left/right panels) - divider is vertical, mouse drags horizontally
8    Vertical,
9    /// Horizontal split (top/bottom panels) - divider is horizontal, mouse drags vertically
10    Horizontal,
11}
12
13/// Tracks the state of a resizable split
14#[derive(Debug, Clone)]
15pub struct ResizableSplit {
16    /// Current split position as percentage (0-100)
17    /// For Vertical: left panel percentage (e.g., 70 means left is 70%, right is 30%)
18    /// For Horizontal: top panel percentage (e.g., 70 means top is 70%, bottom is 30%)
19    pub split_percent: u16,
20    /// Minimum percentage for first panel (left or top)
21    pub min_percent: u16,
22    /// Maximum percentage for first panel (left or top)
23    pub max_percent: u16,
24    /// Whether currently dragging the divider
25    pub is_dragging: bool,
26    /// Whether mouse is hovering over the divider
27    pub is_hovering: bool,
28    /// Direction of the split
29    pub direction: SplitDirection,
30    /// The column or row position of the divider (updated each frame)
31    pub divider_pos: u16,
32}
33
34impl ResizableSplit {
35    /// Create a new vertical split (default)
36    pub fn new(initial_percent: u16) -> Self {
37        Self::new_with_direction(initial_percent, SplitDirection::Vertical)
38    }
39
40    /// Create a new split with a specific direction
41    pub fn new_with_direction(initial_percent: u16, direction: SplitDirection) -> Self {
42        Self {
43            split_percent: initial_percent.clamp(5, 95),
44            min_percent: 10,
45            max_percent: 90,
46            is_dragging: false,
47            is_hovering: false,
48            direction,
49            divider_pos: 0,
50        }
51    }
52
53    /// Update the divider position based on the current area
54    pub fn update_divider_position(&mut self, area: Rect) {
55        match self.direction {
56            SplitDirection::Vertical => {
57                // Calculate where the divider is in absolute columns
58                // The divider is at the boundary between left and right panels
59                let left_width = (area.width as u32 * self.split_percent as u32 / 100) as u16;
60                self.divider_pos = area.x + left_width;
61            }
62            SplitDirection::Horizontal => {
63                // Calculate where the divider is in absolute rows
64                // The divider is at the boundary between top and bottom panels
65                let top_height = (area.height as u32 * self.split_percent as u32 / 100) as u16;
66                self.divider_pos = area.y + top_height;
67            }
68        }
69    }
70
71    /// Check if a mouse position is on the divider
72    /// We give it a 3-unit wide hit area for easier clicking (columns for vertical, rows for horizontal)
73    pub fn is_on_divider(&self, mouse_column: u16, mouse_row: u16, area: Rect) -> bool {
74        match self.direction {
75            SplitDirection::Vertical => {
76                let divider_start = self.divider_pos.saturating_sub(1);
77                let divider_end = (self.divider_pos + 1).min(area.x + area.width.saturating_sub(1));
78                mouse_column >= divider_start && mouse_column <= divider_end
79            }
80            SplitDirection::Horizontal => {
81                let divider_start = self.divider_pos.saturating_sub(1);
82                let divider_end =
83                    (self.divider_pos + 1).min(area.y + area.height.saturating_sub(1));
84                mouse_row >= divider_start && mouse_row <= divider_end
85            }
86        }
87    }
88
89    /// Start dragging the divider
90    pub fn start_drag(&mut self) {
91        self.is_dragging = true;
92    }
93
94    /// Stop dragging the divider
95    pub fn stop_drag(&mut self) {
96        self.is_dragging = false;
97    }
98
99    /// Update the split position based on mouse position
100    pub fn update_from_mouse(&mut self, mouse_column: u16, mouse_row: u16, area: Rect) {
101        if !self.is_dragging {
102            return;
103        }
104
105        let new_percent = match self.direction {
106            SplitDirection::Vertical => {
107                // Calculate the relative position within the area
108                let relative_column = mouse_column.saturating_sub(area.x);
109
110                // Convert to percentage
111                if area.width > 0 {
112                    ((relative_column as u32 * 100) / area.width as u32) as u16
113                } else {
114                    self.split_percent
115                }
116            }
117            SplitDirection::Horizontal => {
118                // Calculate the relative position within the area
119                let relative_row = mouse_row.saturating_sub(area.y);
120
121                // Convert to percentage
122                if area.height > 0 {
123                    ((relative_row as u32 * 100) / area.height as u32) as u16
124                } else {
125                    self.split_percent
126                }
127            }
128        };
129
130        // Clamp to min/max
131        self.split_percent = new_percent.clamp(self.min_percent, self.max_percent);
132    }
133
134    /// Get the percentage for the second panel (right for vertical, bottom for horizontal)
135    pub fn right_percent(&self) -> u16 {
136        100 - self.split_percent
137    }
138
139    /// Alias for right_percent - gets the percentage for the bottom panel
140    pub fn bottom_percent(&self) -> u16 {
141        self.right_percent()
142    }
143
144    /// Render a visual indicator on the divider when hovering or dragging
145    /// Note: Does nothing - border colors are changed on the panes themselves
146    pub fn render_divider_indicator(&self, _frame: &mut Frame, _area: Rect) {
147        // No separator rendering - the pane borders themselves change color
148    }
149}
150
151impl Default for ResizableSplit {
152    fn default() -> Self {
153        Self::new(70)
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_new_clamps_percentage() {
163        let split = ResizableSplit::new(150);
164        assert_eq!(split.split_percent, 95);
165
166        let split = ResizableSplit::new(0);
167        assert_eq!(split.split_percent, 5);
168    }
169
170    #[test]
171    fn test_update_divider_position_vertical() {
172        let mut split = ResizableSplit::new(50);
173        let area = Rect::new(0, 0, 100, 20);
174
175        split.update_divider_position(area);
176        // 50% of 100 columns = 50 (the boundary between left and right panels)
177        assert_eq!(split.divider_pos, 50);
178    }
179
180    #[test]
181    fn test_update_divider_position_horizontal() {
182        let mut split = ResizableSplit::new_with_direction(50, SplitDirection::Horizontal);
183        let area = Rect::new(0, 0, 100, 20);
184
185        split.update_divider_position(area);
186        // 50% of 20 rows = 10 (the boundary between top and bottom panels)
187        assert_eq!(split.divider_pos, 10);
188    }
189
190    #[test]
191    fn test_is_on_divider_vertical() {
192        let mut split = ResizableSplit::new(50);
193        let area = Rect::new(0, 0, 100, 20);
194        split.update_divider_position(area);
195
196        // Should hit within 3-column range (divider at column 50)
197        assert!(split.is_on_divider(49, 10, area)); // 1 before
198        assert!(split.is_on_divider(50, 10, area)); // exact
199        assert!(split.is_on_divider(51, 10, area)); // 1 after
200        assert!(!split.is_on_divider(47, 10, area)); // too far before
201        assert!(!split.is_on_divider(53, 10, area)); // too far after
202    }
203
204    #[test]
205    fn test_is_on_divider_horizontal() {
206        let mut split = ResizableSplit::new_with_direction(50, SplitDirection::Horizontal);
207        let area = Rect::new(0, 0, 100, 20);
208        split.update_divider_position(area);
209
210        // Should hit within 3-row range (divider at row 10)
211        assert!(split.is_on_divider(50, 9, area)); // 1 before
212        assert!(split.is_on_divider(50, 10, area)); // exact
213        assert!(split.is_on_divider(50, 11, area)); // 1 after
214        assert!(!split.is_on_divider(50, 7, area)); // too far before
215        assert!(!split.is_on_divider(50, 13, area)); // too far after
216    }
217
218    #[test]
219    fn test_update_from_mouse_vertical() {
220        let mut split = ResizableSplit::new(50);
221        let area = Rect::new(0, 0, 100, 20);
222
223        // Not dragging - should not update
224        split.update_from_mouse(75, 10, area);
225        assert_eq!(split.split_percent, 50);
226
227        // Start dragging and update
228        split.start_drag();
229        split.update_from_mouse(75, 10, area);
230        assert_eq!(split.split_percent, 75);
231
232        // Test clamping to max
233        split.update_from_mouse(99, 10, area);
234        assert_eq!(split.split_percent, 90); // max_percent
235
236        // Test clamping to min
237        split.update_from_mouse(1, 10, area);
238        assert_eq!(split.split_percent, 10); // min_percent
239    }
240
241    #[test]
242    fn test_update_from_mouse_horizontal() {
243        let mut split = ResizableSplit::new_with_direction(50, SplitDirection::Horizontal);
244        let area = Rect::new(0, 0, 100, 20);
245
246        // Not dragging - should not update
247        split.update_from_mouse(50, 15, area);
248        assert_eq!(split.split_percent, 50);
249
250        // Start dragging and update
251        split.start_drag();
252        split.update_from_mouse(50, 15, area);
253        assert_eq!(split.split_percent, 75); // 15/20 = 75%
254
255        // Test clamping to max
256        split.update_from_mouse(50, 19, area);
257        assert_eq!(split.split_percent, 90); // max_percent
258
259        // Test clamping to min
260        split.update_from_mouse(50, 1, area);
261        assert_eq!(split.split_percent, 10); // min_percent
262    }
263}