Skip to main content

boxmux_lib/components/
vertical_scrollbar.rs

1use crate::components::ComponentDimensions;
2use crate::draw_utils::print_with_color_and_background_at;
3use crate::{Bounds, ScreenBuffer};
4
5/// Vertical Scrollbar Component
6///
7/// Extracted from existing static content scrollbar implementation (F0356)
8/// Provides unified vertical scrollbar functionality with:
9/// - Context awareness of parent box
10/// - Automatic visibility detection
11/// - Proportional knob sizing
12/// - Click-to-jump and drag-to-scroll support
13/// - Transparent behavior matching existing implementation
14#[derive(Debug, Clone)]
15pub struct VerticalScrollbar {
16    /// Parent muxbox ID for context awareness
17    pub parent_id: String,
18    /// Scrollbar track character (from existing implementation)
19    track_char: &'static str,
20    /// Scrollbar knob character (from existing implementation)
21    knob_char: &'static str,
22}
23
24impl VerticalScrollbar {
25    /// Create new vertical scrollbar component for a parent muxbox
26    pub fn new(parent_id: String) -> Self {
27        Self {
28            parent_id,
29            track_char: "│", // V_SCROLL_TRACK from existing implementation
30            knob_char: "█",  // V_SCROLL_CHAR from existing implementation
31        }
32    }
33
34    /// Determine if scrollbar should be visible based on content dimensions
35    pub fn should_draw(&self, content_height: usize, viewable_height: usize) -> bool {
36        content_height > viewable_height
37    }
38
39    /// Get the bounds where this scrollbar should be drawn
40    pub fn get_bounds(&self, parent_bounds: &Bounds) -> (usize, usize, usize) {
41        let component_dims = ComponentDimensions::new(*parent_bounds);
42        let track_bounds = component_dims.vertical_scrollbar_track_bounds();
43        (track_bounds.x1, track_bounds.y1, track_bounds.y2)
44    }
45
46    /// Calculate knob position and size using ScrollDimensions (eliminates ad-hoc math)
47    pub fn calculate_knob_metrics(
48        &self,
49        content_height: usize,
50        viewable_height: usize,
51        vertical_scroll: f64,
52        track_height: usize,
53    ) -> (usize, usize) {
54        if track_height == 0 {
55            return (0, 0);
56        }
57
58        // Use ScrollDimensions for proper calculations
59        use crate::components::dimensions::{Orientation, ScrollDimensions};
60        use crate::Bounds;
61
62        let scroll_dims = ScrollDimensions::new(
63            (1, content_height), // Use 1 for width since this is vertical scrollbar
64            (1, viewable_height),
65            (0.0, vertical_scroll), // Only vertical scroll matters
66            Bounds::new(0, 0, 2, track_height + 1), // Adjust bounds so track_length = track_height
67        );
68
69        let knob_size = scroll_dims.calculate_knob_size(Orientation::Vertical);
70        let knob_position = scroll_dims.calculate_knob_position(Orientation::Vertical);
71
72        (knob_position, knob_size)
73    }
74
75    /// Draw the complete vertical scrollbar (track + knob)
76    /// This is extracted directly from the existing draw_vertical_scrollbar function
77    pub fn draw(
78        &self,
79        parent_bounds: &Bounds,
80        content_height: usize,
81        viewable_height: usize,
82        vertical_scroll: f64,
83        border_color: &Option<String>,
84        bg_color: &Option<String>,
85        buffer: &mut ScreenBuffer,
86    ) {
87        if !self.should_draw(content_height, viewable_height) {
88            return;
89        }
90
91        let (x, start_y, end_y) = self.get_bounds(parent_bounds);
92        let track_height = end_y.saturating_sub(start_y);
93
94        // Draw vertical scroll track (exact copy from existing implementation)
95        for y in start_y..end_y {
96            print_with_color_and_background_at(
97                y,
98                x,
99                &Some("bright_black".to_string()),
100                bg_color,
101                self.track_char,
102                buffer,
103            );
104        }
105
106        if track_height > 0 {
107            let (knob_position, knob_size) = self.calculate_knob_metrics(
108                content_height,
109                viewable_height,
110                vertical_scroll,
111                track_height,
112            );
113
114            // Draw proportional vertical scroll knob (exact copy from existing implementation)
115            for i in 0..knob_size {
116                let knob_y = start_y + knob_position + i;
117                if knob_y < end_y {
118                    print_with_color_and_background_at(
119                        knob_y,
120                        x,
121                        border_color,
122                        bg_color,
123                        self.knob_char,
124                        buffer,
125                    );
126                }
127            }
128        }
129    }
130
131    /// Check if a click position is on this scrollbar
132    pub fn is_click_on_scrollbar(
133        &self,
134        click_x: usize,
135        click_y: usize,
136        parent_bounds: &Bounds,
137    ) -> bool {
138        let (x, _, _) = self.get_bounds(parent_bounds);
139        click_x == x && click_y > parent_bounds.top() && click_y < parent_bounds.bottom()
140    }
141
142    /// Check if a click position is specifically on the scrollbar knob
143    pub fn is_click_on_knob(
144        &self,
145        click_x: usize,
146        click_y: usize,
147        parent_bounds: &Bounds,
148        content_height: usize,
149        viewable_height: usize,
150        vertical_scroll: f64,
151    ) -> bool {
152        if !self.is_click_on_scrollbar(click_x, click_y, parent_bounds) {
153            return false;
154        }
155
156        let (_, start_y, end_y) = self.get_bounds(parent_bounds);
157        let track_height = end_y.saturating_sub(start_y);
158
159        if track_height == 0 {
160            return false;
161        }
162
163        let (knob_position, knob_size) = self.calculate_knob_metrics(
164            content_height,
165            viewable_height,
166            vertical_scroll,
167            track_height,
168        );
169
170        let knob_start_y = start_y + knob_position;
171        let knob_end_y = knob_start_y + knob_size;
172
173        click_y >= knob_start_y && click_y < knob_end_y
174    }
175
176    /// Convert click position to scroll percentage
177    pub fn click_position_to_scroll_percentage(
178        &self,
179        click_y: usize,
180        parent_bounds: &Bounds,
181    ) -> f64 {
182        let (_, start_y, end_y) = self.get_bounds(parent_bounds);
183        let track_height = end_y.saturating_sub(start_y);
184
185        if track_height == 0 {
186            return 0.0;
187        }
188
189        let click_position = (click_y.saturating_sub(start_y)) as f64 / track_height as f64;
190        (click_position * 100.0).clamp(0.0, 100.0)
191    }
192
193    /// Calculate new scroll percentage based on drag movement
194    pub fn drag_to_scroll_percentage(
195        &self,
196        start_y: u16,
197        current_y: u16,
198        start_scroll_percentage: f64,
199        parent_bounds: &Bounds,
200    ) -> f64 {
201        let (_, track_start_y, track_end_y) = self.get_bounds(parent_bounds);
202        let track_height = track_end_y.saturating_sub(track_start_y);
203
204        if track_height == 0 {
205            return start_scroll_percentage;
206        }
207
208        let drag_delta = (current_y as isize) - (start_y as isize);
209        let percentage_delta = (drag_delta as f64 / track_height as f64) * 100.0;
210
211        (start_scroll_percentage + percentage_delta).clamp(0.0, 100.0)
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn test_vertical_scrollbar_creation() {
221        let scrollbar = VerticalScrollbar::new("test_muxbox".to_string());
222        assert_eq!(scrollbar.parent_id, "test_muxbox");
223        assert_eq!(scrollbar.track_char, "│");
224        assert_eq!(scrollbar.knob_char, "█");
225    }
226
227    #[test]
228    fn test_should_draw_logic() {
229        let scrollbar = VerticalScrollbar::new("test".to_string());
230
231        // Should not draw when content fits
232        assert!(!scrollbar.should_draw(10, 20));
233        assert!(!scrollbar.should_draw(10, 10));
234
235        // Should draw when content overflows
236        assert!(scrollbar.should_draw(20, 10));
237    }
238
239    #[test]
240    fn test_knob_metrics_calculation() {
241        let scrollbar = VerticalScrollbar::new("test".to_string());
242
243        // Test proportional knob sizing
244        let (position, size) = scrollbar.calculate_knob_metrics(100, 50, 0.0, 20);
245        assert_eq!(size, 10); // 20 * (50/100) = 10
246        assert_eq!(position, 0); // 0% scroll = position 0
247
248        // Test knob position at 50% scroll
249        let (position, _) = scrollbar.calculate_knob_metrics(100, 50, 50.0, 20);
250        assert_eq!(position, 5); // 50% of available track (10)
251    }
252
253    #[test]
254    fn test_click_position_conversion() {
255        let scrollbar = VerticalScrollbar::new("test".to_string());
256        let bounds = Bounds::new(10, 10, 50, 30);
257
258        // Click at top of track should be 0%
259        let scroll_pct = scrollbar.click_position_to_scroll_percentage(11, &bounds);
260        assert!((scroll_pct - 0.0).abs() < 0.01);
261
262        // Click at bottom of track should be close to 100%
263        let scroll_pct = scrollbar.click_position_to_scroll_percentage(28, &bounds);
264        assert!((scroll_pct - 94.44).abs() < 1.0); // Bottom edge of track (now at position 28)
265
266        // Click in middle should be ~50%
267        let scroll_pct = scrollbar.click_position_to_scroll_percentage(20, &bounds);
268        assert!((scroll_pct - 50.0).abs() < 1.0); // Approximately 50%
269    }
270}