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}