Skip to main content

boxmux_lib/components/dimensions/
mouse_dimensions.rs

1use crate::Bounds;
2
3/// MouseDimensions - Centralizes ALL mouse coordinate translation and hit testing
4///
5/// Eliminates ad hoc coordinate math like:
6/// - column as usize, row as usize conversions from crossterm
7/// - inbox_x = screen_x.saturating_sub(content_start_x.saturating_sub(horizontal_offset))
8/// - Complex coordinate system translations between screen, content, and scrolled spaces
9/// - Hit testing scattered across multiple files
10///
11/// Replaces scattered coordinate translation from box_renderer.rs, draw_loop.rs, input_loop.rs
12#[derive(Debug, Clone)]
13pub struct MouseDimensions {
14    /// Screen bounds (outer component bounds)
15    screen_bounds: Bounds,
16    /// Content bounds (inner drawable area)
17    content_bounds: Bounds,
18    /// Current scroll offset (horizontal, vertical) in pixels
19    scroll_offset: (usize, usize),
20    /// Content size (total scrollable area)
21    content_size: (usize, usize),
22}
23
24impl MouseDimensions {
25    /// Create new mouse dimensions
26    pub fn new(
27        screen_bounds: Bounds,
28        content_bounds: Bounds,
29        scroll_offset: (usize, usize),
30        content_size: (usize, usize),
31    ) -> Self {
32        Self {
33            screen_bounds,
34            content_bounds,
35            scroll_offset,
36            content_size,
37        }
38    }
39
40    /// Update scroll offset
41    pub fn set_scroll_offset(&mut self, horizontal: usize, vertical: usize) {
42        self.scroll_offset = (horizontal, vertical);
43    }
44
45    /// Convert crossterm coordinates (u16) to internal coordinates (usize)
46    /// Centralizes: column as usize, row as usize conversions
47    pub fn crossterm_to_internal(&self, column: u16, row: u16) -> (usize, usize) {
48        (column as usize, row as usize)
49    }
50
51    /// Convert internal coordinates back to crossterm (for event generation)
52    pub fn internal_to_crossterm(&self, x: usize, y: usize) -> (u16, u16) {
53        (x as u16, y as u16)
54    }
55
56    /// Test if screen coordinates are within component bounds
57    pub fn is_within_screen_bounds(&self, x: usize, y: usize) -> bool {
58        self.screen_bounds.contains_point(x, y)
59    }
60
61    /// Test if screen coordinates are within content area
62    pub fn is_within_content_bounds(&self, x: usize, y: usize) -> bool {
63        self.content_bounds.contains_point(x, y)
64    }
65
66    /// Convert screen coordinates to content coordinates (accounting for scroll)
67    /// Centralizes complex translation logic from box_renderer.rs
68    pub fn screen_to_content(&self, screen_x: usize, screen_y: usize) -> Option<(usize, usize)> {
69        // First check if within content bounds
70        if !self.content_bounds.contains_point(screen_x, screen_y) {
71            return None;
72        }
73
74        // Convert to content-relative coordinates
75        let relative_x = screen_x.saturating_sub(self.content_bounds.x1);
76        let relative_y = screen_y.saturating_sub(self.content_bounds.y1);
77
78        // Apply scroll offset to get actual content coordinates
79        let content_x = relative_x + self.scroll_offset.0;
80        let content_y = relative_y + self.scroll_offset.1;
81
82        // Validate within content size
83        if content_x >= self.content_size.0 || content_y >= self.content_size.1 {
84            return None;
85        }
86
87        Some((content_x, content_y))
88    }
89
90    /// Convert content coordinates to screen coordinates (reverse translation)
91    pub fn content_to_screen(&self, content_x: usize, content_y: usize) -> Option<(usize, usize)> {
92        // Check if content coordinates are valid
93        if content_x >= self.content_size.0 || content_y >= self.content_size.1 {
94            return None;
95        }
96
97        // Check if content coordinates are within the currently visible range
98        // Visible range is from scroll_offset to scroll_offset + viewable_size
99        let viewable_width = self.content_bounds.width();
100        let viewable_height = self.content_bounds.height();
101
102        if content_x < self.scroll_offset.0 || content_y < self.scroll_offset.1 {
103            return None; // Before visible area (scrolled past)
104        }
105
106        if content_x >= self.scroll_offset.0 + viewable_width
107            || content_y >= self.scroll_offset.1 + viewable_height
108        {
109            return None; // After visible area
110        }
111
112        // Apply scroll offset to get screen-relative position
113        let screen_relative_x = content_x - self.scroll_offset.0;
114        let screen_relative_y = content_y - self.scroll_offset.1;
115
116        // Convert to absolute screen coordinates
117        let screen_x = self.content_bounds.x1 + screen_relative_x;
118        let screen_y = self.content_bounds.y1 + screen_relative_y;
119
120        Some((screen_x, screen_y))
121    }
122
123    /// Get the content coordinate range that's currently visible
124    pub fn get_visible_content_range(&self) -> ((usize, usize), (usize, usize)) {
125        let start_x = self.scroll_offset.0;
126        let start_y = self.scroll_offset.1;
127        let end_x = (start_x + self.content_bounds.width()).min(self.content_size.0);
128        let end_y = (start_y + self.content_bounds.height()).min(self.content_size.1);
129
130        ((start_x, start_y), (end_x, end_y))
131    }
132
133    /// Test if content coordinates are currently visible on screen
134    pub fn is_content_coordinate_visible(&self, content_x: usize, content_y: usize) -> bool {
135        let ((start_x, start_y), (end_x, end_y)) = self.get_visible_content_range();
136
137        content_x >= start_x && content_x < end_x && content_y >= start_y && content_y < end_y
138    }
139
140    /// Calculate which content line a screen Y coordinate corresponds to
141    /// Useful for text selection and choice navigation
142    pub fn screen_y_to_content_line(&self, screen_y: usize) -> Option<usize> {
143        if !self.is_within_content_bounds(0, screen_y) {
144            return None;
145        }
146
147        let relative_y = screen_y.saturating_sub(self.content_bounds.y1);
148        let content_line = relative_y + self.scroll_offset.1;
149
150        if content_line >= self.content_size.1 {
151            None
152        } else {
153            Some(content_line)
154        }
155    }
156
157    /// Calculate which content column a screen X coordinate corresponds to  
158    /// Useful for text cursor positioning
159    pub fn screen_x_to_content_column(&self, screen_x: usize) -> Option<usize> {
160        if !self.is_within_content_bounds(screen_x, 0) {
161            return None;
162        }
163
164        let relative_x = screen_x.saturating_sub(self.content_bounds.x1);
165        let content_column = relative_x + self.scroll_offset.0;
166
167        if content_column >= self.content_size.0 {
168            None
169        } else {
170            Some(content_column)
171        }
172    }
173
174    /// Calculate click regions for UI components
175    /// Centralizes region detection logic
176    pub fn detect_click_region(&self, x: usize, y: usize) -> ClickRegion {
177        if !self.is_within_screen_bounds(x, y) {
178            return ClickRegion::Outside;
179        }
180
181        // Check if in content area
182        if self.is_within_content_bounds(x, y) {
183            return ClickRegion::Content;
184        }
185
186        // Check specific UI regions
187        let bounds = &self.screen_bounds;
188
189        // Scrollbar regions
190        if x == bounds.x2 && y >= bounds.y1 && y <= bounds.y2 {
191            return ClickRegion::VerticalScrollbar;
192        }
193
194        if y == bounds.y2 && x >= bounds.x1 && x <= bounds.x2 {
195            return ClickRegion::HorizontalScrollbar;
196        }
197
198        // Tab bar region (top inside border)
199        if y == bounds.y1 + 1 && x > bounds.x1 && x < bounds.x2 {
200            return ClickRegion::TabBar;
201        }
202
203        // Title/border regions
204        if y == bounds.y1 || y == bounds.y2 || x == bounds.x1 || x == bounds.x2 {
205            return ClickRegion::Border;
206        }
207
208        ClickRegion::Content // Fallback
209    }
210
211    /// Calculate distance between two points (for drag detection)
212    pub fn calculate_distance(&self, x1: usize, y1: usize, x2: usize, y2: usize) -> f64 {
213        let dx = (x2 as f64) - (x1 as f64);
214        let dy = (y2 as f64) - (y1 as f64);
215        (dx * dx + dy * dy).sqrt()
216    }
217
218    /// Test if mouse movement constitutes a drag (beyond threshold)
219    pub fn is_drag_operation(
220        &self,
221        start_x: usize,
222        start_y: usize,
223        current_x: usize,
224        current_y: usize,
225        threshold: f64,
226    ) -> bool {
227        self.calculate_distance(start_x, start_y, current_x, current_y) > threshold
228    }
229
230    /// Calculate bounding box for a selection region
231    pub fn calculate_selection_bounds(
232        &self,
233        start_x: usize,
234        start_y: usize,
235        end_x: usize,
236        end_y: usize,
237    ) -> SelectionBounds {
238        let min_x = start_x.min(end_x);
239        let max_x = start_x.max(end_x);
240        let min_y = start_y.min(end_y);
241        let max_y = start_y.max(end_y);
242
243        SelectionBounds {
244            start_x: min_x,
245            start_y: min_y,
246            end_x: max_x,
247            end_y: max_y,
248            width: max_x.saturating_sub(min_x) + 1,
249            height: max_y.saturating_sub(min_y) + 1,
250        }
251    }
252
253    /// Clamp coordinates to valid screen bounds
254    /// Prevents out-of-bounds coordinate issues
255    pub fn clamp_to_screen_bounds(&self, x: usize, y: usize) -> (usize, usize) {
256        let clamped_x = x.clamp(self.screen_bounds.x1, self.screen_bounds.x2);
257        let clamped_y = y.clamp(self.screen_bounds.y1, self.screen_bounds.y2);
258        (clamped_x, clamped_y)
259    }
260
261    /// Clamp coordinates to valid content bounds
262    pub fn clamp_to_content_bounds(&self, x: usize, y: usize) -> (usize, usize) {
263        let clamped_x = x.clamp(self.content_bounds.x1, self.content_bounds.x2);
264        let clamped_y = y.clamp(self.content_bounds.y1, self.content_bounds.y2);
265        (clamped_x, clamped_y)
266    }
267
268    /// Calculate scroll delta needed to make content coordinate visible
269    pub fn calculate_scroll_to_visible(&self, content_x: usize, content_y: usize) -> (i32, i32) {
270        let mut delta_x = 0i32;
271        let mut delta_y = 0i32;
272
273        let viewable_width = self.content_bounds.width();
274        let viewable_height = self.content_bounds.height();
275
276        // Horizontal scrolling
277        if content_x < self.scroll_offset.0 {
278            // Scroll left
279            delta_x = -((self.scroll_offset.0 - content_x) as i32);
280        } else if content_x >= self.scroll_offset.0 + viewable_width {
281            // Scroll right
282            delta_x = (content_x - (self.scroll_offset.0 + viewable_width - 1)) as i32;
283        }
284
285        // Vertical scrolling
286        if content_y < self.scroll_offset.1 {
287            // Scroll up
288            delta_y = -((self.scroll_offset.1 - content_y) as i32);
289        } else if content_y >= self.scroll_offset.1 + viewable_height {
290            // Scroll down
291            delta_y = (content_y - (self.scroll_offset.1 + viewable_height - 1)) as i32;
292        }
293
294        (delta_x, delta_y)
295    }
296
297    /// Update mouse dimensions for window/content resize
298    pub fn update_bounds(&mut self, screen_bounds: Bounds, content_bounds: Bounds) {
299        self.screen_bounds = screen_bounds;
300        self.content_bounds = content_bounds;
301    }
302
303    /// Update content size (when content changes)
304    pub fn update_content_size(&mut self, width: usize, height: usize) {
305        self.content_size = (width, height);
306    }
307}
308
309#[derive(Debug, Clone, PartialEq)]
310pub enum ClickRegion {
311    Content,
312    Border,
313    TabBar,
314    VerticalScrollbar,
315    HorizontalScrollbar,
316    CloseButton,
317    ResizeHandle,
318    Outside,
319}
320
321#[derive(Debug, Clone, PartialEq)]
322pub struct SelectionBounds {
323    pub start_x: usize,
324    pub start_y: usize,
325    pub end_x: usize,
326    pub end_y: usize,
327    pub width: usize,
328    pub height: usize,
329}
330
331impl SelectionBounds {
332    /// Test if a point is within the selection
333    pub fn contains(&self, x: usize, y: usize) -> bool {
334        x >= self.start_x && x <= self.end_x && y >= self.start_y && y <= self.end_y
335    }
336
337    /// Get all coordinates within the selection
338    pub fn get_coordinates(&self) -> Vec<(usize, usize)> {
339        let mut coords = Vec::new();
340        for y in self.start_y..=self.end_y {
341            for x in self.start_x..=self.end_x {
342                coords.push((x, y));
343            }
344        }
345        coords
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    fn create_test_mouse_dims() -> MouseDimensions {
354        let screen_bounds = Bounds::new(0, 0, 20, 10); // 21x11
355        let content_bounds = Bounds::new(2, 2, 18, 8); // 17x7 content area
356        let scroll_offset = (5, 3); // Scrolled 5 right, 3 down
357        let content_size = (50, 30); // Large content area
358
359        MouseDimensions::new(screen_bounds, content_bounds, scroll_offset, content_size)
360    }
361
362    #[test]
363    fn test_crossterm_coordinate_conversion() {
364        let mouse_dims = create_test_mouse_dims();
365
366        let (x, y) = mouse_dims.crossterm_to_internal(15, 7);
367        assert_eq!(x, 15);
368        assert_eq!(y, 7);
369
370        let (col, row) = mouse_dims.internal_to_crossterm(15, 7);
371        assert_eq!(col, 15);
372        assert_eq!(row, 7);
373    }
374
375    #[test]
376    fn test_bounds_checking() {
377        let mouse_dims = create_test_mouse_dims();
378
379        // Within screen bounds
380        assert!(mouse_dims.is_within_screen_bounds(10, 5));
381
382        // Outside screen bounds
383        assert!(!mouse_dims.is_within_screen_bounds(25, 5));
384        assert!(!mouse_dims.is_within_screen_bounds(10, 15));
385
386        // Within content bounds
387        assert!(mouse_dims.is_within_content_bounds(10, 5));
388
389        // Outside content bounds (but within screen)
390        assert!(!mouse_dims.is_within_content_bounds(1, 1)); // Border area
391    }
392
393    #[test]
394    fn test_screen_to_content_translation() {
395        let mouse_dims = create_test_mouse_dims();
396
397        // Click at content area (10, 5) should translate to content coordinates
398        if let Some((content_x, content_y)) = mouse_dims.screen_to_content(10, 5) {
399            // Screen (10,5) - content_bounds.x1 (2) = 8, + scroll_offset.0 (5) = 13
400            // Screen (5) - content_bounds.y1 (2) = 3, + scroll_offset.1 (3) = 6
401            assert_eq!(content_x, 13);
402            assert_eq!(content_y, 6);
403        } else {
404            panic!("Should successfully translate coordinates");
405        }
406
407        // Click outside content area should return None
408        assert!(mouse_dims.screen_to_content(1, 1).is_none());
409    }
410
411    #[test]
412    fn test_content_to_screen_translation() {
413        let mouse_dims = create_test_mouse_dims();
414
415        // Content coordinates (13, 6) should translate back to screen
416        if let Some((screen_x, screen_y)) = mouse_dims.content_to_screen(13, 6) {
417            // Content (13) - scroll_offset.0 (5) = 8, + content_bounds.x1 (2) = 10
418            // Content (6) - scroll_offset.1 (3) = 3, + content_bounds.y1 (2) = 5
419            assert_eq!(screen_x, 10);
420            assert_eq!(screen_y, 5);
421        } else {
422            panic!("Should successfully translate back to screen coordinates");
423        }
424
425        // Content coordinates not currently visible should return None
426        assert!(mouse_dims.content_to_screen(0, 0).is_none()); // Before scroll area
427    }
428
429    #[test]
430    fn test_visible_content_range() {
431        let mouse_dims = create_test_mouse_dims();
432
433        let ((start_x, start_y), (end_x, end_y)) = mouse_dims.get_visible_content_range();
434
435        // Should match scroll offset and content bounds size
436        assert_eq!(start_x, 5); // scroll_offset.0
437        assert_eq!(start_y, 3); // scroll_offset.1
438        assert_eq!(end_x, 5 + 17); // start + content_bounds.width()
439        assert_eq!(end_y, 3 + 7); // start + content_bounds.height()
440    }
441
442    #[test]
443    fn test_click_region_detection() {
444        let mouse_dims = create_test_mouse_dims();
445
446        // Content area
447        assert_eq!(mouse_dims.detect_click_region(10, 5), ClickRegion::Content);
448
449        // Border
450        assert_eq!(mouse_dims.detect_click_region(0, 0), ClickRegion::Border);
451
452        // Vertical scrollbar (right edge)
453        assert_eq!(
454            mouse_dims.detect_click_region(20, 5),
455            ClickRegion::VerticalScrollbar
456        );
457
458        // Horizontal scrollbar (bottom edge)
459        assert_eq!(
460            mouse_dims.detect_click_region(10, 10),
461            ClickRegion::HorizontalScrollbar
462        );
463
464        // Outside
465        assert_eq!(mouse_dims.detect_click_region(25, 15), ClickRegion::Outside);
466    }
467
468    #[test]
469    fn test_drag_detection() {
470        let mouse_dims = create_test_mouse_dims();
471
472        // Small movement - not a drag
473        assert!(!mouse_dims.is_drag_operation(10, 5, 11, 6, 3.0));
474
475        // Large movement - is a drag
476        assert!(mouse_dims.is_drag_operation(10, 5, 15, 10, 3.0));
477    }
478
479    #[test]
480    fn test_selection_bounds() {
481        let mouse_dims = create_test_mouse_dims();
482
483        let selection = mouse_dims.calculate_selection_bounds(5, 3, 10, 7);
484
485        assert_eq!(selection.start_x, 5);
486        assert_eq!(selection.start_y, 3);
487        assert_eq!(selection.end_x, 10);
488        assert_eq!(selection.end_y, 7);
489        assert_eq!(selection.width, 6); // 10 - 5 + 1
490        assert_eq!(selection.height, 5); // 7 - 3 + 1
491
492        // Test contains
493        assert!(selection.contains(7, 5));
494        assert!(!selection.contains(2, 2));
495        assert!(!selection.contains(12, 8));
496    }
497
498    #[test]
499    fn test_coordinate_clamping() {
500        let mouse_dims = create_test_mouse_dims();
501
502        // Clamp to screen bounds
503        let (x, y) = mouse_dims.clamp_to_screen_bounds(25, 15);
504        assert_eq!(x, 20); // screen_bounds.x2
505        assert_eq!(y, 10); // screen_bounds.y2
506
507        // Clamp to content bounds
508        let (x, y) = mouse_dims.clamp_to_content_bounds(25, 15);
509        assert_eq!(x, 18); // content_bounds.x2
510        assert_eq!(y, 8); // content_bounds.y2
511    }
512
513    #[test]
514    fn test_scroll_to_visible_calculation() {
515        let mouse_dims = create_test_mouse_dims();
516
517        // Content coordinate already visible - no scroll needed
518        let (dx, dy) = mouse_dims.calculate_scroll_to_visible(10, 5);
519        assert_eq!(dx, 0);
520        assert_eq!(dy, 0);
521
522        // Content coordinate to the left - need to scroll left
523        let (dx, dy) = mouse_dims.calculate_scroll_to_visible(2, 5);
524        assert!(dx < 0); // Scroll left
525
526        // Content coordinate to the right - need to scroll right
527        let (dx, dy) = mouse_dims.calculate_scroll_to_visible(25, 5);
528        assert!(dx > 0); // Scroll right
529    }
530}