Skip to main content

ratatui_interact/traits/
clickable.rs

1//! Clickable trait for mouse interaction
2//!
3//! Provides click region management for components that respond to mouse clicks.
4//! Click regions are registered during rendering and checked during event handling.
5//!
6//! # Example
7//!
8//! ```rust
9//! use ratatui_interact::traits::{ClickRegion, ClickRegionRegistry};
10//! use ratatui::layout::Rect;
11//!
12//! // Create a registry for tracking click regions
13//! let mut registry: ClickRegionRegistry<&str> = ClickRegionRegistry::new();
14//!
15//! // Register click regions during render
16//! registry.register(Rect::new(0, 0, 10, 1), "button1");
17//! registry.register(Rect::new(15, 0, 10, 1), "button2");
18//!
19//! // Check for clicks during event handling
20//! if let Some(action) = registry.handle_click(5, 0) {
21//!     assert_eq!(*action, "button1");
22//! }
23//! ```
24
25use ratatui::layout::Rect;
26
27/// A registered click region that responds to mouse clicks.
28///
29/// Associates a rectangular area with user-defined data that is returned
30/// when a click occurs within the region.
31#[derive(Debug, Clone)]
32pub struct ClickRegion<T: Clone> {
33    /// The area that responds to clicks.
34    pub area: Rect,
35    /// User-defined data associated with this region.
36    pub data: T,
37}
38
39impl<T: Clone> ClickRegion<T> {
40    /// Create a new click region.
41    ///
42    /// # Arguments
43    ///
44    /// * `area` - The rectangular area that responds to clicks
45    /// * `data` - Data to return when this region is clicked
46    pub fn new(area: Rect, data: T) -> Self {
47        Self { area, data }
48    }
49
50    /// Check if a point is within this region.
51    ///
52    /// # Arguments
53    ///
54    /// * `col` - The column (x) position
55    /// * `row` - The row (y) position
56    ///
57    /// # Returns
58    ///
59    /// `true` if the point is within the region's bounds.
60    pub fn contains(&self, col: u16, row: u16) -> bool {
61        col >= self.area.x
62            && col < self.area.x + self.area.width
63            && row >= self.area.y
64            && row < self.area.y + self.area.height
65    }
66}
67
68/// Trait for components that respond to mouse clicks.
69///
70/// Implement this trait to make a component clickable with automatic
71/// hit-testing based on registered click regions.
72pub trait Clickable {
73    /// The type of action that a click produces.
74    type ClickAction: Clone;
75
76    /// Returns all click regions for this component.
77    ///
78    /// Called after rendering to get the active regions.
79    fn click_regions(&self) -> &[ClickRegion<Self::ClickAction>];
80
81    /// Handle a click at the given position.
82    ///
83    /// Returns `Some(action)` if the click was within a region,
84    /// `None` otherwise.
85    ///
86    /// Default implementation checks all regions and returns the first match.
87    fn handle_click(&self, col: u16, row: u16) -> Option<Self::ClickAction> {
88        self.click_regions()
89            .iter()
90            .find(|r| r.contains(col, row))
91            .map(|r| r.data.clone())
92    }
93}
94
95/// Registry for managing click regions during render.
96///
97/// Use this to track clickable areas that are populated during rendering
98/// and checked during event handling.
99///
100/// # Example
101///
102/// ```rust
103/// use ratatui_interact::traits::ClickRegionRegistry;
104/// use ratatui::layout::Rect;
105///
106/// #[derive(Clone, PartialEq, Debug)]
107/// enum ButtonId { Save, Cancel }
108///
109/// let mut registry: ClickRegionRegistry<ButtonId> = ClickRegionRegistry::new();
110///
111/// // Clear before each render
112/// registry.clear();
113///
114/// // Register regions during render
115/// registry.register(Rect::new(0, 0, 8, 1), ButtonId::Save);
116/// registry.register(Rect::new(10, 0, 8, 1), ButtonId::Cancel);
117///
118/// // Check clicks during event handling
119/// assert_eq!(registry.handle_click(4, 0), Some(&ButtonId::Save));
120/// assert_eq!(registry.handle_click(14, 0), Some(&ButtonId::Cancel));
121/// assert_eq!(registry.handle_click(9, 0), None); // Gap between buttons
122/// ```
123#[derive(Debug, Clone)]
124pub struct ClickRegionRegistry<T: Clone> {
125    regions: Vec<ClickRegion<T>>,
126}
127
128impl<T: Clone> Default for ClickRegionRegistry<T> {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134impl<T: Clone> ClickRegionRegistry<T> {
135    /// Create a new empty registry.
136    pub fn new() -> Self {
137        Self {
138            regions: Vec::new(),
139        }
140    }
141
142    /// Create a new registry with pre-allocated capacity.
143    pub fn with_capacity(capacity: usize) -> Self {
144        Self {
145            regions: Vec::with_capacity(capacity),
146        }
147    }
148
149    /// Clear all registered regions.
150    ///
151    /// Call this at the start of each render to reset the regions.
152    pub fn clear(&mut self) {
153        self.regions.clear();
154    }
155
156    /// Register a new click region.
157    ///
158    /// # Arguments
159    ///
160    /// * `area` - The rectangular area that responds to clicks
161    /// * `data` - Data to return when this region is clicked
162    pub fn register(&mut self, area: Rect, data: T) {
163        self.regions.push(ClickRegion::new(area, data));
164    }
165
166    /// Handle a click at the given position.
167    ///
168    /// Returns a reference to the data if the click was within a region,
169    /// `None` otherwise.
170    ///
171    /// # Arguments
172    ///
173    /// * `col` - The column (x) position
174    /// * `row` - The row (y) position
175    pub fn handle_click(&self, col: u16, row: u16) -> Option<&T> {
176        self.regions
177            .iter()
178            .find(|r| r.contains(col, row))
179            .map(|r| &r.data)
180    }
181
182    /// Get all registered regions.
183    pub fn regions(&self) -> &[ClickRegion<T>] {
184        &self.regions
185    }
186
187    /// Check if any regions are registered.
188    pub fn is_empty(&self) -> bool {
189        self.regions.is_empty()
190    }
191
192    /// Get the number of registered regions.
193    pub fn len(&self) -> usize {
194        self.regions.len()
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn test_click_region_contains() {
204        let region = ClickRegion::new(Rect::new(10, 5, 20, 3), "test");
205
206        // Inside
207        assert!(region.contains(10, 5)); // Top-left corner
208        assert!(region.contains(29, 7)); // Bottom-right corner (exclusive bounds)
209        assert!(region.contains(20, 6)); // Middle
210
211        // Outside
212        assert!(!region.contains(9, 5)); // Left of region
213        assert!(!region.contains(30, 5)); // Right of region
214        assert!(!region.contains(10, 4)); // Above region
215        assert!(!region.contains(10, 8)); // Below region
216    }
217
218    #[test]
219    fn test_click_region_zero_size() {
220        let region = ClickRegion::new(Rect::new(5, 5, 0, 0), "test");
221        assert!(!region.contains(5, 5));
222    }
223
224    #[test]
225    fn test_registry_basic_operations() {
226        let mut registry: ClickRegionRegistry<&str> = ClickRegionRegistry::new();
227
228        assert!(registry.is_empty());
229        assert_eq!(registry.len(), 0);
230
231        registry.register(Rect::new(0, 0, 10, 1), "first");
232        registry.register(Rect::new(15, 0, 10, 1), "second");
233
234        assert!(!registry.is_empty());
235        assert_eq!(registry.len(), 2);
236
237        registry.clear();
238        assert!(registry.is_empty());
239    }
240
241    #[test]
242    fn test_registry_handle_click() {
243        let mut registry: ClickRegionRegistry<i32> = ClickRegionRegistry::new();
244
245        registry.register(Rect::new(0, 0, 10, 1), 1);
246        registry.register(Rect::new(15, 0, 10, 1), 2);
247        registry.register(Rect::new(0, 2, 25, 2), 3);
248
249        // Click on first region
250        assert_eq!(registry.handle_click(5, 0), Some(&1));
251
252        // Click on second region
253        assert_eq!(registry.handle_click(20, 0), Some(&2));
254
255        // Click on third region
256        assert_eq!(registry.handle_click(12, 3), Some(&3));
257
258        // Click in gap
259        assert_eq!(registry.handle_click(12, 0), None);
260
261        // Click outside all regions
262        assert_eq!(registry.handle_click(100, 100), None);
263    }
264
265    #[test]
266    fn test_registry_overlapping_regions() {
267        let mut registry: ClickRegionRegistry<&str> = ClickRegionRegistry::new();
268
269        // Overlapping regions - first registered wins
270        registry.register(Rect::new(0, 0, 20, 2), "back");
271        registry.register(Rect::new(5, 0, 10, 1), "front");
272
273        // Click on overlapping area returns first registered
274        assert_eq!(registry.handle_click(7, 0), Some(&"back"));
275
276        // Click on non-overlapping part of back region
277        assert_eq!(registry.handle_click(2, 1), Some(&"back"));
278    }
279
280    #[test]
281    fn test_clickable_trait() {
282        #[derive(Clone, PartialEq, Debug)]
283        enum Action {
284            Click,
285        }
286
287        struct ClickableWidget {
288            regions: Vec<ClickRegion<Action>>,
289        }
290
291        impl Clickable for ClickableWidget {
292            type ClickAction = Action;
293
294            fn click_regions(&self) -> &[ClickRegion<Self::ClickAction>] {
295                &self.regions
296            }
297        }
298
299        let widget = ClickableWidget {
300            regions: vec![ClickRegion::new(Rect::new(0, 0, 10, 1), Action::Click)],
301        };
302
303        assert_eq!(widget.handle_click(5, 0), Some(Action::Click));
304        assert_eq!(widget.handle_click(15, 0), None);
305    }
306}