ratatui_toolkit/widgets/split_layout/
mod.rs

1//! SplitLayout widget with integrated hover and mouse handling.
2//!
3//! This widget wraps the SplitLayout primitive and provides:
4//! - Mouse hover detection on dividers
5//! - Drag-to-resize functionality
6//! - Optional styling for dividers and panes
7//! - Rendering support for pane borders and overlays
8
9use crate::primitives::split_layout::{PaneLayout, SplitAxis, SplitDividerLayout, SplitLayout};
10use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
11use ratatui::{
12    layout::Rect,
13    style::{Color, Modifier, Style},
14    text::Line,
15    widgets::{Block, BorderType, Borders, StatefulWidget, Widget},
16    Frame,
17};
18
19/// State for SplitLayoutWidget interactions.
20///
21/// This can be stored in app state to preserve hover and drag
22/// information across frames.
23#[derive(Debug, Clone, Copy, Default)]
24pub struct SplitLayoutWidgetState {
25    /// Index of the divider currently being hovered
26    pub hovered_divider: Option<usize>,
27    /// Index of the divider currently being dragged
28    pub dragging_divider: Option<usize>,
29}
30
31/// A widget that wraps SplitLayout with mouse interaction support.
32///
33/// This widget manages hover state, drag state, and handles mouse events
34/// to resize dividers. It also provides styling options for visual feedback
35/// during interactions.
36///
37/// # Example
38///
39/// ```rust
40/// use ratatui_toolkit::widgets::split_layout::{SplitLayoutWidget, SplitLayoutWidgetState};
41/// use ratatui_toolkit::primitives::split_layout::SplitLayout;
42///
43/// let mut layout = SplitLayout::new(0);
44/// layout.split_pane_vertically(0);
45/// let mut state = SplitLayoutWidgetState::default();
46///
47/// let widget = SplitLayoutWidget::new(&mut layout)
48///     .with_divider_width(1)
49///     .with_hit_threshold(2);
50/// ```
51#[derive(Debug)]
52pub struct SplitLayoutWidget<'a> {
53    /// Reference to the underlying SplitLayout
54    layout: &'a mut SplitLayout,
55    /// State for hover and drag interactions
56    state: SplitLayoutWidgetState,
57    /// Width of divider lines in columns
58    divider_width: u16,
59    /// Hit detection threshold in columns/rows
60    hit_threshold: u16,
61    /// Style for hovered dividers
62    hover_style: Style,
63    /// Style for dragging dividers
64    drag_style: Style,
65    /// Style for normal dividers
66    divider_style: Style,
67    /// Optional block to render around the entire widget
68    block: Option<Block<'a>>,
69    /// Whether to show pane borders
70    show_pane_borders: bool,
71}
72
73impl<'a> SplitLayoutWidget<'a> {
74    /// Create a new SplitLayoutWidget wrapping a SplitLayout.
75    pub fn new(layout: &'a mut SplitLayout) -> Self {
76        Self {
77            layout,
78            state: SplitLayoutWidgetState::default(),
79            divider_width: 1,
80            hit_threshold: 2,
81            hover_style: Style::default()
82                .fg(Color::Cyan)
83                .add_modifier(Modifier::BOLD),
84            drag_style: Style::default()
85                .fg(Color::Yellow)
86                .add_modifier(Modifier::BOLD),
87            divider_style: Style::default(),
88            block: None,
89            show_pane_borders: true,
90        }
91    }
92
93    /// Set the widget state (for preserving hover/drag across frames).
94    pub fn with_state(mut self, state: SplitLayoutWidgetState) -> Self {
95        self.state = state;
96        self
97    }
98
99    /// Get the current widget state (for saving after frame).
100    pub fn state(&self) -> SplitLayoutWidgetState {
101        self.state
102    }
103
104    /// Get the recommended event poll duration based on interaction state.
105    ///
106    /// Returns 8ms (~120fps) when dragging for smooth resizing,
107    /// otherwise returns 50ms (normal rate).
108    ///
109    /// # Example
110    ///
111    /// ```rust,ignore
112    /// let poll_timeout = widget.optimal_poll_duration();
113    /// if event::poll(poll_timeout)? { ... }
114    /// ```
115    pub fn optimal_poll_duration(&self) -> std::time::Duration {
116        if self.state.dragging_divider.is_some() {
117            std::time::Duration::from_millis(8)
118        } else {
119            std::time::Duration::from_millis(50)
120        }
121    }
122
123    /// Get a reference to the underlying layout.
124    pub fn layout(&self) -> &SplitLayout {
125        self.layout
126    }
127
128    /// Get a mutable reference to the underlying layout.
129    pub fn layout_mut(&mut self) -> &mut SplitLayout {
130        self.layout
131    }
132
133    /// Set the width of divider lines.
134    pub fn with_divider_width(mut self, width: u16) -> Self {
135        self.divider_width = width.max(1);
136        self
137    }
138
139    /// Set the hit detection threshold for dividers.
140    pub fn with_hit_threshold(mut self, threshold: u16) -> Self {
141        self.hit_threshold = threshold.max(1);
142        self
143    }
144
145    /// Set the style for hovered dividers.
146    pub fn with_hover_style(mut self, style: Style) -> Self {
147        self.hover_style = style;
148        self
149    }
150
151    /// Set the style for dragging dividers.
152    pub fn with_drag_style(mut self, style: Style) -> Self {
153        self.drag_style = style;
154        self
155    }
156
157    /// Set the style for normal dividers.
158    pub fn with_divider_style(mut self, style: Style) -> Self {
159        self.divider_style = style;
160        self
161    }
162
163    /// Set the block to render around the widget.
164    pub fn with_block(mut self, block: Block<'a>) -> Self {
165        self.block = Some(block);
166        self
167    }
168
169    /// Enable or disable pane borders.
170    pub fn with_pane_borders(mut self, show: bool) -> Self {
171        self.show_pane_borders = show;
172        self
173    }
174
175    /// Check if currently hovering over any divider.
176    pub fn is_hovering(&self) -> bool {
177        self.state.hovered_divider.is_some()
178    }
179
180    /// Check if currently dragging any divider.
181    pub fn is_dragging(&self) -> bool {
182        self.state.dragging_divider.is_some()
183    }
184
185    /// Get the currently hovered divider index, if any.
186    pub fn hovered_divider(&self) -> Option<usize> {
187        self.state.hovered_divider
188    }
189
190    /// Get the currently dragging divider index, if any.
191    pub fn dragging_divider(&self) -> Option<usize> {
192        self.state.dragging_divider
193    }
194
195    /// Handle a mouse event.
196    ///
197    /// This method processes mouse events and updates the widget's state:
198    /// - Mouse move: Update hover state
199    /// - Mouse down: Start dragging if on a divider
200    /// - Mouse drag: Resize the divider
201    /// - Mouse up: Stop dragging
202    ///
203    /// # Arguments
204    ///
205    /// * `mouse` - The mouse event to handle
206    /// * `area` - The area the widget is rendered in
207    pub fn handle_mouse(&mut self, mouse: MouseEvent, area: Rect) {
208        match mouse.kind {
209            MouseEventKind::Moved => {
210                if self.state.dragging_divider.is_none() {
211                    self.state.hovered_divider =
212                        self.find_divider_at(mouse.column, mouse.row, area);
213                }
214            }
215            MouseEventKind::Down(MouseButton::Left) => {
216                if let Some(pane_id) = self.find_divider_at(mouse.column, mouse.row, area) {
217                    self.state.dragging_divider = Some(pane_id);
218                }
219            }
220            MouseEventKind::Drag(MouseButton::Left) => {
221                if let Some(pane_id) = self.state.dragging_divider {
222                    self.resize_divider(pane_id, mouse.column, mouse.row, area);
223                }
224            }
225            MouseEventKind::Up(MouseButton::Left) => {
226                self.state.dragging_divider = None;
227                self.state.hovered_divider = self.find_divider_at(mouse.column, mouse.row, area);
228            }
229            _ => {}
230        }
231    }
232
233    /// Find which split divider the mouse is over.
234    ///
235    /// Returns the split index if mouse is near a divider, or None otherwise.
236    fn find_divider_at(&self, column: u16, row: u16, area: Rect) -> Option<usize> {
237        let layouts = self.layout.layout_dividers(area);
238        let threshold = self.hit_threshold;
239        let mut best_match: Option<(usize, u16, u32)> = None;
240
241        for divider in &layouts {
242            let rect = divider.area();
243            match divider.axis() {
244                SplitAxis::Vertical => {
245                    let divider_x = rect.x.saturating_add(
246                        ((rect.width as u32 * divider.ratio() as u32) / 100) as u16,
247                    );
248                    let distance = divider_x.abs_diff(column);
249                    if distance <= threshold
250                        && column <= divider_x.saturating_add(threshold)
251                        && row >= rect.y
252                        && row <= rect.y.saturating_add(rect.height)
253                    {
254                        let area_size = rect.width as u32 * rect.height as u32;
255                        if best_match
256                            .map(|(_, best_distance, best_area)| {
257                                distance < best_distance
258                                    || (distance == best_distance && area_size < best_area)
259                            })
260                            .unwrap_or(true)
261                        {
262                            best_match = Some((divider.split_index(), distance, area_size));
263                        }
264                    }
265                }
266                SplitAxis::Horizontal => {
267                    let divider_y = rect.y.saturating_add(
268                        ((rect.height as u32 * divider.ratio() as u32) / 100) as u16,
269                    );
270                    let distance = divider_y.abs_diff(row);
271                    if distance <= threshold
272                        && row <= divider_y.saturating_add(threshold)
273                        && column >= rect.x
274                        && column <= rect.x.saturating_add(rect.width)
275                    {
276                        let area_size = rect.width as u32 * rect.height as u32;
277                        if best_match
278                            .map(|(_, best_distance, best_area)| {
279                                distance < best_distance
280                                    || (distance == best_distance && area_size < best_area)
281                            })
282                            .unwrap_or(true)
283                        {
284                            best_match = Some((divider.split_index(), distance, area_size));
285                        }
286                    }
287                }
288            }
289        }
290
291        best_match.map(|(split_index, _, _)| split_index)
292    }
293
294    /// Resize a divider based on mouse position.
295    ///
296    /// Calculates new split percentage based on mouse position and calls
297    /// resize_divider on the SplitLayout.
298    fn resize_divider(&mut self, split_index: usize, column: u16, row: u16, area: Rect) {
299        let layouts = self.layout.layout_dividers(area);
300        let divider_layout = layouts
301            .iter()
302            .find(|divider| divider.split_index() == split_index);
303
304        if let Some(divider) = divider_layout {
305            let rect = divider.area();
306            match divider.axis() {
307                SplitAxis::Vertical => {
308                    let content_width = rect.width;
309                    if content_width > 0 {
310                        let relative_x = column.saturating_sub(rect.x);
311                        let percent = ((relative_x as u32 * 100) / content_width as u32) as u16;
312                        let _ = self.layout.resize_split(split_index, percent);
313                    }
314                }
315                SplitAxis::Horizontal => {
316                    let content_height = rect.height;
317                    if content_height > 0 {
318                        let relative_y = row.saturating_sub(rect.y);
319                        let percent = ((relative_y as u32 * 100) / content_height as u32) as u16;
320                        let _ = self.layout.resize_split(split_index, percent);
321                    }
322                }
323            }
324        }
325    }
326
327    /// Get the layout rectangles for all panes.
328    ///
329    /// This allows callers to render pane contents after the widget
330    /// has drawn borders and overlays.
331    pub fn pane_layouts(&self, area: Rect) -> Vec<PaneLayout> {
332        self.layout.layout_panes(area)
333    }
334}
335
336impl<'a> Widget for SplitLayoutWidget<'a> {
337    fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) {
338        let mut render_area = area;
339
340        // Render outer block if provided
341        if let Some(ref block) = self.block {
342            let block = block.clone();
343            render_area = block.inner(area);
344            block.render(area, buf);
345        }
346
347        let pane_layouts = self.layout.layout_panes(render_area);
348        let divider_layouts = self.layout.layout_dividers(render_area);
349
350        // Render each pane with borders
351        for pane_layout in &pane_layouts {
352            let pane_id = pane_layout.pane_id();
353            let pane_area = pane_layout.area();
354
355            let border_style = self.divider_style;
356
357            // Render pane border
358            if self.show_pane_borders {
359                let pane_block = Block::default()
360                    .borders(Borders::ALL)
361                    .border_type(BorderType::Rounded)
362                    .border_style(border_style)
363                    .title(Line::from(format!(" {}", pane_id)));
364
365                pane_block.render(pane_area, buf);
366            }
367
368            // Render divider overlay when hovered/dragging
369        }
370
371        for divider in &divider_layouts {
372            let divider_style = if self.state.dragging_divider == Some(divider.split_index()) {
373                self.drag_style
374            } else if self.state.hovered_divider == Some(divider.split_index()) {
375                self.hover_style
376            } else {
377                continue;
378            };
379
380            self.render_divider_overlay(divider, divider_style, buf);
381        }
382    }
383}
384
385impl<'a> SplitLayoutWidget<'a> {
386    /// Render a visual overlay on the divider to indicate it's active.
387    fn render_divider_overlay(
388        &self,
389        divider: &SplitDividerLayout,
390        style: Style,
391        buf: &mut ratatui::buffer::Buffer,
392    ) {
393        let width = self.divider_width;
394        let rect = divider.area();
395
396        match divider.axis() {
397            SplitAxis::Vertical => {
398                let divider_x = rect
399                    .x
400                    .saturating_add(((rect.width as u32 * divider.ratio() as u32) / 100) as u16);
401                for y in rect.top()..rect.bottom() {
402                    for dx in 0..width {
403                        let x = divider_x.saturating_sub(dx);
404                        let cell = buf.get_mut(x, y);
405                        cell.set_style(style);
406                        cell.set_char('│');
407                    }
408                }
409            }
410            SplitAxis::Horizontal => {
411                let divider_y = rect
412                    .y
413                    .saturating_add(((rect.height as u32 * divider.ratio() as u32) / 100) as u16);
414                for x in rect.left()..rect.right() {
415                    for dy in 0..width {
416                        let y = divider_y.saturating_sub(dy);
417                        let cell = buf.get_mut(x, y);
418                        cell.set_style(style);
419                        cell.set_char('─');
420                    }
421                }
422            }
423        }
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
431
432    #[test]
433    fn test_widget_creation() {
434        let mut layout = SplitLayout::new(0);
435        let widget = SplitLayoutWidget::new(&mut layout);
436        assert!(!widget.is_hovering());
437        assert!(!widget.is_dragging());
438    }
439
440    #[test]
441    fn test_hover_on_vertical_divider() {
442        let mut layout = SplitLayout::new(0);
443        let _pane_2 = layout.split_pane_horizontally(0).unwrap();
444
445        let mut widget = SplitLayoutWidget::new(&mut layout);
446
447        let area = Rect::new(0, 0, 80, 24);
448
449        // Test hovering near right edge of pane 0 (vertical divider at ~33%)
450        let mouse = MouseEvent {
451            kind: MouseEventKind::Moved,
452            column: 26, // Near divider at 33% of 80 = ~26
453            row: 5,
454            modifiers: KeyModifiers::empty(),
455        };
456
457        widget.handle_mouse(mouse, area);
458
459        // Should detect hover on pane 0's divider
460        assert!(widget.is_hovering());
461        assert_eq!(widget.hovered_divider(), Some(0));
462    }
463
464    #[test]
465    fn test_drag_vertical_divider() {
466        let mut layout = SplitLayout::new(0);
467        let _pane_2 = layout.split_pane_horizontally(0).unwrap();
468
469        let mut widget = SplitLayoutWidget::new(&mut layout);
470
471        let area = Rect::new(0, 0, 80, 24);
472
473        // Start drag on divider
474        let mouse_down = MouseEvent {
475            kind: MouseEventKind::Down(MouseButton::Left),
476            column: 26, // Near divider at 33%
477            row: 5,
478            modifiers: KeyModifiers::empty(),
479        };
480        widget.handle_mouse(mouse_down, area);
481
482        // Should be dragging
483        assert!(widget.is_dragging());
484        assert_eq!(widget.dragging_divider(), Some(0));
485
486        // Drag to new position (50%)
487        let mouse_drag = MouseEvent {
488            kind: MouseEventKind::Drag(MouseButton::Left),
489            column: 40, // 50% of 80
490            row: 5,
491            modifiers: KeyModifiers::empty(),
492        };
493        widget.handle_mouse(mouse_drag, area);
494
495        // Layout should be resized
496        let layouts = layout.layout_panes(area);
497        let pane_0_area = layouts.iter().find(|p| p.pane_id() == 0).unwrap().area();
498
499        // Pane 0 should be approximately 50% width
500        assert!(pane_0_area.width >= 38 && pane_0_area.width <= 42);
501    }
502
503    #[test]
504    fn test_hover_on_horizontal_divider() {
505        let mut layout = SplitLayout::new(0);
506        let _pane_2 = layout.split_pane_vertically(0).unwrap();
507
508        let mut widget = SplitLayoutWidget::new(&mut layout);
509
510        let area = Rect::new(0, 0, 80, 24);
511
512        // Test hovering near bottom edge of pane 0 (horizontal divider at ~50%)
513        let mouse = MouseEvent {
514            kind: MouseEventKind::Moved,
515            column: 40,
516            row: 11, // Near 50% of 24 = ~12
517            modifiers: KeyModifiers::empty(),
518        };
519
520        widget.handle_mouse(mouse, area);
521
522        // Should detect hover on pane 0's divider
523        assert!(widget.is_hovering());
524        assert_eq!(widget.hovered_divider(), Some(0));
525    }
526
527    #[test]
528    fn test_no_hover_in_middle_of_pane() {
529        let mut layout = SplitLayout::new(0);
530        let _pane_2 = layout.split_pane_horizontally(0).unwrap();
531
532        let mut widget = SplitLayoutWidget::new(&mut layout);
533
534        let area = Rect::new(0, 0, 80, 24);
535
536        // Test mouse in middle of pane, not near edges
537        let mouse = MouseEvent {
538            kind: MouseEventKind::Moved,
539            column: 13, // Middle of left pane
540            row: 12,
541            modifiers: KeyModifiers::empty(),
542        };
543
544        widget.handle_mouse(mouse, area);
545
546        // Should NOT detect hover
547        assert!(!widget.is_hovering());
548        assert!(widget.hovered_divider().is_none());
549    }
550
551    #[test]
552    fn test_drag_stops_on_mouse_up() {
553        let mut layout = SplitLayout::new(0);
554        let _pane_2 = layout.split_pane_horizontally(0).unwrap();
555
556        let mut widget = SplitLayoutWidget::new(&mut layout);
557
558        let area = Rect::new(0, 0, 80, 24);
559
560        // Start drag
561        let mouse_down = MouseEvent {
562            kind: MouseEventKind::Down(MouseButton::Left),
563            column: 26,
564            row: 5,
565            modifiers: KeyModifiers::empty(),
566        };
567        widget.handle_mouse(mouse_down, area);
568        assert!(widget.is_dragging());
569
570        // Stop drag
571        let mouse_up = MouseEvent {
572            kind: MouseEventKind::Up(MouseButton::Left),
573            column: 40,
574            row: 5,
575            modifiers: KeyModifiers::empty(),
576        };
577        widget.handle_mouse(mouse_up, area);
578        assert!(!widget.is_dragging());
579    }
580
581    #[test]
582    fn test_divider_hit_threshold() {
583        let mut layout = SplitLayout::new(0);
584        let _pane_2 = layout.split_pane_horizontally(0).unwrap();
585
586        let widget = SplitLayoutWidget::new(&mut layout).with_hit_threshold(5);
587
588        assert_eq!(widget.hit_threshold, 5);
589    }
590
591    #[test]
592    fn test_with_styling_methods() {
593        let mut layout = SplitLayout::new(0);
594        let hover_style = Style::default().fg(Color::Red);
595        let drag_style = Style::default().fg(Color::Blue);
596        let divider_style = Style::default().fg(Color::Green);
597
598        let widget = SplitLayoutWidget::new(&mut layout)
599            .with_hover_style(hover_style)
600            .with_drag_style(drag_style)
601            .with_divider_style(divider_style);
602
603        // Styles should be set
604        assert_eq!(widget.hover_style.fg, Some(Color::Red));
605        assert_eq!(widget.drag_style.fg, Some(Color::Blue));
606        assert_eq!(widget.divider_style.fg, Some(Color::Green));
607    }
608
609    #[test]
610    fn test_complex_grid_layout_hover() {
611        // Test showcase layout: 5 panes in a grid
612        let mut layout = SplitLayout::new(0);
613        let pane_2 = layout.split_pane_horizontally(0).unwrap();
614        let _pane_3 = layout.split_pane_vertically(pane_2).unwrap();
615        let _pane_4 = layout.split_pane_vertically(1).unwrap();
616
617        let area = Rect::new(0, 0, 80, 24);
618        let (divider_x, divider_split_index) = {
619            let dividers = layout.layout_dividers(area);
620            let divider = dividers
621                .iter()
622                .find(|divider| divider.axis() == SplitAxis::Vertical)
623                .unwrap();
624            let divider_area = divider.area();
625            (
626                divider_area.x.saturating_add(
627                    ((divider_area.width as u32 * divider.ratio() as u32) / 100) as u16,
628                ),
629                divider.split_index(),
630            )
631        };
632
633        let mut widget = SplitLayoutWidget::new(&mut layout);
634
635        // Hover over pane 2's divider
636        let mouse = MouseEvent {
637            kind: MouseEventKind::Moved,
638            column: divider_x.saturating_sub(1),
639            row: 5,
640            modifiers: KeyModifiers::empty(),
641        };
642
643        widget.handle_mouse(mouse, area);
644
645        // Should detect hover on the divider
646        assert_eq!(widget.hovered_divider(), Some(divider_split_index));
647    }
648
649    #[test]
650    fn test_drag_resize_multiple_panes() {
651        // Test that dragging affects correct pane in multi-pane layout
652        let mut layout = SplitLayout::new(0);
653        let pane_2 = layout.split_pane_horizontally(0).unwrap();
654        let _pane_3 = layout.split_pane_vertically(pane_2).unwrap();
655
656        let area = Rect::new(0, 0, 80, 24);
657
658        // Get initial layout before creating widget
659        let initial_pane_0_width = {
660            let initial_layouts = layout.layout_panes(area);
661            initial_layouts
662                .iter()
663                .find(|p| p.pane_id() == 0)
664                .unwrap()
665                .area()
666                .width
667        };
668
669        let mut widget = SplitLayoutWidget::new(&mut layout);
670
671        // Start drag on pane 0's divider
672        let mouse_down = MouseEvent {
673            kind: MouseEventKind::Down(MouseButton::Left),
674            column: 26,
675            row: 5,
676            modifiers: KeyModifiers::empty(),
677        };
678        widget.handle_mouse(mouse_down, area);
679
680        // Drag to 60%
681        let mouse_drag = MouseEvent {
682            kind: MouseEventKind::Drag(MouseButton::Left),
683            column: 48, // 60% of 80
684            row: 5,
685            modifiers: KeyModifiers::empty(),
686        };
687        widget.handle_mouse(mouse_drag, area);
688
689        // Pane 0 should be wider now
690        let final_layouts = layout.layout_panes(area);
691        let final_pane_0_width = final_layouts
692            .iter()
693            .find(|p| p.pane_id() == 0)
694            .unwrap()
695            .area()
696            .width;
697
698        assert!(final_pane_0_width > initial_pane_0_width);
699    }
700
701    #[test]
702    fn test_no_drag_on_single_pane() {
703        // Single pane has no dividers, so dragging should not work
704        let mut layout = SplitLayout::new(0);
705
706        let mut widget = SplitLayoutWidget::new(&mut layout);
707
708        let area = Rect::new(0, 0, 80, 24);
709
710        let mouse_down = MouseEvent {
711            kind: MouseEventKind::Down(MouseButton::Left),
712            column: 40,
713            row: 10,
714            modifiers: KeyModifiers::empty(),
715        };
716        widget.handle_mouse(mouse_down, area);
717
718        // Should NOT be dragging since there's no divider
719        assert!(!widget.is_dragging());
720        assert!(widget.dragging_divider().is_none());
721    }
722}