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