Skip to main content

ratatui_interact/components/
container.rs

1//! Container component - Popup dialogs with focus management
2//!
3//! Provides a generic popup dialog container that manages child components,
4//! handles Tab navigation, and supports mouse click interactions.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use ratatui_interact::components::{DialogConfig, DialogState, PopupDialog};
10//! use ratatui_interact::traits::ContainerAction;
11//!
12//! // Create dialog configuration
13//! let config = DialogConfig::new("Settings")
14//!     .width_percent(50)
15//!     .height_percent(40)
16//!     .buttons(vec![
17//!         ("Cancel".to_string(), ContainerAction::Close),
18//!         ("Save".to_string(), ContainerAction::Submit),
19//!     ]);
20//!
21//! // Create dialog state
22//! let mut state = DialogState::new(MyContent::default());
23//! state.show();
24//!
25//! // Render in your draw function
26//! let mut dialog = PopupDialog::new(&config, &mut state, |frame, area, content| {
27//!     // Render your content here
28//! });
29//! dialog.render(frame);
30//! ```
31
32use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
33use ratatui::{
34    Frame,
35    layout::{Alignment, Constraint, Direction, Layout, Rect},
36    style::{Color, Modifier, Style},
37    text::Span,
38    widgets::{Block, Borders, Clear, Paragraph},
39};
40
41use crate::{
42    state::FocusManager,
43    traits::{ClickRegionRegistry, ContainerAction, EventResult},
44};
45
46/// Focus targets within a dialog.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum DialogFocusTarget {
49    /// A child component by index.
50    Child(usize),
51    /// A dialog button by index.
52    Button(usize),
53    /// The close button (if present).
54    Close,
55}
56
57/// State for a dialog.
58#[derive(Debug, Clone)]
59pub struct DialogState<T> {
60    /// Child component state.
61    pub children: T,
62    /// Focus manager for Tab navigation.
63    pub focus: FocusManager<DialogFocusTarget>,
64    /// Click regions registry.
65    pub click_regions: ClickRegionRegistry<DialogFocusTarget>,
66    /// Whether the dialog is visible.
67    pub visible: bool,
68}
69
70impl<T: Default> Default for DialogState<T> {
71    fn default() -> Self {
72        Self::new(T::default())
73    }
74}
75
76impl<T> DialogState<T> {
77    /// Create a new dialog state.
78    pub fn new(children: T) -> Self {
79        Self {
80            children,
81            focus: FocusManager::new(),
82            click_regions: ClickRegionRegistry::new(),
83            visible: false,
84        }
85    }
86
87    /// Show the dialog.
88    pub fn show(&mut self) {
89        self.visible = true;
90    }
91
92    /// Hide the dialog.
93    pub fn hide(&mut self) {
94        self.visible = false;
95    }
96
97    /// Toggle dialog visibility.
98    pub fn toggle(&mut self) {
99        self.visible = !self.visible;
100    }
101
102    /// Check if dialog is visible.
103    pub fn is_visible(&self) -> bool {
104        self.visible
105    }
106
107    /// Register a child for focus navigation.
108    pub fn register_child(&mut self, index: usize) {
109        self.focus.register(DialogFocusTarget::Child(index));
110    }
111
112    /// Register a button for focus navigation.
113    pub fn register_button(&mut self, index: usize) {
114        self.focus.register(DialogFocusTarget::Button(index));
115    }
116
117    /// Get the currently focused target.
118    pub fn current_focus(&self) -> Option<&DialogFocusTarget> {
119        self.focus.current()
120    }
121
122    /// Check if a child is focused.
123    pub fn is_child_focused(&self, index: usize) -> bool {
124        self.focus.is_focused(&DialogFocusTarget::Child(index))
125    }
126
127    /// Check if a button is focused.
128    pub fn is_button_focused(&self, index: usize) -> bool {
129        self.focus.is_focused(&DialogFocusTarget::Button(index))
130    }
131}
132
133/// Configuration for a popup dialog.
134#[derive(Debug, Clone)]
135pub struct DialogConfig {
136    /// Dialog title.
137    pub title: String,
138    /// Width as percentage of screen (0-100).
139    pub width_percent: u16,
140    /// Height as percentage of screen (0-100).
141    pub height_percent: u16,
142    /// Minimum width in columns.
143    pub min_width: u16,
144    /// Minimum height in rows.
145    pub min_height: u16,
146    /// Maximum width in columns.
147    pub max_width: u16,
148    /// Maximum height in rows.
149    pub max_height: u16,
150    /// Border color.
151    pub border_color: Color,
152    /// Border color when focused.
153    pub focused_border_color: Color,
154    /// Close dialog on Escape.
155    pub close_on_escape: bool,
156    /// Close dialog when clicking outside.
157    pub close_on_outside_click: bool,
158    /// Dialog buttons (label, action).
159    pub buttons: Vec<(String, ContainerAction)>,
160}
161
162impl Default for DialogConfig {
163    fn default() -> Self {
164        Self {
165            title: String::new(),
166            width_percent: 60,
167            height_percent: 50,
168            min_width: 40,
169            min_height: 10,
170            max_width: 120,
171            max_height: 40,
172            border_color: Color::Blue,
173            focused_border_color: Color::Cyan,
174            close_on_escape: true,
175            close_on_outside_click: true,
176            buttons: vec![
177                ("Cancel".to_string(), ContainerAction::Close),
178                ("OK".to_string(), ContainerAction::Submit),
179            ],
180        }
181    }
182}
183
184impl DialogConfig {
185    /// Create a new dialog configuration with title.
186    pub fn new(title: impl Into<String>) -> Self {
187        Self {
188            title: title.into(),
189            ..Default::default()
190        }
191    }
192
193    /// Set the width percentage.
194    pub fn width_percent(mut self, percent: u16) -> Self {
195        self.width_percent = percent.min(100);
196        self
197    }
198
199    /// Set the height percentage.
200    pub fn height_percent(mut self, percent: u16) -> Self {
201        self.height_percent = percent.min(100);
202        self
203    }
204
205    /// Set minimum dimensions.
206    pub fn min_size(mut self, width: u16, height: u16) -> Self {
207        self.min_width = width;
208        self.min_height = height;
209        self
210    }
211
212    /// Set maximum dimensions.
213    pub fn max_size(mut self, width: u16, height: u16) -> Self {
214        self.max_width = width;
215        self.max_height = height;
216        self
217    }
218
219    /// Set the border color.
220    pub fn border_color(mut self, color: Color) -> Self {
221        self.border_color = color;
222        self
223    }
224
225    /// Set the focused border color.
226    pub fn focused_border_color(mut self, color: Color) -> Self {
227        self.focused_border_color = color;
228        self
229    }
230
231    /// Set close on escape behavior.
232    pub fn close_on_escape(mut self, close: bool) -> Self {
233        self.close_on_escape = close;
234        self
235    }
236
237    /// Set close on outside click behavior.
238    pub fn close_on_outside_click(mut self, close: bool) -> Self {
239        self.close_on_outside_click = close;
240        self
241    }
242
243    /// Set dialog buttons.
244    pub fn buttons(mut self, buttons: Vec<(String, ContainerAction)>) -> Self {
245        self.buttons = buttons;
246        self
247    }
248
249    /// Add a single button.
250    pub fn add_button(mut self, label: impl Into<String>, action: ContainerAction) -> Self {
251        self.buttons.push((label.into(), action));
252        self
253    }
254
255    /// Clear all buttons.
256    pub fn no_buttons(mut self) -> Self {
257        self.buttons.clear();
258        self
259    }
260
261    /// Set only OK button.
262    pub fn ok_only(mut self) -> Self {
263        self.buttons = vec![("OK".to_string(), ContainerAction::Close)];
264        self
265    }
266
267    /// Set OK and Cancel buttons.
268    pub fn ok_cancel(mut self) -> Self {
269        self.buttons = vec![
270            ("Cancel".to_string(), ContainerAction::Close),
271            ("OK".to_string(), ContainerAction::Submit),
272        ];
273        self
274    }
275
276    /// Set Yes and No buttons.
277    pub fn yes_no(mut self) -> Self {
278        self.buttons = vec![
279            ("No".to_string(), ContainerAction::Close),
280            ("Yes".to_string(), ContainerAction::Submit),
281        ];
282        self
283    }
284}
285
286/// Generic popup dialog container.
287///
288/// Manages rendering, focus, and event handling for a popup dialog.
289pub struct PopupDialog<'a, T, F>
290where
291    F: FnMut(&mut Frame, Rect, &mut T),
292{
293    config: &'a DialogConfig,
294    state: &'a mut DialogState<T>,
295    content_renderer: F,
296}
297
298impl<'a, T, F> PopupDialog<'a, T, F>
299where
300    F: FnMut(&mut Frame, Rect, &mut T),
301{
302    /// Create a new popup dialog.
303    ///
304    /// # Arguments
305    ///
306    /// * `config` - Dialog configuration
307    /// * `state` - Dialog state
308    /// * `content_renderer` - Closure to render dialog content
309    pub fn new(
310        config: &'a DialogConfig,
311        state: &'a mut DialogState<T>,
312        content_renderer: F,
313    ) -> Self {
314        Self {
315            config,
316            state,
317            content_renderer,
318        }
319    }
320
321    /// Calculate dialog area centered on screen.
322    pub fn calculate_area(&self, screen: Rect) -> Rect {
323        let width = (screen.width * self.config.width_percent / 100)
324            .max(self.config.min_width)
325            .min(self.config.max_width)
326            .min(screen.width.saturating_sub(4));
327
328        let height = (screen.height * self.config.height_percent / 100)
329            .max(self.config.min_height)
330            .min(self.config.max_height)
331            .min(screen.height.saturating_sub(4));
332
333        let x = (screen.width.saturating_sub(width)) / 2;
334        let y = (screen.height.saturating_sub(height)) / 2;
335
336        Rect::new(x, y, width, height)
337    }
338
339    /// Render the popup.
340    pub fn render(&mut self, frame: &mut Frame) {
341        if !self.state.visible {
342            return;
343        }
344
345        let screen = frame.area();
346        let area = self.calculate_area(screen);
347
348        // Clear click regions before rendering
349        self.state.click_regions.clear();
350
351        // Clear area behind popup
352        frame.render_widget(Clear, area);
353
354        // Render border and title
355        let block = Block::default()
356            .borders(Borders::ALL)
357            .border_style(Style::default().fg(self.config.focused_border_color))
358            .title(format!(" {} ", self.config.title))
359            .title_alignment(Alignment::Center);
360
361        let inner = block.inner(area);
362        frame.render_widget(block, area);
363
364        // Split inner area for content and buttons
365        let button_height = if self.config.buttons.is_empty() { 0 } else { 2 };
366        let chunks = Layout::default()
367            .direction(Direction::Vertical)
368            .constraints([Constraint::Min(1), Constraint::Length(button_height)])
369            .split(inner);
370
371        // Render content
372        (self.content_renderer)(frame, chunks[0], &mut self.state.children);
373
374        // Render buttons
375        if !self.config.buttons.is_empty() {
376            self.render_buttons(frame, chunks[1]);
377        }
378    }
379
380    fn render_buttons(&mut self, frame: &mut Frame, area: Rect) {
381        let button_count = self.config.buttons.len();
382        if button_count == 0 {
383            return;
384        }
385
386        let total_button_width: u16 = self
387            .config
388            .buttons
389            .iter()
390            .map(|(label, _)| label.len() as u16 + 4)
391            .sum::<u16>()
392            + (button_count as u16).saturating_sub(1) * 2;
393
394        let start_x = area.x + (area.width.saturating_sub(total_button_width)) / 2;
395        let mut x = start_x;
396
397        for (idx, (label, _action)) in self.config.buttons.iter().enumerate() {
398            let is_focused = self.state.is_button_focused(idx);
399            let btn_width = label.len() as u16 + 4;
400            let btn_area = Rect::new(x, area.y, btn_width, 1);
401
402            let style = if is_focused {
403                Style::default()
404                    .fg(Color::Black)
405                    .bg(Color::Yellow)
406                    .add_modifier(Modifier::BOLD)
407            } else {
408                Style::default().fg(Color::White).bg(Color::DarkGray)
409            };
410
411            let button_text = format!(" {} ", label);
412            let paragraph = Paragraph::new(Span::styled(button_text, style));
413            frame.render_widget(paragraph, btn_area);
414
415            // Register click region
416            self.state
417                .click_regions
418                .register(btn_area, DialogFocusTarget::Button(idx));
419
420            x += btn_width + 2;
421        }
422    }
423
424    /// Handle keyboard event.
425    pub fn handle_key(&mut self, key: KeyEvent) -> EventResult {
426        if !self.state.visible {
427            return EventResult::NotHandled;
428        }
429
430        match key.code {
431            KeyCode::Esc if self.config.close_on_escape => {
432                self.state.hide();
433                EventResult::Action(ContainerAction::Close)
434            }
435            KeyCode::Tab if !key.modifiers.contains(KeyModifiers::SHIFT) => {
436                self.state.focus.next();
437                EventResult::Consumed
438            }
439            KeyCode::BackTab => {
440                self.state.focus.prev();
441                EventResult::Consumed
442            }
443            KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
444                self.state.focus.prev();
445                EventResult::Consumed
446            }
447            KeyCode::Enter => {
448                if let Some(DialogFocusTarget::Button(idx)) = self.state.focus.current() {
449                    if let Some((_, action)) = self.config.buttons.get(*idx) {
450                        let action = action.clone();
451                        if action.is_close() {
452                            self.state.hide();
453                        }
454                        return EventResult::Action(action);
455                    }
456                }
457                EventResult::NotHandled
458            }
459            _ => EventResult::NotHandled,
460        }
461    }
462
463    /// Handle mouse event.
464    pub fn handle_mouse(&mut self, mouse: MouseEvent) -> EventResult {
465        if !self.state.visible {
466            return EventResult::NotHandled;
467        }
468
469        let screen = Rect::new(0, 0, 80, 24); // Will be passed from actual screen
470        let area = self.calculate_area(screen);
471
472        if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
473            let col = mouse.column;
474            let row = mouse.row;
475
476            // Check if click is outside dialog
477            if self.config.close_on_outside_click
478                && (col < area.x
479                    || col >= area.x + area.width
480                    || row < area.y
481                    || row >= area.y + area.height)
482            {
483                self.state.hide();
484                return EventResult::Action(ContainerAction::Close);
485            }
486
487            // Check click regions
488            if let Some(target) = self.state.click_regions.handle_click(col, row) {
489                match target {
490                    DialogFocusTarget::Button(idx) => {
491                        if let Some((_, action)) = self.config.buttons.get(*idx) {
492                            let action = action.clone();
493                            if action.is_close() {
494                                self.state.hide();
495                            }
496                            return EventResult::Action(action);
497                        }
498                    }
499                    DialogFocusTarget::Child(idx) => {
500                        self.state.focus.set(DialogFocusTarget::Child(*idx));
501                        return EventResult::Consumed;
502                    }
503                    DialogFocusTarget::Close => {
504                        self.state.hide();
505                        return EventResult::Action(ContainerAction::Close);
506                    }
507                }
508            }
509        }
510
511        EventResult::NotHandled
512    }
513
514    /// Handle mouse event with screen dimensions.
515    pub fn handle_mouse_with_screen(&mut self, mouse: MouseEvent, screen: Rect) -> EventResult {
516        if !self.state.visible {
517            return EventResult::NotHandled;
518        }
519
520        let area = self.calculate_area(screen);
521
522        if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
523            let col = mouse.column;
524            let row = mouse.row;
525
526            // Check if click is outside dialog
527            if self.config.close_on_outside_click
528                && (col < area.x
529                    || col >= area.x + area.width
530                    || row < area.y
531                    || row >= area.y + area.height)
532            {
533                self.state.hide();
534                return EventResult::Action(ContainerAction::Close);
535            }
536
537            // Check click regions (same as handle_mouse)
538            if let Some(target) = self.state.click_regions.handle_click(col, row) {
539                match target {
540                    DialogFocusTarget::Button(idx) => {
541                        if let Some((_, action)) = self.config.buttons.get(*idx) {
542                            let action = action.clone();
543                            if action.is_close() {
544                                self.state.hide();
545                            }
546                            return EventResult::Action(action);
547                        }
548                    }
549                    DialogFocusTarget::Child(idx) => {
550                        self.state.focus.set(DialogFocusTarget::Child(*idx));
551                        return EventResult::Consumed;
552                    }
553                    DialogFocusTarget::Close => {
554                        self.state.hide();
555                        return EventResult::Action(ContainerAction::Close);
556                    }
557                }
558            }
559        }
560
561        EventResult::NotHandled
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    #[test]
570    fn test_dialog_state_default() {
571        let state: DialogState<()> = DialogState::default();
572        assert!(!state.visible);
573        assert!(state.focus.is_empty());
574    }
575
576    #[test]
577    fn test_dialog_state_visibility() {
578        let mut state: DialogState<()> = DialogState::new(());
579
580        assert!(!state.is_visible());
581
582        state.show();
583        assert!(state.is_visible());
584
585        state.hide();
586        assert!(!state.is_visible());
587
588        state.toggle();
589        assert!(state.is_visible());
590
591        state.toggle();
592        assert!(!state.is_visible());
593    }
594
595    #[test]
596    fn test_dialog_state_focus_registration() {
597        let mut state: DialogState<()> = DialogState::new(());
598
599        state.register_child(0);
600        state.register_child(1);
601        state.register_button(0);
602        state.register_button(1);
603
604        assert!(state.is_child_focused(0)); // First registered is focused
605
606        state.focus.next();
607        assert!(state.is_child_focused(1));
608
609        state.focus.next();
610        assert!(state.is_button_focused(0));
611    }
612
613    #[test]
614    fn test_dialog_config_default() {
615        let config = DialogConfig::default();
616        assert_eq!(config.width_percent, 60);
617        assert_eq!(config.height_percent, 50);
618        assert!(config.close_on_escape);
619        assert!(config.close_on_outside_click);
620        assert_eq!(config.buttons.len(), 2);
621    }
622
623    #[test]
624    fn test_dialog_config_builder() {
625        let config = DialogConfig::new("Test Dialog")
626            .width_percent(80)
627            .height_percent(60)
628            .close_on_escape(false)
629            .close_on_outside_click(false);
630
631        assert_eq!(config.title, "Test Dialog");
632        assert_eq!(config.width_percent, 80);
633        assert_eq!(config.height_percent, 60);
634        assert!(!config.close_on_escape);
635        assert!(!config.close_on_outside_click);
636    }
637
638    #[test]
639    fn test_dialog_config_buttons() {
640        let config = DialogConfig::new("Test").ok_only();
641        assert_eq!(config.buttons.len(), 1);
642        assert_eq!(config.buttons[0].0, "OK");
643
644        let config = DialogConfig::new("Test").ok_cancel();
645        assert_eq!(config.buttons.len(), 2);
646
647        let config = DialogConfig::new("Test").yes_no();
648        assert_eq!(config.buttons.len(), 2);
649        assert_eq!(config.buttons[0].0, "No");
650        assert_eq!(config.buttons[1].0, "Yes");
651
652        let config = DialogConfig::new("Test").no_buttons();
653        assert!(config.buttons.is_empty());
654    }
655
656    #[test]
657    fn test_dialog_config_custom_buttons() {
658        let config = DialogConfig::new("Test")
659            .no_buttons()
660            .add_button("Apply", ContainerAction::custom("apply"))
661            .add_button("Close", ContainerAction::Close);
662
663        assert_eq!(config.buttons.len(), 2);
664        assert_eq!(config.buttons[0].0, "Apply");
665        assert_eq!(config.buttons[1].1, ContainerAction::Close);
666    }
667
668    #[test]
669    fn test_calculate_area() {
670        let config = DialogConfig::new("Test")
671            .width_percent(50)
672            .height_percent(50);
673        let mut state: DialogState<()> = DialogState::new(());
674
675        let dialog = PopupDialog::new(&config, &mut state, |_, _, _| {});
676
677        let screen = Rect::new(0, 0, 100, 50);
678        let area = dialog.calculate_area(screen);
679
680        assert_eq!(area.width, 50); // 50% of 100
681        assert_eq!(area.height, 25); // 50% of 50
682        assert_eq!(area.x, 25); // Centered: (100 - 50) / 2
683        assert_eq!(area.y, 12); // Centered: (50 - 25) / 2
684    }
685
686    #[test]
687    fn test_calculate_area_constrained() {
688        let config = DialogConfig::new("Test")
689            .width_percent(100)
690            .height_percent(100)
691            .max_size(60, 30);
692        let mut state: DialogState<()> = DialogState::new(());
693
694        let dialog = PopupDialog::new(&config, &mut state, |_, _, _| {});
695
696        let screen = Rect::new(0, 0, 100, 50);
697        let area = dialog.calculate_area(screen);
698
699        // Should be constrained to max size
700        assert_eq!(area.width, 60);
701        assert_eq!(area.height, 30);
702    }
703
704    #[test]
705    fn test_dialog_focus_target_equality() {
706        assert_eq!(DialogFocusTarget::Child(0), DialogFocusTarget::Child(0));
707        assert_ne!(DialogFocusTarget::Child(0), DialogFocusTarget::Child(1));
708        assert_ne!(DialogFocusTarget::Child(0), DialogFocusTarget::Button(0));
709        assert_eq!(DialogFocusTarget::Close, DialogFocusTarget::Close);
710    }
711}