Skip to main content

ratatui_interact/components/
split_pane.rs

1//! Split pane layout component
2//!
3//! A resizable two-pane layout with drag-to-resize divider support.
4//! Supports both horizontal (left/right) and vertical (top/bottom) orientations.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use ratatui_interact::components::{SplitPane, SplitPaneState, SplitPaneStyle, Orientation};
10//! use ratatui::prelude::*;
11//!
12//! let mut state = SplitPaneState::new(50); // 50% split
13//! let split_pane = SplitPane::new()
14//!     .orientation(Orientation::Horizontal)
15//!     .min_size(10)
16//!     .divider_char("│");
17//!
18//! // In render:
19//! split_pane.render_with_content(
20//!     area,
21//!     buf,
22//!     &mut state,
23//!     |first_area, buf| { /* render first pane */ },
24//!     |second_area, buf| { /* render second pane */ },
25//!     &mut registry,
26//! );
27//! ```
28
29use ratatui::{
30    buffer::Buffer,
31    layout::Rect,
32    style::{Color, Modifier, Style},
33};
34
35use crate::traits::{ClickRegion, ClickRegionRegistry, FocusId, Focusable};
36
37/// Actions that can be triggered by mouse interaction with the split pane
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum SplitPaneAction {
40    /// Click on the first pane (left or top)
41    FirstPaneClick,
42    /// Click on the second pane (right or bottom)
43    SecondPaneClick,
44    /// Click/drag on the divider
45    DividerDrag,
46}
47
48/// Orientation of the split pane
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum Orientation {
51    /// Horizontal split (left | right)
52    #[default]
53    Horizontal,
54    /// Vertical split (top / bottom)
55    Vertical,
56}
57
58/// State for the SplitPane component
59#[derive(Debug, Clone)]
60pub struct SplitPaneState {
61    /// Percentage of the first pane (0-100)
62    pub split_percent: u16,
63    /// Whether the component is focused
64    pub focused: bool,
65    /// Whether the divider itself is focused (for keyboard resize)
66    pub divider_focused: bool,
67    /// Whether currently dragging the divider
68    pub is_dragging: bool,
69    /// Starting position when drag began
70    drag_start_pos: u16,
71    /// Split percentage when drag began
72    drag_start_percent: u16,
73    /// Total size of the split area (cached from last render)
74    total_size: u16,
75    /// Focus ID for focus management
76    pub focus_id: FocusId,
77}
78
79impl SplitPaneState {
80    /// Create a new SplitPaneState with the given initial split percentage
81    pub fn new(split_percent: u16) -> Self {
82        Self {
83            split_percent: split_percent.clamp(0, 100),
84            focused: false,
85            divider_focused: false,
86            is_dragging: false,
87            drag_start_pos: 0,
88            drag_start_percent: 0,
89            total_size: 0,
90            focus_id: FocusId::default(),
91        }
92    }
93
94    /// Create a new SplitPaneState with 50/50 split
95    pub fn half() -> Self {
96        Self::new(50)
97    }
98
99    /// Start dragging the divider
100    pub fn start_drag(&mut self, pos: u16) {
101        self.is_dragging = true;
102        self.drag_start_pos = pos;
103        self.drag_start_percent = self.split_percent;
104    }
105
106    /// Update the split position during drag
107    pub fn update_drag(&mut self, pos: u16, min_percent: u16, max_percent: u16) {
108        if !self.is_dragging || self.total_size == 0 {
109            return;
110        }
111
112        let delta = (pos as i32) - (self.drag_start_pos as i32);
113        let percent_delta = (delta * 100) / (self.total_size as i32);
114        let new_percent =
115            ((self.drag_start_percent as i32) + percent_delta).clamp(min_percent as i32, max_percent as i32) as u16;
116
117        self.split_percent = new_percent;
118    }
119
120    /// End dragging the divider
121    pub fn end_drag(&mut self) {
122        self.is_dragging = false;
123    }
124
125    /// Adjust split percentage by delta (for keyboard control)
126    pub fn adjust_split(&mut self, delta: i16, min_percent: u16, max_percent: u16) {
127        let new_percent = ((self.split_percent as i16) + delta).clamp(min_percent as i16, max_percent as i16) as u16;
128        self.split_percent = new_percent;
129    }
130
131    /// Set the split percentage directly
132    pub fn set_split_percent(&mut self, percent: u16) {
133        self.split_percent = percent.clamp(0, 100);
134    }
135
136    /// Get the current split percentage
137    pub fn split_percent(&self) -> u16 {
138        self.split_percent
139    }
140
141    /// Check if currently dragging
142    pub fn is_dragging(&self) -> bool {
143        self.is_dragging
144    }
145
146    /// Update total size (called during render or manually)
147    pub fn set_total_size(&mut self, size: u16) {
148        self.total_size = size;
149    }
150}
151
152impl Default for SplitPaneState {
153    fn default() -> Self {
154        Self::half()
155    }
156}
157
158impl Focusable for SplitPaneState {
159    fn focus_id(&self) -> FocusId {
160        self.focus_id
161    }
162
163    fn is_focused(&self) -> bool {
164        self.focused
165    }
166
167    fn set_focused(&mut self, focused: bool) {
168        self.focused = focused;
169        if !focused {
170            self.divider_focused = false;
171        }
172    }
173
174    fn focused_style(&self) -> Style {
175        Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
176    }
177
178    fn unfocused_style(&self) -> Style {
179        Style::default().fg(Color::White)
180    }
181}
182
183/// Style configuration for the SplitPane component
184#[derive(Debug, Clone)]
185pub struct SplitPaneStyle {
186    /// Style for the divider when not focused
187    pub divider_style: Style,
188    /// Style for the divider when focused
189    pub divider_focused_style: Style,
190    /// Style for the divider when being dragged
191    pub divider_dragging_style: Style,
192    /// Style for the divider when hovered
193    pub divider_hover_style: Style,
194    /// Character used for the divider (vertical orientation: ─, horizontal: │)
195    pub divider_char: Option<&'static str>,
196    /// Width/height of the divider in cells (default: 1)
197    pub divider_size: u16,
198    /// Show a grab indicator on the divider
199    pub show_grab_indicator: bool,
200}
201
202impl Default for SplitPaneStyle {
203    fn default() -> Self {
204        Self {
205            divider_style: Style::default().bg(Color::DarkGray),
206            divider_focused_style: Style::default().bg(Color::Yellow).fg(Color::Black),
207            divider_dragging_style: Style::default().bg(Color::Cyan).fg(Color::Black),
208            divider_hover_style: Style::default().bg(Color::Gray),
209            divider_char: None, // Auto-select based on orientation
210            divider_size: 1,
211            show_grab_indicator: true,
212        }
213    }
214}
215
216impl SplitPaneStyle {
217    /// Create a minimal style with thin divider
218    pub fn minimal() -> Self {
219        Self {
220            divider_style: Style::default().fg(Color::DarkGray),
221            divider_focused_style: Style::default().fg(Color::Yellow),
222            divider_dragging_style: Style::default().fg(Color::Cyan),
223            divider_hover_style: Style::default().fg(Color::Gray),
224            divider_char: None,
225            divider_size: 1,
226            show_grab_indicator: false,
227        }
228    }
229
230    /// Create a style with prominent divider
231    pub fn prominent() -> Self {
232        Self {
233            divider_style: Style::default().bg(Color::Blue).fg(Color::White),
234            divider_focused_style: Style::default().bg(Color::Yellow).fg(Color::Black),
235            divider_dragging_style: Style::default().bg(Color::Green).fg(Color::Black),
236            divider_hover_style: Style::default().bg(Color::LightBlue).fg(Color::Black),
237            divider_char: None,
238            divider_size: 1,
239            show_grab_indicator: true,
240        }
241    }
242
243    /// Set the divider character
244    pub fn divider_char(mut self, char: &'static str) -> Self {
245        self.divider_char = Some(char);
246        self
247    }
248
249    /// Set the divider size
250    pub fn divider_size(mut self, size: u16) -> Self {
251        self.divider_size = size.max(1);
252        self
253    }
254}
255
256/// A resizable split pane component
257pub struct SplitPane {
258    orientation: Orientation,
259    style: SplitPaneStyle,
260    min_size: u16,
261    min_percent: u16,
262    max_percent: u16,
263}
264
265impl SplitPane {
266    /// Create a new SplitPane
267    pub fn new() -> Self {
268        Self {
269            orientation: Orientation::default(),
270            style: SplitPaneStyle::default(),
271            min_size: 5,
272            min_percent: 10,
273            max_percent: 90,
274        }
275    }
276
277    /// Set the orientation (horizontal or vertical)
278    pub fn orientation(mut self, orientation: Orientation) -> Self {
279        self.orientation = orientation;
280        self
281    }
282
283    /// Set the style
284    pub fn style(mut self, style: SplitPaneStyle) -> Self {
285        self.style = style;
286        self
287    }
288
289    /// Set the minimum size for each pane in cells
290    pub fn min_size(mut self, min_size: u16) -> Self {
291        self.min_size = min_size;
292        self
293    }
294
295    /// Set the minimum percentage for the first pane
296    pub fn min_percent(mut self, min_percent: u16) -> Self {
297        self.min_percent = min_percent.clamp(0, 100);
298        self
299    }
300
301    /// Set the maximum percentage for the first pane
302    pub fn max_percent(mut self, max_percent: u16) -> Self {
303        self.max_percent = max_percent.clamp(0, 100);
304        self
305    }
306
307    /// Set the divider character
308    pub fn divider_char(mut self, char: &'static str) -> Self {
309        self.style.divider_char = Some(char);
310        self
311    }
312
313    /// Calculate the layout areas for the split pane
314    ///
315    /// Takes a split_percent (0-100) to determine the first pane size.
316    pub fn calculate_areas(&self, area: Rect, split_percent: u16) -> (Rect, Rect, Rect) {
317        let total_size = match self.orientation {
318            Orientation::Horizontal => area.width,
319            Orientation::Vertical => area.height,
320        };
321
322        let divider_size = self.style.divider_size;
323        let available_size = total_size.saturating_sub(divider_size);
324
325        // Calculate first pane size based on percentage
326        let first_size = ((available_size as u32) * (split_percent as u32) / 100) as u16;
327        let first_size = first_size.clamp(self.min_size, available_size.saturating_sub(self.min_size));
328
329        // Second pane gets the rest
330        let second_size = available_size.saturating_sub(first_size);
331
332        match self.orientation {
333            Orientation::Horizontal => {
334                let first_area = Rect::new(area.x, area.y, first_size, area.height);
335                let divider_area = Rect::new(area.x + first_size, area.y, divider_size, area.height);
336                let second_area = Rect::new(
337                    area.x + first_size + divider_size,
338                    area.y,
339                    second_size,
340                    area.height,
341                );
342                (first_area, divider_area, second_area)
343            }
344            Orientation::Vertical => {
345                let first_area = Rect::new(area.x, area.y, area.width, first_size);
346                let divider_area = Rect::new(area.x, area.y + first_size, area.width, divider_size);
347                let second_area = Rect::new(
348                    area.x,
349                    area.y + first_size + divider_size,
350                    area.width,
351                    second_size,
352                );
353                (first_area, divider_area, second_area)
354            }
355        }
356    }
357
358    /// Render the divider
359    fn render_divider(&self, state: &SplitPaneState, divider_area: Rect, buf: &mut Buffer) {
360        let divider_style = if state.is_dragging {
361            self.style.divider_dragging_style
362        } else if state.divider_focused {
363            self.style.divider_focused_style
364        } else {
365            self.style.divider_style
366        };
367
368        let divider_char = self.style.divider_char.unwrap_or(match self.orientation {
369            Orientation::Horizontal => "│",
370            Orientation::Vertical => "─",
371        });
372
373        match self.orientation {
374            Orientation::Horizontal => {
375                for y in divider_area.y..divider_area.y + divider_area.height {
376                    for x in divider_area.x..divider_area.x + divider_area.width {
377                        // Show grab indicator in the middle
378                        let char_to_draw = if self.style.show_grab_indicator {
379                            let mid_y = divider_area.y + divider_area.height / 2;
380                            if y == mid_y {
381                                "┃"
382                            } else if y == mid_y.saturating_sub(1) || y == mid_y + 1 {
383                                "║"
384                            } else {
385                                divider_char
386                            }
387                        } else {
388                            divider_char
389                        };
390                        buf.set_string(x, y, char_to_draw, divider_style);
391                    }
392                }
393            }
394            Orientation::Vertical => {
395                for y in divider_area.y..divider_area.y + divider_area.height {
396                    for x in divider_area.x..divider_area.x + divider_area.width {
397                        // Show grab indicator in the middle
398                        let char_to_draw = if self.style.show_grab_indicator {
399                            let mid_x = divider_area.x + divider_area.width / 2;
400                            if x == mid_x {
401                                "━"
402                            } else if x == mid_x.saturating_sub(1) || x == mid_x + 1 {
403                                "═"
404                            } else {
405                                divider_char
406                            }
407                        } else {
408                            divider_char
409                        };
410                        buf.set_string(x, y, char_to_draw, divider_style);
411                    }
412                }
413            }
414        }
415    }
416
417    /// Render the split pane with custom content renderers and click region registry
418    pub fn render_with_content<F1, F2>(
419        &self,
420        area: Rect,
421        buf: &mut Buffer,
422        state: &mut SplitPaneState,
423        first_pane_renderer: F1,
424        second_pane_renderer: F2,
425        registry: &mut ClickRegionRegistry<SplitPaneAction>,
426    ) where
427        F1: FnOnce(Rect, &mut Buffer),
428        F2: FnOnce(Rect, &mut Buffer),
429    {
430        // Update total size in state for drag calculations
431        let total_size = match self.orientation {
432            Orientation::Horizontal => area.width,
433            Orientation::Vertical => area.height,
434        };
435        state.set_total_size(total_size);
436
437        let (first_area, divider_area, second_area) = self.calculate_areas(area, state.split_percent);
438
439        // Register click regions
440        registry.register(first_area, SplitPaneAction::FirstPaneClick);
441        registry.register(divider_area, SplitPaneAction::DividerDrag);
442        registry.register(second_area, SplitPaneAction::SecondPaneClick);
443
444        // Render content
445        first_pane_renderer(first_area, buf);
446        second_pane_renderer(second_area, buf);
447
448        // Render divider on top
449        self.render_divider(state, divider_area, buf);
450    }
451
452    /// Render just the split pane divider (for cases where content is rendered separately)
453    pub fn render_divider_only(
454        &self,
455        area: Rect,
456        buf: &mut Buffer,
457        state: &mut SplitPaneState,
458    ) -> (Rect, Rect, Rect) {
459        // Update total size in state
460        let total_size = match self.orientation {
461            Orientation::Horizontal => area.width,
462            Orientation::Vertical => area.height,
463        };
464        state.set_total_size(total_size);
465
466        let (first_area, divider_area, second_area) = self.calculate_areas(area, state.split_percent);
467        self.render_divider(state, divider_area, buf);
468        (first_area, divider_area, second_area)
469    }
470
471    /// Get a simple click region for the divider
472    pub fn divider_click_region(&self, area: Rect, split_percent: u16) -> ClickRegion<SplitPaneAction> {
473        let (_, divider_area, _) = self.calculate_areas(area, split_percent);
474        ClickRegion::new(divider_area, SplitPaneAction::DividerDrag)
475    }
476
477    /// Get the orientation
478    pub fn get_orientation(&self) -> Orientation {
479        self.orientation
480    }
481
482    /// Get min_percent
483    pub fn get_min_percent(&self) -> u16 {
484        self.min_percent
485    }
486
487    /// Get max_percent
488    pub fn get_max_percent(&self) -> u16 {
489        self.max_percent
490    }
491}
492
493impl Default for SplitPane {
494    fn default() -> Self {
495        Self::new()
496    }
497}
498
499/// Handle keyboard input for split pane
500///
501/// Returns true if the key was handled
502pub fn handle_split_pane_key(
503    state: &mut SplitPaneState,
504    key: &crossterm::event::KeyEvent,
505    orientation: Orientation,
506    step: i16,
507    min_percent: u16,
508    max_percent: u16,
509) -> bool {
510    use crossterm::event::KeyCode;
511
512    if !state.divider_focused {
513        return false;
514    }
515
516    match key.code {
517        KeyCode::Left if orientation == Orientation::Horizontal => {
518            state.adjust_split(-step, min_percent, max_percent);
519            true
520        }
521        KeyCode::Right if orientation == Orientation::Horizontal => {
522            state.adjust_split(step, min_percent, max_percent);
523            true
524        }
525        KeyCode::Up if orientation == Orientation::Vertical => {
526            state.adjust_split(-step, min_percent, max_percent);
527            true
528        }
529        KeyCode::Down if orientation == Orientation::Vertical => {
530            state.adjust_split(step, min_percent, max_percent);
531            true
532        }
533        KeyCode::Home => {
534            state.set_split_percent(min_percent);
535            true
536        }
537        KeyCode::End => {
538            state.set_split_percent(max_percent);
539            true
540        }
541        _ => false,
542    }
543}
544
545/// Handle mouse input for split pane
546///
547/// Returns the action triggered, if any
548pub fn handle_split_pane_mouse(
549    state: &mut SplitPaneState,
550    mouse: &crossterm::event::MouseEvent,
551    orientation: Orientation,
552    registry: &ClickRegionRegistry<SplitPaneAction>,
553    min_percent: u16,
554    max_percent: u16,
555) -> Option<SplitPaneAction> {
556    use crossterm::event::{MouseButton, MouseEventKind};
557
558    let pos = match orientation {
559        Orientation::Horizontal => mouse.column,
560        Orientation::Vertical => mouse.row,
561    };
562
563    match mouse.kind {
564        MouseEventKind::Down(MouseButton::Left) => {
565            if let Some(&action) = registry.handle_click(mouse.column, mouse.row) {
566                if action == SplitPaneAction::DividerDrag {
567                    state.start_drag(pos);
568                }
569                return Some(action);
570            }
571        }
572        MouseEventKind::Up(MouseButton::Left) => {
573            if state.is_dragging {
574                state.end_drag();
575            }
576        }
577        MouseEventKind::Drag(MouseButton::Left) => {
578            if state.is_dragging {
579                state.update_drag(pos, min_percent, max_percent);
580            }
581        }
582        _ => {}
583    }
584
585    None
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    #[test]
593    fn test_state_creation() {
594        let state = SplitPaneState::new(30);
595        assert_eq!(state.split_percent, 30);
596        assert!(!state.is_dragging);
597        assert!(!state.focused);
598    }
599
600    #[test]
601    fn test_state_half() {
602        let state = SplitPaneState::half();
603        assert_eq!(state.split_percent, 50);
604    }
605
606    #[test]
607    fn test_split_percent_clamping() {
608        let state = SplitPaneState::new(150);
609        assert_eq!(state.split_percent, 100);
610
611        let mut state2 = SplitPaneState::new(50);
612        state2.set_split_percent(200);
613        assert_eq!(state2.split_percent, 100);
614    }
615
616    #[test]
617    fn test_drag_operations() {
618        let mut state = SplitPaneState::new(50);
619        state.set_total_size(100);
620
621        state.start_drag(50);
622        assert!(state.is_dragging);
623
624        state.update_drag(60, 10, 90);
625        assert_eq!(state.split_percent, 60);
626
627        state.end_drag();
628        assert!(!state.is_dragging);
629    }
630
631    #[test]
632    fn test_drag_respects_limits() {
633        let mut state = SplitPaneState::new(50);
634        state.set_total_size(100);
635
636        state.start_drag(50);
637        state.update_drag(5, 10, 90);
638        assert!(state.split_percent >= 10);
639
640        state.update_drag(95, 10, 90);
641        assert!(state.split_percent <= 90);
642    }
643
644    #[test]
645    fn test_adjust_split() {
646        let mut state = SplitPaneState::new(50);
647
648        state.adjust_split(10, 10, 90);
649        assert_eq!(state.split_percent, 60);
650
651        state.adjust_split(-20, 10, 90);
652        assert_eq!(state.split_percent, 40);
653    }
654
655    #[test]
656    fn test_calculate_areas_horizontal() {
657        let split_pane = SplitPane::new().orientation(Orientation::Horizontal);
658
659        let area = Rect::new(0, 0, 100, 50);
660        let (first, divider, second) = split_pane.calculate_areas(area, 50);
661
662        assert_eq!(first.width + divider.width + second.width, area.width);
663        assert_eq!(divider.width, 1);
664    }
665
666    #[test]
667    fn test_calculate_areas_vertical() {
668        let split_pane = SplitPane::new().orientation(Orientation::Vertical);
669
670        let area = Rect::new(0, 0, 100, 50);
671        let (first, divider, second) = split_pane.calculate_areas(area, 50);
672
673        assert_eq!(first.height + divider.height + second.height, area.height);
674        assert_eq!(divider.height, 1);
675    }
676
677    #[test]
678    fn test_focusable_trait() {
679        let mut state = SplitPaneState::new(50);
680        assert!(!state.is_focused());
681
682        state.set_focused(true);
683        assert!(state.is_focused());
684
685        state.divider_focused = true;
686        state.set_focused(false);
687        assert!(!state.divider_focused);
688    }
689}