Skip to main content

ratatui_interact/traits/
container.rs

1//! Container trait for component composition
2//!
3//! Provides trait-based "inheritance" pattern for containers that manage
4//! child components and can be expressed as popup dialogs.
5//!
6//! # Design Pattern
7//!
8//! The Container and PopupContainer traits enable composition-based UI design:
9//!
10//! - `Container` - Base trait for any component that manages children
11//! - `PopupContainer` - Extension for popup/modal dialog behavior
12//!
13//! # Example
14//!
15//! ```rust,ignore
16//! use ratatui_interact::traits::{Container, PopupContainer, EventResult, ContainerAction};
17//! use ratatui::{layout::Rect, Frame};
18//! use crossterm::event::{KeyEvent, MouseEvent};
19//!
20//! struct MyDialog;
21//!
22//! impl Container for MyDialog {
23//!     type State = MyDialogState;
24//!
25//!     fn render(&self, frame: &mut Frame, area: Rect, state: &Self::State) {
26//!         // Render dialog content
27//!     }
28//!
29//!     fn handle_key(&self, key: KeyEvent, state: &mut Self::State) -> EventResult {
30//!         EventResult::NotHandled
31//!     }
32//!
33//!     fn handle_mouse(&self, mouse: MouseEvent, state: &mut Self::State) -> EventResult {
34//!         EventResult::NotHandled
35//!     }
36//!
37//!     fn preferred_size(&self) -> (u16, u16) {
38//!         (60, 20)
39//!     }
40//! }
41//!
42//! impl PopupContainer for MyDialog {
43//!     // Uses default implementations for popup_area, close_on_escape, etc.
44//! }
45//! ```
46
47use crossterm::event::{KeyEvent, MouseEvent};
48use ratatui::{Frame, layout::Rect};
49
50/// Result of handling an event.
51///
52/// Used by containers to indicate how an event was processed.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum EventResult {
55    /// Event was consumed, no further handling needed.
56    Consumed,
57    /// Event was not handled, should propagate to parent.
58    NotHandled,
59    /// Event triggered a specific action.
60    Action(ContainerAction),
61}
62
63impl EventResult {
64    /// Check if the event was consumed (either Consumed or Action).
65    pub fn is_consumed(&self) -> bool {
66        !matches!(self, EventResult::NotHandled)
67    }
68
69    /// Check if the result is an action.
70    pub fn is_action(&self) -> bool {
71        matches!(self, EventResult::Action(_))
72    }
73
74    /// Get the action if this is an Action result.
75    pub fn action(&self) -> Option<&ContainerAction> {
76        match self {
77            EventResult::Action(action) => Some(action),
78            _ => None,
79        }
80    }
81}
82
83/// Actions that containers can emit.
84///
85/// These are standard actions that containers can produce in response
86/// to user interactions.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum ContainerAction {
89    /// Close the container (dismiss popup/dialog).
90    Close,
91    /// Submit/confirm the container's contents.
92    Submit,
93    /// Custom action with string identifier.
94    Custom(String),
95}
96
97impl ContainerAction {
98    /// Create a custom action.
99    pub fn custom(name: impl Into<String>) -> Self {
100        Self::Custom(name.into())
101    }
102
103    /// Check if this is a Close action.
104    pub fn is_close(&self) -> bool {
105        matches!(self, ContainerAction::Close)
106    }
107
108    /// Check if this is a Submit action.
109    pub fn is_submit(&self) -> bool {
110        matches!(self, ContainerAction::Submit)
111    }
112
113    /// Get the custom action name if this is a Custom action.
114    pub fn custom_name(&self) -> Option<&str> {
115        match self {
116            ContainerAction::Custom(name) => Some(name),
117            _ => None,
118        }
119    }
120}
121
122/// Trait for container components that manage children.
123///
124/// Containers are responsible for:
125/// - Rendering their content within a given area
126/// - Handling keyboard events
127/// - Handling mouse events
128/// - Reporting their preferred size
129pub trait Container {
130    /// The type of state this container manages.
131    type State;
132
133    /// Render the container and its children.
134    ///
135    /// # Arguments
136    ///
137    /// * `frame` - The frame to render into
138    /// * `area` - The area allocated to this container
139    /// * `state` - The container's state
140    fn render(&self, frame: &mut Frame, area: Rect, state: &Self::State);
141
142    /// Handle keyboard events.
143    ///
144    /// # Arguments
145    ///
146    /// * `key` - The key event to handle
147    /// * `state` - The container's mutable state
148    ///
149    /// # Returns
150    ///
151    /// How the event was handled.
152    fn handle_key(&self, key: KeyEvent, state: &mut Self::State) -> EventResult;
153
154    /// Handle mouse events.
155    ///
156    /// # Arguments
157    ///
158    /// * `mouse` - The mouse event to handle
159    /// * `state` - The container's mutable state
160    ///
161    /// # Returns
162    ///
163    /// How the event was handled.
164    fn handle_mouse(&self, mouse: MouseEvent, state: &mut Self::State) -> EventResult;
165
166    /// Get the preferred size for this container.
167    ///
168    /// # Returns
169    ///
170    /// A tuple of (width, height) representing the preferred dimensions.
171    fn preferred_size(&self) -> (u16, u16);
172}
173
174/// Trait for containers that can be expressed as popup dialogs.
175///
176/// This extends `Container` with popup-specific behavior like
177/// centered positioning and close-on-escape.
178pub trait PopupContainer: Container {
179    /// Calculate the centered popup area given screen dimensions.
180    ///
181    /// Default implementation centers the popup based on `preferred_size()`,
182    /// with padding from screen edges.
183    fn popup_area(&self, screen: Rect) -> Rect {
184        let (width, height) = self.preferred_size();
185        let width = width.min(screen.width.saturating_sub(4));
186        let height = height.min(screen.height.saturating_sub(4));
187
188        let x = (screen.width.saturating_sub(width)) / 2;
189        let y = (screen.height.saturating_sub(height)) / 2;
190
191        Rect::new(x, y, width, height)
192    }
193
194    /// Whether clicking outside the popup should close it.
195    ///
196    /// Default returns `true`.
197    fn close_on_outside_click(&self) -> bool {
198        true
199    }
200
201    /// Whether pressing Escape should close the popup.
202    ///
203    /// Default returns `true`.
204    fn close_on_escape(&self) -> bool {
205        true
206    }
207
208    /// Get the minimum margin from screen edges.
209    ///
210    /// Default returns 2 (allows for shadows/borders).
211    fn screen_margin(&self) -> u16 {
212        2
213    }
214
215    /// Calculate popup area with custom positioning.
216    ///
217    /// # Arguments
218    ///
219    /// * `screen` - The full screen dimensions
220    /// * `anchor_x` - Optional x position to anchor near
221    /// * `anchor_y` - Optional y position to anchor near
222    fn popup_area_anchored(
223        &self,
224        screen: Rect,
225        anchor_x: Option<u16>,
226        anchor_y: Option<u16>,
227    ) -> Rect {
228        let (width, height) = self.preferred_size();
229        let margin = self.screen_margin();
230        let width = width.min(screen.width.saturating_sub(margin * 2));
231        let height = height.min(screen.height.saturating_sub(margin * 2));
232
233        let x = match anchor_x {
234            Some(ax) => {
235                // Try to position near anchor, but keep on screen
236                let ideal = ax.saturating_sub(width / 2);
237                ideal.clamp(margin, screen.width.saturating_sub(width + margin))
238            }
239            None => (screen.width.saturating_sub(width)) / 2,
240        };
241
242        let y = match anchor_y {
243            Some(ay) => {
244                // Try to position below anchor
245                let below = ay + 1;
246                if below + height <= screen.height.saturating_sub(margin) {
247                    below
248                } else {
249                    // Position above if not enough room below
250                    ay.saturating_sub(height + 1).max(margin)
251                }
252            }
253            None => (screen.height.saturating_sub(height)) / 2,
254        };
255
256        Rect::new(x, y, width, height)
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn test_event_result_consumed() {
266        assert!(EventResult::Consumed.is_consumed());
267        assert!(EventResult::Action(ContainerAction::Close).is_consumed());
268        assert!(!EventResult::NotHandled.is_consumed());
269    }
270
271    #[test]
272    fn test_event_result_action() {
273        assert!(!EventResult::Consumed.is_action());
274        assert!(!EventResult::NotHandled.is_action());
275        assert!(EventResult::Action(ContainerAction::Close).is_action());
276
277        let result = EventResult::Action(ContainerAction::Submit);
278        assert_eq!(result.action(), Some(&ContainerAction::Submit));
279
280        assert_eq!(EventResult::Consumed.action(), None);
281    }
282
283    #[test]
284    fn test_container_action_types() {
285        assert!(ContainerAction::Close.is_close());
286        assert!(!ContainerAction::Close.is_submit());
287
288        assert!(ContainerAction::Submit.is_submit());
289        assert!(!ContainerAction::Submit.is_close());
290
291        let custom = ContainerAction::custom("my_action");
292        assert_eq!(custom.custom_name(), Some("my_action"));
293        assert!(!custom.is_close());
294        assert!(!custom.is_submit());
295
296        assert_eq!(ContainerAction::Close.custom_name(), None);
297    }
298
299    struct TestContainer {
300        preferred_width: u16,
301        preferred_height: u16,
302    }
303
304    impl Container for TestContainer {
305        type State = ();
306
307        fn render(&self, _frame: &mut Frame, _area: Rect, _state: &Self::State) {}
308
309        fn handle_key(&self, _key: KeyEvent, _state: &mut Self::State) -> EventResult {
310            EventResult::NotHandled
311        }
312
313        fn handle_mouse(&self, _mouse: MouseEvent, _state: &mut Self::State) -> EventResult {
314            EventResult::NotHandled
315        }
316
317        fn preferred_size(&self) -> (u16, u16) {
318            (self.preferred_width, self.preferred_height)
319        }
320    }
321
322    impl PopupContainer for TestContainer {}
323
324    #[test]
325    fn test_popup_area_centered() {
326        let container = TestContainer {
327            preferred_width: 40,
328            preferred_height: 20,
329        };
330
331        let screen = Rect::new(0, 0, 100, 50);
332        let area = container.popup_area(screen);
333
334        // Should be centered
335        assert_eq!(area.width, 40);
336        assert_eq!(area.height, 20);
337        assert_eq!(area.x, 30); // (100 - 40) / 2
338        assert_eq!(area.y, 15); // (50 - 20) / 2
339    }
340
341    #[test]
342    fn test_popup_area_constrained() {
343        let container = TestContainer {
344            preferred_width: 200, // Larger than screen
345            preferred_height: 100,
346        };
347
348        let screen = Rect::new(0, 0, 80, 24);
349        let area = container.popup_area(screen);
350
351        // Should be constrained to screen with padding
352        assert_eq!(area.width, 76); // 80 - 4
353        assert_eq!(area.height, 20); // 24 - 4
354    }
355
356    #[test]
357    fn test_popup_defaults() {
358        let container = TestContainer {
359            preferred_width: 40,
360            preferred_height: 20,
361        };
362
363        assert!(container.close_on_escape());
364        assert!(container.close_on_outside_click());
365        assert_eq!(container.screen_margin(), 2);
366    }
367
368    #[test]
369    fn test_popup_area_anchored() {
370        let container = TestContainer {
371            preferred_width: 20,
372            preferred_height: 10,
373        };
374
375        let screen = Rect::new(0, 0, 80, 24);
376
377        // Anchored at center
378        let area = container.popup_area_anchored(screen, Some(40), Some(10));
379        assert_eq!(area.x, 30); // 40 - 20/2, within bounds
380        assert_eq!(area.y, 11); // Below anchor (10 + 1)
381
382        // Anchored near bottom - should flip above
383        let area = container.popup_area_anchored(screen, Some(40), Some(20));
384        assert_eq!(area.y, 9); // Above anchor (20 - 10 - 1)
385    }
386}