Skip to main content

ratatui_toolkit/master_layout/
pane.rs

1//! Pane component and PaneContent trait
2
3use super::PaneId;
4use crossterm::event::{KeyEvent, MouseEvent};
5use ratatui::{
6    buffer::Buffer,
7    layout::Rect,
8    style::{Color, Modifier, Style},
9    text::{Line, Span},
10    widgets::{Block, BorderType, Borders, Widget},
11};
12
13/// Trait that pane content must implement
14pub trait PaneContent {
15    /// Handle keyboard input when pane is focused
16    /// Returns true if the event was consumed, false otherwise
17    fn handle_key(&mut self, key: KeyEvent) -> bool;
18
19    /// Handle mouse input when pane is focused
20    /// Returns true if the event was consumed, false otherwise
21    fn handle_mouse(&mut self, mouse: MouseEvent) -> bool;
22
23    /// Get the pane's title
24    fn title(&self) -> String;
25
26    /// Render the pane content
27    /// This is called instead of Widget::render to allow mutable access
28    /// Receives Frame so panes can use either Frame or Buffer rendering
29    fn render_content(&mut self, area: Rect, frame: &mut ratatui::Frame);
30
31    /// Check if this pane can receive focus
32    /// Returns false for display-only panes (status displays, etc.)
33    fn is_focusable(&self) -> bool {
34        true
35    }
36
37    /// Start text selection at the given coordinates (relative to content area)
38    fn start_selection(&mut self, _x: u16, _y: u16) {
39        // Default: no-op (pane doesn't support selection)
40    }
41
42    /// Update text selection to the given coordinates (relative to content area)
43    fn update_selection(&mut self, _x: u16, _y: u16) {
44        // Default: no-op
45    }
46
47    /// End text selection
48    fn end_selection(&mut self) {
49        // Default: no-op
50    }
51
52    /// Get the currently selected text, if any
53    fn get_selected_text(&self) -> Option<String> {
54        None // Default: no selection
55    }
56
57    /// Clear the current selection
58    fn clear_selection(&mut self) {
59        // Default: no-op
60    }
61
62    /// Check if there is an active selection
63    fn has_selection(&self) -> bool {
64        false // Default: no selection
65    }
66
67    /// Notify the pane about focus change
68    /// Called by the master layout when focus is gained or lost
69    fn set_focused(&mut self, _focused: bool) {
70        // Default: no-op
71    }
72
73    /// Get custom border style for this pane
74    /// Default implementation uses standard focus/selection colors
75    fn border_style(&self, is_selected: bool, is_focused: bool) -> Style {
76        if is_focused {
77            // Focused: Cyan bold
78            Style::default()
79                .fg(Color::Cyan)
80                .add_modifier(Modifier::BOLD)
81        } else if is_selected {
82            // Selected: Yellow bold
83            Style::default()
84                .fg(Color::Yellow)
85                .add_modifier(Modifier::BOLD)
86        } else {
87            // Inactive: Dark gray
88            Style::default().fg(Color::DarkGray)
89        }
90    }
91
92    /// Get title with focus/selection indicator
93    fn title_with_indicator(&self, is_selected: bool, is_focused: bool) -> String {
94        let title = self.title();
95        if is_focused {
96            format!("█ {} (Focused)", title)
97        } else if is_selected {
98            format!("● {} (Selected)", title)
99        } else {
100            title
101        }
102    }
103}
104
105/// A pane within a tab
106pub struct Pane {
107    id: PaneId,
108    content: Box<dyn PaneContent>,
109    area: Rect,
110
111    // Visual enhancements
112    /// Optional icon to display before the title
113    icon: Option<String>,
114    /// Padding around the content (top, right, bottom, left)
115    padding: (u16, u16, u16, u16),
116    /// Optional text footer displayed in the border
117    text_footer: Option<String>,
118    /// Border type (Rounded, Plain, Double, etc.)
119    border_type: BorderType,
120}
121
122impl Pane {
123    /// Create a new pane with given ID and content
124    pub fn new(id: PaneId, content: Box<dyn PaneContent>) -> Self {
125        Self {
126            id,
127            content,
128            area: Rect::default(),
129            icon: None,
130            padding: (0, 0, 0, 0),
131            text_footer: None,
132            border_type: BorderType::Rounded,
133        }
134    }
135
136    /// Set the icon for the title
137    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
138        self.icon = Some(icon.into());
139        self
140    }
141
142    /// Set the padding (top, right, bottom, left)
143    pub fn with_padding(mut self, top: u16, right: u16, bottom: u16, left: u16) -> Self {
144        self.padding = (top, right, bottom, left);
145        self
146    }
147
148    /// Set uniform padding on all sides
149    pub fn with_uniform_padding(mut self, padding: u16) -> Self {
150        self.padding = (padding, padding, padding, padding);
151        self
152    }
153
154    /// Set a text footer (displayed in the border)
155    pub fn with_footer(mut self, footer: impl Into<String>) -> Self {
156        self.text_footer = Some(footer.into());
157        self
158    }
159
160    /// Set the border type
161    pub fn with_border_type(mut self, border_type: BorderType) -> Self {
162        self.border_type = border_type;
163        self
164    }
165
166    /// Get the pane's ID
167    pub fn id(&self) -> PaneId {
168        self.id
169    }
170
171    /// Get the pane's current area
172    pub fn area(&self) -> Rect {
173        self.area
174    }
175
176    /// Set the pane's area (called during layout)
177    pub fn set_area(&mut self, area: Rect) {
178        self.area = area;
179    }
180
181    /// Get the pane's title
182    pub fn title(&self) -> String {
183        self.content.title()
184    }
185
186    /// Check if pane is focusable
187    pub fn is_focusable(&self) -> bool {
188        self.content.is_focusable()
189    }
190
191    /// Handle keyboard input
192    pub fn handle_key(&mut self, key: KeyEvent) -> bool {
193        self.content.handle_key(key)
194    }
195
196    /// Handle mouse input (coordinates already translated to pane-local)
197    pub fn handle_mouse(&mut self, mouse: MouseEvent) -> bool {
198        self.content.handle_mouse(mouse)
199    }
200
201    /// Start text selection at the given coordinates
202    pub fn start_selection(&mut self, x: u16, y: u16) {
203        self.content.start_selection(x, y);
204    }
205
206    /// Update text selection to the given coordinates
207    pub fn update_selection(&mut self, x: u16, y: u16) {
208        self.content.update_selection(x, y);
209    }
210
211    /// End text selection
212    pub fn end_selection(&mut self) {
213        self.content.end_selection();
214    }
215
216    /// Get the currently selected text
217    pub fn get_selected_text(&self) -> Option<String> {
218        self.content.get_selected_text()
219    }
220
221    /// Clear the current selection
222    pub fn clear_selection(&mut self) {
223        self.content.clear_selection();
224    }
225
226    /// Check if there is an active selection
227    pub fn has_selection(&self) -> bool {
228        self.content.has_selection()
229    }
230
231    /// Notify pane content about focus state change
232    pub fn set_focused(&mut self, focused: bool) {
233        self.content.set_focused(focused);
234    }
235
236    /// Translate global mouse coordinates to pane-local coordinates
237    pub fn translate_mouse(&self, mouse: MouseEvent) -> MouseEvent {
238        MouseEvent {
239            kind: mouse.kind,
240            column: mouse.column.saturating_sub(self.area.x),
241            row: mouse.row.saturating_sub(self.area.y),
242            modifiers: mouse.modifiers,
243        }
244    }
245
246    /// Check if point is within pane bounds
247    pub fn contains_point(&self, x: u16, y: u16) -> bool {
248        x >= self.area.x
249            && x < self.area.x + self.area.width
250            && y >= self.area.y
251            && y < self.area.y + self.area.height
252    }
253
254    /// Build the title line with optional icon
255    fn build_title(&self, is_selected: bool, is_focused: bool) -> Line<'static> {
256        let base_title = self.content.title_with_indicator(is_selected, is_focused);
257
258        if let Some(ref icon) = self.icon {
259            let icon_str = icon.clone();
260            let title_str = base_title;
261            Line::from(vec![
262                Span::raw(" "),
263                Span::raw(icon_str),
264                Span::raw(" "),
265                Span::raw(title_str),
266                Span::raw(" "),
267            ])
268        } else {
269            Line::from(format!(" {} ", base_title))
270        }
271    }
272
273    /// Get the padded area (after applying padding)
274    fn get_padded_area(&self, area: Rect) -> Rect {
275        Rect {
276            x: area.x + self.padding.3,                                        // left
277            y: area.y + self.padding.0,                                        // top
278            width: area.width.saturating_sub(self.padding.1 + self.padding.3), // right + left
279            height: area.height.saturating_sub(self.padding.0 + self.padding.2), // top + bottom
280        }
281    }
282
283    /// Render the pane with border
284    pub fn render(&mut self, frame: &mut ratatui::Frame, is_selected: bool, is_focused: bool) {
285        let area = self.area;
286
287        // Get border style from content
288        let border_style = self.content.border_style(is_selected, is_focused);
289        let title = self.build_title(is_selected, is_focused);
290
291        // Create block with border
292        let mut block = Block::default()
293            .borders(Borders::ALL)
294            .border_type(self.border_type)
295            .border_style(border_style)
296            .title(title);
297
298        // Add footer if present
299        if let Some(ref footer) = self.text_footer {
300            block = block.title_bottom(Line::from(format!(" {} ", footer)));
301        }
302
303        // Apply padding to the area
304        let padded_area = self.get_padded_area(area);
305
306        // Calculate inner area (inside border)
307        let inner_area = block.inner(padded_area);
308
309        // Render border
310        frame.render_widget(block, padded_area);
311
312        // Render content inside border
313        self.content.render_content(inner_area, frame);
314    }
315}
316
317// Helper for rendering - the content itself
318impl Pane {
319    /// Get a reference to render the content widget
320    /// This is used during the render phase
321    pub fn render_content(
322        &mut self,
323        area: Rect,
324        buf: &mut Buffer,
325        is_selected: bool,
326        is_focused: bool,
327    ) {
328        // Calculate inner area (inside border)
329        let border_style = self.content.border_style(is_selected, is_focused);
330        let title = self.build_title(is_selected, is_focused);
331
332        let mut block = Block::default()
333            .borders(Borders::ALL)
334            .border_type(self.border_type)
335            .border_style(border_style)
336            .title(title);
337
338        // Add footer if present
339        if let Some(ref footer) = self.text_footer {
340            block = block.title_bottom(Line::from(format!(" {} ", footer)));
341        }
342
343        // Apply padding to the area
344        let padded_area = self.get_padded_area(area);
345
346        // Render border
347        block.render(padded_area, buf);
348
349        // Render content (need to work around Widget consuming self)
350        // For now, we'll document that PaneContent needs special handling
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
358
359    // Mock pane content for testing
360    struct MockPaneContent {
361        title: String,
362        focusable: bool,
363        focused: bool,
364        last_key: Option<KeyEvent>,
365        last_mouse: Option<MouseEvent>,
366    }
367
368    impl MockPaneContent {
369        fn new(title: &str) -> Self {
370            Self {
371                title: title.to_string(),
372                focusable: true,
373                focused: false,
374                last_key: None,
375                last_mouse: None,
376            }
377        }
378
379        fn non_focusable(title: &str) -> Self {
380            Self {
381                title: title.to_string(),
382                focusable: false,
383                focused: false,
384                last_key: None,
385                last_mouse: None,
386            }
387        }
388    }
389
390    impl Widget for MockPaneContent {
391        fn render(self, _area: Rect, _buf: &mut Buffer) {
392            // No-op for testing
393        }
394    }
395
396    impl PaneContent for MockPaneContent {
397        fn handle_key(&mut self, key: KeyEvent) -> bool {
398            self.last_key = Some(key);
399            true
400        }
401
402        fn handle_mouse(&mut self, mouse: MouseEvent) -> bool {
403            self.last_mouse = Some(mouse);
404            true
405        }
406
407        fn title(&self) -> String {
408            self.title.clone()
409        }
410
411        fn render_content(&mut self, _area: Rect, _frame: &mut ratatui::Frame) {
412            // Mock implementation - do nothing
413        }
414
415        fn is_focusable(&self) -> bool {
416            self.focusable
417        }
418
419        fn set_focused(&mut self, focused: bool) {
420            self.focused = focused;
421        }
422    }
423
424    #[test]
425    fn test_pane_creation() {
426        let pane_id = PaneId::new("test");
427        let content = Box::new(MockPaneContent::new("Test Pane"));
428        let pane = Pane::new(pane_id, content);
429
430        assert_eq!(pane.id(), pane_id);
431        assert_eq!(pane.title(), "Test Pane");
432        assert!(pane.is_focusable());
433    }
434
435    #[test]
436    fn test_pane_non_focusable() {
437        let pane_id = PaneId::new("status");
438        let content = Box::new(MockPaneContent::non_focusable("Status"));
439        let pane = Pane::new(pane_id, content);
440
441        assert!(!pane.is_focusable());
442    }
443
444    #[test]
445    fn test_pane_area() {
446        let pane_id = PaneId::new("test");
447        let content = Box::new(MockPaneContent::new("Test"));
448        let mut pane = Pane::new(pane_id, content);
449
450        let area = Rect::new(10, 20, 30, 40);
451        pane.set_area(area);
452
453        assert_eq!(pane.area(), area);
454    }
455
456    #[test]
457    fn test_pane_contains_point() {
458        let pane_id = PaneId::new("test");
459        let content = Box::new(MockPaneContent::new("Test"));
460        let mut pane = Pane::new(pane_id, content);
461
462        pane.set_area(Rect::new(10, 20, 30, 40));
463
464        // Inside
465        assert!(pane.contains_point(15, 25));
466        assert!(pane.contains_point(10, 20)); // Top-left corner
467        assert!(pane.contains_point(39, 59)); // Bottom-right corner - 1
468
469        // Outside
470        assert!(!pane.contains_point(5, 25)); // Left
471        assert!(!pane.contains_point(45, 25)); // Right
472        assert!(!pane.contains_point(15, 15)); // Above
473        assert!(!pane.contains_point(15, 65)); // Below
474    }
475
476    #[test]
477    fn test_pane_translate_mouse() {
478        let pane_id = PaneId::new("test");
479        let content = Box::new(MockPaneContent::new("Test"));
480        let mut pane = Pane::new(pane_id, content);
481
482        pane.set_area(Rect::new(10, 20, 30, 40));
483
484        let global_mouse = MouseEvent {
485            kind: MouseEventKind::Down(MouseButton::Left),
486            column: 25,
487            row: 35,
488            modifiers: KeyModifiers::empty(),
489        };
490
491        let local_mouse = pane.translate_mouse(global_mouse);
492
493        assert_eq!(local_mouse.column, 15); // 25 - 10
494        assert_eq!(local_mouse.row, 15); // 35 - 20
495        assert_eq!(local_mouse.kind, global_mouse.kind);
496    }
497
498    #[test]
499    fn test_title_with_indicator_focused() {
500        let content = MockPaneContent::new("Test");
501        let title = content.title_with_indicator(false, true);
502        assert_eq!(title, "█ Test (Focused)");
503    }
504
505    #[test]
506    fn test_title_with_indicator_selected() {
507        let content = MockPaneContent::new("Test");
508        let title = content.title_with_indicator(true, false);
509        assert_eq!(title, "● Test (Selected)");
510    }
511
512    #[test]
513    fn test_title_with_indicator_inactive() {
514        let content = MockPaneContent::new("Test");
515        let title = content.title_with_indicator(false, false);
516        assert_eq!(title, "Test");
517    }
518
519    #[test]
520    fn test_border_style_focused() {
521        let content = MockPaneContent::new("Test");
522        let style = content.border_style(false, true);
523        assert_eq!(style.fg, Some(Color::Cyan));
524        assert!(style.add_modifier.contains(Modifier::BOLD));
525    }
526
527    #[test]
528    fn test_border_style_selected() {
529        let content = MockPaneContent::new("Test");
530        let style = content.border_style(true, false);
531        assert_eq!(style.fg, Some(Color::Yellow));
532        assert!(style.add_modifier.contains(Modifier::BOLD));
533    }
534
535    #[test]
536    fn test_border_style_inactive() {
537        let content = MockPaneContent::new("Test");
538        let style = content.border_style(false, false);
539        assert_eq!(style.fg, Some(Color::DarkGray));
540    }
541
542    // Tests for new features
543
544    #[test]
545    fn test_pane_with_icon() {
546        let pane_id = PaneId::new("test");
547        let content = Box::new(MockPaneContent::new("Test Pane"));
548        let pane = Pane::new(pane_id, content).with_icon("🔥");
549
550        assert_eq!(pane.icon, Some("🔥".to_string()));
551    }
552
553    #[test]
554    fn test_pane_without_icon() {
555        let pane_id = PaneId::new("test");
556        let content = Box::new(MockPaneContent::new("Test Pane"));
557        let pane = Pane::new(pane_id, content);
558
559        assert_eq!(pane.icon, None);
560    }
561
562    #[test]
563    fn test_pane_with_padding() {
564        let pane_id = PaneId::new("test");
565        let content = Box::new(MockPaneContent::new("Test Pane"));
566        let pane = Pane::new(pane_id, content).with_padding(1, 2, 3, 4);
567
568        assert_eq!(pane.padding, (1, 2, 3, 4));
569    }
570
571    #[test]
572    fn test_pane_with_uniform_padding() {
573        let pane_id = PaneId::new("test");
574        let content = Box::new(MockPaneContent::new("Test Pane"));
575        let pane = Pane::new(pane_id, content).with_uniform_padding(2);
576
577        assert_eq!(pane.padding, (2, 2, 2, 2));
578    }
579
580    #[test]
581    fn test_pane_default_padding() {
582        let pane_id = PaneId::new("test");
583        let content = Box::new(MockPaneContent::new("Test Pane"));
584        let pane = Pane::new(pane_id, content);
585
586        assert_eq!(pane.padding, (0, 0, 0, 0));
587    }
588
589    #[test]
590    fn test_pane_with_footer() {
591        let pane_id = PaneId::new("test");
592        let content = Box::new(MockPaneContent::new("Test Pane"));
593        let pane = Pane::new(pane_id, content).with_footer("Status: Connected");
594
595        assert_eq!(pane.text_footer, Some("Status: Connected".to_string()));
596    }
597
598    #[test]
599    fn test_pane_without_footer() {
600        let pane_id = PaneId::new("test");
601        let content = Box::new(MockPaneContent::new("Test Pane"));
602        let pane = Pane::new(pane_id, content);
603
604        assert_eq!(pane.text_footer, None);
605    }
606
607    #[test]
608    fn test_pane_with_border_type() {
609        let pane_id = PaneId::new("test");
610        let content = Box::new(MockPaneContent::new("Test Pane"));
611        let pane = Pane::new(pane_id, content).with_border_type(BorderType::Double);
612
613        assert_eq!(pane.border_type, BorderType::Double);
614    }
615
616    #[test]
617    fn test_pane_default_border_type() {
618        let pane_id = PaneId::new("test");
619        let content = Box::new(MockPaneContent::new("Test Pane"));
620        let pane = Pane::new(pane_id, content);
621
622        assert_eq!(pane.border_type, BorderType::Rounded);
623    }
624
625    #[test]
626    fn test_pane_builder_chaining() {
627        let pane_id = PaneId::new("test");
628        let content = Box::new(MockPaneContent::new("Test Pane"));
629        let pane = Pane::new(pane_id, content)
630            .with_icon("🚀")
631            .with_padding(1, 2, 3, 4)
632            .with_footer("Footer text")
633            .with_border_type(BorderType::Thick);
634
635        assert_eq!(pane.icon, Some("🚀".to_string()));
636        assert_eq!(pane.padding, (1, 2, 3, 4));
637        assert_eq!(pane.text_footer, Some("Footer text".to_string()));
638        assert_eq!(pane.border_type, BorderType::Thick);
639    }
640
641    #[test]
642    fn test_get_padded_area_no_padding() {
643        let pane_id = PaneId::new("test");
644        let content = Box::new(MockPaneContent::new("Test Pane"));
645        let pane = Pane::new(pane_id, content);
646
647        let area = Rect::new(10, 20, 100, 50);
648        let padded = pane.get_padded_area(area);
649
650        assert_eq!(padded, area);
651    }
652
653    #[test]
654    fn test_get_padded_area_with_padding() {
655        let pane_id = PaneId::new("test");
656        let content = Box::new(MockPaneContent::new("Test Pane"));
657        let pane = Pane::new(pane_id, content).with_padding(1, 2, 3, 4); // top, right, bottom, left
658
659        let area = Rect::new(10, 20, 100, 50);
660        let padded = pane.get_padded_area(area);
661
662        // x = 10 + 4 (left) = 14
663        // y = 20 + 1 (top) = 21
664        // width = 100 - 2 (right) - 4 (left) = 94
665        // height = 50 - 1 (top) - 3 (bottom) = 46
666        assert_eq!(padded, Rect::new(14, 21, 94, 46));
667    }
668
669    #[test]
670    fn test_get_padded_area_uniform_padding() {
671        let pane_id = PaneId::new("test");
672        let content = Box::new(MockPaneContent::new("Test Pane"));
673        let pane = Pane::new(pane_id, content).with_uniform_padding(2);
674
675        let area = Rect::new(10, 20, 100, 50);
676        let padded = pane.get_padded_area(area);
677
678        // x = 10 + 2 = 12
679        // y = 20 + 2 = 22
680        // width = 100 - 2 - 2 = 96
681        // height = 50 - 2 - 2 = 46
682        assert_eq!(padded, Rect::new(12, 22, 96, 46));
683    }
684
685    #[test]
686    fn test_build_title_with_icon() {
687        let pane_id = PaneId::new("test");
688        let content = Box::new(MockPaneContent::new("My Pane"));
689        let pane = Pane::new(pane_id, content).with_icon("📁");
690
691        let title = pane.build_title(false, false);
692
693        // Check that the title contains the icon
694        let title_text = title
695            .spans
696            .iter()
697            .map(|span| span.content.as_ref())
698            .collect::<String>();
699
700        assert!(title_text.contains("📁"));
701        assert!(title_text.contains("My Pane"));
702    }
703
704    #[test]
705    fn test_build_title_without_icon() {
706        let pane_id = PaneId::new("test");
707        let content = Box::new(MockPaneContent::new("My Pane"));
708        let pane = Pane::new(pane_id, content);
709
710        let title = pane.build_title(false, false);
711
712        let title_text = title
713            .spans
714            .iter()
715            .map(|span| span.content.as_ref())
716            .collect::<String>();
717
718        assert!(title_text.contains("My Pane"));
719        assert!(!title_text.contains("📁"));
720    }
721
722    #[test]
723    fn test_pane_set_focused() {
724        let pane_id = PaneId::new("test");
725        let content = Box::new(MockPaneContent::new("Test Pane"));
726        let mut pane = Pane::new(pane_id, content);
727
728        // Should not panic when setting focus
729        pane.set_focused(true);
730        pane.set_focused(false);
731        pane.set_focused(true);
732    }
733
734    #[test]
735    fn test_pane_set_focused_multiple_times() {
736        let pane_id = PaneId::new("test");
737        let content = Box::new(MockPaneContent::new("Test Pane"));
738        let mut pane = Pane::new(pane_id, content);
739
740        // Toggle focus multiple times - should not panic
741        pane.set_focused(true);
742        pane.set_focused(true); // Should remain true
743        pane.set_focused(false);
744        pane.set_focused(false); // Should remain false
745        pane.set_focused(true);
746
747        // The test passes if no panic occurs
748    }
749
750    #[test]
751    fn test_mock_pane_content_focus_tracking() {
752        // Test the mock implementation directly
753        let mut mock = MockPaneContent::new("Test");
754
755        // Initially not focused
756        assert!(!mock.focused);
757
758        // Set focused via trait method
759        PaneContent::set_focused(&mut mock, true);
760        assert!(mock.focused);
761
762        // Set unfocused
763        PaneContent::set_focused(&mut mock, false);
764        assert!(!mock.focused);
765    }
766}