Skip to main content

boxmux_lib/components/dimensions/
scroll_dimensions.rs

1use super::Orientation;
2use crate::Bounds;
3
4/// ScrollDimensions - Centralizes ALL scrolling and scrollbar mathematical operations
5///
6/// Eliminates ad hoc scrollbar math like:
7/// - knob_size = std::cmp::max(1, (track_height as f64 * content_ratio).round() as usize)
8/// - available_track = track_height.saturating_sub(knob_size)
9/// - knob_position = ((scroll / 100.0) * available_track as f64).round() as usize
10/// - All coordinate translation between scroll percentages and pixel positions
11///
12/// Replaces scattered scrollbar logic from vertical_scrollbar.rs, horizontal_scrollbar.rs,
13/// draw_loop.rs scrollbar detection, and box_renderer.rs scroll calculations.
14#[derive(Debug, Clone)]
15pub struct ScrollDimensions {
16    /// Total content size (width, height)
17    content_size: (usize, usize),
18    /// Viewable area size (width, height)
19    viewable_size: (usize, usize),
20    /// Current scroll position as percentage (0.0-100.0) for (horizontal, vertical)
21    scroll_position: (f64, f64),
22    /// Parent bounds for track positioning
23    parent_bounds: Bounds,
24}
25
26impl ScrollDimensions {
27    /// Create new scroll dimensions
28    pub fn new(
29        content_size: (usize, usize),
30        viewable_size: (usize, usize),
31        scroll_position: (f64, f64),
32        parent_bounds: Bounds,
33    ) -> Self {
34        Self {
35            content_size,
36            viewable_size,
37            scroll_position: (
38                scroll_position.0.clamp(0.0, 100.0),
39                scroll_position.1.clamp(0.0, 100.0),
40            ),
41            parent_bounds,
42        }
43    }
44
45    /// Update scroll position (with automatic clamping)
46    pub fn set_scroll_position(&mut self, horizontal: f64, vertical: f64) {
47        self.scroll_position = (horizontal.clamp(0.0, 100.0), vertical.clamp(0.0, 100.0));
48    }
49
50    /// Check if scrollbar is needed for given orientation
51    /// Replaces: content_height > viewable_height checks
52    pub fn is_scrollbar_needed(&self, orientation: Orientation) -> bool {
53        match orientation {
54            Orientation::Vertical => self.content_size.1 > self.viewable_size.1,
55            Orientation::Horizontal => self.content_size.0 > self.viewable_size.0,
56        }
57    }
58
59    /// Get scrollbar track bounds (where scrollbar can be drawn)
60    /// Centralizes track positioning logic from scrollbar components
61    pub fn get_track_bounds(&self, orientation: Orientation) -> TrackBounds {
62        match orientation {
63            Orientation::Vertical => {
64                let x = self.parent_bounds.right();
65                let start_y = self.parent_bounds.top() + 1;
66                let end_y = self.parent_bounds.bottom().saturating_sub(1);
67                TrackBounds {
68                    position: x,
69                    start: start_y,
70                    end: end_y,
71                    length: end_y.saturating_sub(start_y),
72                }
73            }
74            Orientation::Horizontal => {
75                let y = self.parent_bounds.bottom();
76                let start_x = self.parent_bounds.left() + 1;
77                let end_x = self.parent_bounds.right().saturating_sub(1);
78                TrackBounds {
79                    position: y,
80                    start: start_x,
81                    end: end_x,
82                    length: end_x.saturating_sub(start_x),
83                }
84            }
85        }
86    }
87
88    /// Calculate scrollbar knob size
89    /// Centralizes: knob_size = std::cmp::max(1, (track_length as f64 * content_ratio).round() as usize)
90    pub fn calculate_knob_size(&self, orientation: Orientation) -> usize {
91        let track = self.get_track_bounds(orientation);
92        let track_length = track.length;
93
94        if track_length == 0 {
95            return 1;
96        }
97
98        let content_ratio = match orientation {
99            Orientation::Vertical => {
100                if self.content_size.1 == 0 {
101                    1.0
102                } else {
103                    self.viewable_size.1 as f64 / self.content_size.1 as f64
104                }
105            }
106            Orientation::Horizontal => {
107                if self.content_size.0 == 0 {
108                    1.0
109                } else {
110                    self.viewable_size.0 as f64 / self.content_size.0 as f64
111                }
112            }
113        };
114
115        std::cmp::max(1, (track_length as f64 * content_ratio).round() as usize)
116    }
117
118    /// Calculate scrollbar knob position within track
119    /// Centralizes: knob_position = ((scroll / 100.0) * available_track as f64).round() as usize
120    pub fn calculate_knob_position(&self, orientation: Orientation) -> usize {
121        let track = self.get_track_bounds(orientation);
122        let knob_size = self.calculate_knob_size(orientation);
123        let available_track = track.length.saturating_sub(knob_size);
124
125        if available_track == 0 {
126            return 0;
127        }
128
129        let scroll_percent = match orientation {
130            Orientation::Vertical => self.scroll_position.1,
131            Orientation::Horizontal => self.scroll_position.0,
132        };
133
134        ((scroll_percent / 100.0) * available_track as f64).round() as usize
135    }
136
137    /// Get absolute knob bounds for drawing
138    /// Centralizes knob positioning from scrollbar components
139    pub fn get_knob_bounds(&self, orientation: Orientation) -> KnobBounds {
140        let track = self.get_track_bounds(orientation);
141        let knob_size = self.calculate_knob_size(orientation);
142        let knob_position = self.calculate_knob_position(orientation);
143
144        match orientation {
145            Orientation::Vertical => KnobBounds {
146                x: track.position,
147                y_start: track.start + knob_position,
148                y_end: track.start + knob_position + knob_size,
149                size: knob_size,
150            },
151            Orientation::Horizontal => KnobBounds {
152                x: track.start + knob_position,
153                y_start: track.position,
154                y_end: track.position,
155                size: knob_size,
156            },
157        }
158    }
159
160    /// Calculate scroll offset in content coordinates
161    /// Centralizes: offset = ((scroll / 100.0) * max_offset as f64).floor() as usize
162    pub fn calculate_scroll_offset(&self, orientation: Orientation) -> usize {
163        let max_offset = match orientation {
164            Orientation::Vertical => {
165                if self.content_size.1 <= self.viewable_size.1 {
166                    0
167                } else {
168                    self.content_size.1 - self.viewable_size.1
169                }
170            }
171            Orientation::Horizontal => {
172                if self.content_size.0 <= self.viewable_size.0 {
173                    0
174                } else {
175                    self.content_size.0 - self.viewable_size.0
176                }
177            }
178        };
179
180        if max_offset == 0 {
181            return 0;
182        }
183
184        let scroll_percent = match orientation {
185            Orientation::Vertical => self.scroll_position.1,
186            Orientation::Horizontal => self.scroll_position.0,
187        };
188
189        ((scroll_percent / 100.0) * max_offset as f64).floor() as usize
190    }
191
192    /// Convert pixel position within track to scroll percentage
193    /// Centralizes click-to-scroll calculations from draw_loop.rs
194    pub fn pixel_to_scroll_percent(
195        &self,
196        pixel_coordinate: usize,
197        orientation: Orientation,
198    ) -> f64 {
199        let track = self.get_track_bounds(orientation);
200
201        if track.length == 0 {
202            return 0.0;
203        }
204
205        let relative_position = pixel_coordinate.saturating_sub(track.start);
206        let click_position = relative_position as f64 / track.length as f64;
207
208        (click_position * 100.0).clamp(0.0, 100.0)
209    }
210
211    /// Test if coordinate hits the scrollbar knob
212    /// Centralizes knob hit testing from draw_loop.rs
213    pub fn hits_knob(&self, x: usize, y: usize, orientation: Orientation) -> bool {
214        let knob = self.get_knob_bounds(orientation);
215
216        match orientation {
217            Orientation::Vertical => x == knob.x && y >= knob.y_start && y < knob.y_end,
218            Orientation::Horizontal => y == knob.y_start && x >= knob.x && x < (knob.x + knob.size),
219        }
220    }
221
222    /// Test if coordinate hits the scrollbar track (but not knob)
223    /// Centralizes track click detection for jump-to-position
224    pub fn hits_track(&self, x: usize, y: usize, orientation: Orientation) -> bool {
225        let track = self.get_track_bounds(orientation);
226
227        match orientation {
228            Orientation::Vertical => {
229                x == track.position
230                    && y >= track.start
231                    && y <= track.end
232                    && !self.hits_knob(x, y, orientation)
233            }
234            Orientation::Horizontal => {
235                y == track.position
236                    && x >= track.start
237                    && x <= track.end
238                    && !self.hits_knob(x, y, orientation)
239            }
240        }
241    }
242
243    /// Calculate new scroll percentage from knob drag operation
244    /// Centralizes drag-to-scroll calculations
245    pub fn calculate_drag_scroll(
246        &self,
247        start_pixel: usize,
248        current_pixel: usize,
249        orientation: Orientation,
250    ) -> f64 {
251        let track = self.get_track_bounds(orientation);
252        let knob_size = self.calculate_knob_size(orientation);
253        let available_track = track.length.saturating_sub(knob_size);
254
255        if available_track == 0 {
256            return match orientation {
257                Orientation::Vertical => self.scroll_position.1,
258                Orientation::Horizontal => self.scroll_position.0,
259            };
260        }
261
262        let pixel_delta = current_pixel as i32 - start_pixel as i32;
263        let percentage_delta = (pixel_delta as f64 / available_track as f64) * 100.0;
264
265        let current_scroll = match orientation {
266            Orientation::Vertical => self.scroll_position.1,
267            Orientation::Horizontal => self.scroll_position.0,
268        };
269
270        (current_scroll + percentage_delta).clamp(0.0, 100.0)
271    }
272
273    /// Get visible content range (start, end) for given orientation  
274    /// Centralizes visible range calculations for content clipping
275    pub fn get_visible_range(&self, orientation: Orientation) -> (usize, usize) {
276        let scroll_offset = self.calculate_scroll_offset(orientation);
277        let viewable_size = match orientation {
278            Orientation::Vertical => self.viewable_size.1,
279            Orientation::Horizontal => self.viewable_size.0,
280        };
281
282        let start = scroll_offset;
283        let end = scroll_offset + viewable_size;
284
285        (start, end)
286    }
287
288    /// Calculate auto-scroll adjustment to keep target line visible
289    /// Centralizes auto-scroll logic from draw_loop.rs choice navigation
290    pub fn calculate_auto_scroll_to_line(
291        &self,
292        target_line: usize,
293        orientation: Orientation,
294    ) -> f64 {
295        let (visible_start, visible_end) = self.get_visible_range(orientation);
296        let viewable_size = match orientation {
297            Orientation::Vertical => self.viewable_size.1,
298            Orientation::Horizontal => self.viewable_size.0,
299        };
300        let content_size = match orientation {
301            Orientation::Vertical => self.content_size.1,
302            Orientation::Horizontal => self.content_size.0,
303        };
304
305        // Already visible
306        if target_line >= visible_start && target_line < visible_end {
307            return match orientation {
308                Orientation::Vertical => self.scroll_position.1,
309                Orientation::Horizontal => self.scroll_position.0,
310            };
311        }
312
313        let new_offset = if target_line < visible_start {
314            // Scroll up to show target line at top
315            target_line
316        } else {
317            // Scroll down to show target line at bottom
318            target_line.saturating_sub(viewable_size - 1)
319        };
320
321        if content_size <= viewable_size {
322            return 0.0;
323        }
324
325        let max_offset = content_size - viewable_size;
326        let scroll_percent = (new_offset as f64 / max_offset as f64) * 100.0;
327
328        scroll_percent.clamp(0.0, 100.0)
329    }
330}
331
332#[derive(Debug, Clone, PartialEq)]
333pub struct TrackBounds {
334    /// Position of the track (x for vertical, y for horizontal)
335    pub position: usize,
336    /// Start coordinate along track direction
337    pub start: usize,
338    /// End coordinate along track direction
339    pub end: usize,
340    /// Total track length
341    pub length: usize,
342}
343
344#[derive(Debug, Clone, PartialEq)]
345pub struct KnobBounds {
346    /// X coordinate of knob
347    pub x: usize,
348    /// Start Y coordinate of knob
349    pub y_start: usize,
350    /// End Y coordinate of knob
351    pub y_end: usize,
352    /// Knob size
353    pub size: usize,
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn test_scrollbar_needed() {
362        let bounds = Bounds::new(0, 0, 10, 5);
363        let scroll_dims = ScrollDimensions::new(
364            (20, 15), // content larger than viewable
365            (10, 5),  // viewable size
366            (0.0, 0.0),
367            bounds,
368        );
369
370        assert!(scroll_dims.is_scrollbar_needed(Orientation::Horizontal));
371        assert!(scroll_dims.is_scrollbar_needed(Orientation::Vertical));
372    }
373
374    #[test]
375    fn test_knob_size_calculation() {
376        let bounds = Bounds::new(0, 0, 10, 10);
377        let scroll_dims = ScrollDimensions::new(
378            (20, 20), // content is 2x viewable size
379            (10, 10), // viewable size
380            (0.0, 0.0),
381            bounds,
382        );
383
384        // Track length is bounds.height() - 2 = 8 (top+1, bottom-1)
385        // Content ratio is 10/20 = 0.5
386        // Knob size should be 8 * 0.5 = 4
387        let knob_size = scroll_dims.calculate_knob_size(Orientation::Vertical);
388        assert_eq!(knob_size, 4);
389    }
390
391    #[test]
392    fn test_scroll_offset_calculation() {
393        let bounds = Bounds::new(0, 0, 10, 10);
394        let mut scroll_dims = ScrollDimensions::new(
395            (20, 20),     // content size
396            (10, 10),     // viewable size
397            (50.0, 50.0), // 50% scroll
398            bounds,
399        );
400
401        // Max offset = 20 - 10 = 10
402        // 50% of 10 = 5
403        let offset = scroll_dims.calculate_scroll_offset(Orientation::Vertical);
404        assert_eq!(offset, 5);
405
406        // Test 100% scroll
407        scroll_dims.set_scroll_position(100.0, 100.0);
408        let offset = scroll_dims.calculate_scroll_offset(Orientation::Vertical);
409        assert_eq!(offset, 10);
410    }
411
412    #[test]
413    fn test_pixel_to_scroll_percent() {
414        let bounds = Bounds::new(0, 0, 10, 10);
415        let scroll_dims = ScrollDimensions::new((20, 20), (10, 10), (0.0, 0.0), bounds);
416
417        // Vertical track: start=1, end=9, length=8
418        // Click at pixel 5 (middle of track) should be 50%
419        let track = scroll_dims.get_track_bounds(Orientation::Vertical);
420        let middle_pixel = track.start + track.length / 2;
421        let percent = scroll_dims.pixel_to_scroll_percent(middle_pixel, Orientation::Vertical);
422
423        assert!((percent - 50.0).abs() < 1.0); // Allow small floating point error
424    }
425
426    #[test]
427    fn test_knob_hit_testing() {
428        let bounds = Bounds::new(0, 0, 10, 10);
429        let scroll_dims = ScrollDimensions::new(
430            (20, 20),
431            (10, 10),
432            (0.0, 0.0), // No scroll - knob at top
433            bounds,
434        );
435
436        let knob = scroll_dims.get_knob_bounds(Orientation::Vertical);
437
438        // Should hit knob at its position
439        assert!(scroll_dims.hits_knob(knob.x, knob.y_start, Orientation::Vertical));
440
441        // Should not hit outside knob
442        assert!(!scroll_dims.hits_knob(knob.x, knob.y_end + 1, Orientation::Vertical));
443    }
444
445    #[test]
446    fn test_auto_scroll_to_line() {
447        let bounds = Bounds::new(0, 0, 10, 10);
448        let scroll_dims = ScrollDimensions::new(
449            (10, 100),  // 100 lines of content
450            (10, 10),   // 10 lines visible
451            (0.0, 0.0), // Start at top
452            bounds,
453        );
454
455        // Target line 50 should scroll to show it
456        let scroll_percent = scroll_dims.calculate_auto_scroll_to_line(50, Orientation::Vertical);
457
458        // Should be around 50% scroll to center line 50
459        assert!(scroll_percent > 40.0 && scroll_percent < 60.0);
460    }
461}