ratatui_toolkit/resizable_split/
mod.rs

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