Skip to main content

presentar_terminal/widgets/
scrollbar.rs

1//! Scrollbar widget with position indicator and arrow buttons.
2//!
3//! Provides vertical and horizontal scrollbars for scrollable content.
4//! Based on btop scrollbar patterns.
5
6use presentar_core::{
7    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
8    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
9};
10use std::any::Any;
11use std::time::Duration;
12
13/// Scrollbar orientation.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum ScrollOrientation {
16    /// Vertical scrollbar (default).
17    #[default]
18    Vertical,
19    /// Horizontal scrollbar.
20    Horizontal,
21}
22
23/// Characters used for scrollbar rendering.
24#[derive(Debug, Clone)]
25pub struct ScrollbarChars {
26    /// Track/background character.
27    pub track: char,
28    /// Thumb/position indicator.
29    pub thumb: char,
30    /// Up arrow (vertical) or left arrow (horizontal).
31    pub arrow_start: char,
32    /// Down arrow (vertical) or right arrow (horizontal).
33    pub arrow_end: char,
34}
35
36impl Default for ScrollbarChars {
37    fn default() -> Self {
38        Self::unicode()
39    }
40}
41
42impl ScrollbarChars {
43    /// Unicode box-drawing characters.
44    #[must_use]
45    pub fn unicode() -> Self {
46        Self {
47            track: '░',
48            thumb: '█',
49            arrow_start: '▲',
50            arrow_end: '▼',
51        }
52    }
53
54    /// Unicode horizontal variant.
55    #[must_use]
56    pub fn unicode_horizontal() -> Self {
57        Self {
58            track: '░',
59            thumb: '█',
60            arrow_start: '◀',
61            arrow_end: '▶',
62        }
63    }
64
65    /// ASCII-only characters.
66    #[must_use]
67    pub fn ascii() -> Self {
68        Self {
69            track: '-',
70            thumb: '#',
71            arrow_start: '^',
72            arrow_end: 'v',
73        }
74    }
75
76    /// Minimal style (no arrows).
77    #[must_use]
78    pub fn minimal() -> Self {
79        Self {
80            track: '│',
81            thumb: '┃',
82            arrow_start: '│',
83            arrow_end: '│',
84        }
85    }
86}
87
88/// Scrollbar widget with position indicator and optional arrow buttons.
89#[derive(Debug, Clone)]
90pub struct Scrollbar {
91    /// Scrollbar orientation.
92    orientation: ScrollOrientation,
93    /// Total content length (items or lines).
94    content_length: usize,
95    /// Visible viewport length.
96    viewport_length: usize,
97    /// Current scroll offset (0-based).
98    offset: usize,
99    /// Show arrow buttons at ends.
100    show_arrows: bool,
101    /// Characters for rendering.
102    chars: ScrollbarChars,
103    /// Track color.
104    track_color: Color,
105    /// Thumb color.
106    thumb_color: Color,
107    /// Arrow color.
108    arrow_color: Color,
109    /// Cached bounds.
110    bounds: Rect,
111}
112
113impl Default for Scrollbar {
114    fn default() -> Self {
115        Self::vertical(100, 10)
116    }
117}
118
119impl Scrollbar {
120    /// Create a vertical scrollbar.
121    #[must_use]
122    pub fn vertical(content_length: usize, viewport_length: usize) -> Self {
123        Self {
124            orientation: ScrollOrientation::Vertical,
125            content_length,
126            viewport_length,
127            offset: 0,
128            show_arrows: true,
129            chars: ScrollbarChars::unicode(),
130            track_color: Color::new(0.3, 0.3, 0.3, 1.0),
131            thumb_color: Color::new(0.7, 0.7, 0.7, 1.0),
132            arrow_color: Color::new(0.5, 0.5, 0.5, 1.0),
133            bounds: Rect::default(),
134        }
135    }
136
137    /// Create a horizontal scrollbar.
138    #[must_use]
139    pub fn horizontal(content_length: usize, viewport_length: usize) -> Self {
140        Self {
141            orientation: ScrollOrientation::Horizontal,
142            content_length,
143            viewport_length,
144            offset: 0,
145            show_arrows: true,
146            chars: ScrollbarChars::unicode_horizontal(),
147            track_color: Color::new(0.3, 0.3, 0.3, 1.0),
148            thumb_color: Color::new(0.7, 0.7, 0.7, 1.0),
149            arrow_color: Color::new(0.5, 0.5, 0.5, 1.0),
150            bounds: Rect::default(),
151        }
152    }
153
154    /// Set whether to show arrow buttons.
155    #[must_use]
156    pub fn with_arrows(mut self, show: bool) -> Self {
157        self.show_arrows = show;
158        self
159    }
160
161    /// Set custom characters.
162    #[must_use]
163    pub fn with_chars(mut self, chars: ScrollbarChars) -> Self {
164        self.chars = chars;
165        self
166    }
167
168    /// Set track color.
169    #[must_use]
170    pub fn with_track_color(mut self, color: Color) -> Self {
171        self.track_color = color;
172        self
173    }
174
175    /// Set thumb color.
176    #[must_use]
177    pub fn with_thumb_color(mut self, color: Color) -> Self {
178        self.thumb_color = color;
179        self
180    }
181
182    /// Set arrow color.
183    #[must_use]
184    pub fn with_arrow_color(mut self, color: Color) -> Self {
185        self.arrow_color = color;
186        self
187    }
188
189    /// Get current offset.
190    #[must_use]
191    pub fn offset(&self) -> usize {
192        self.offset
193    }
194
195    /// Set scroll offset.
196    pub fn set_offset(&mut self, offset: usize) {
197        self.offset = offset.min(self.max_offset());
198    }
199
200    /// Get scroll position as fraction (0.0-1.0).
201    #[must_use]
202    pub fn position(&self) -> f64 {
203        let max = self.max_offset();
204        if max == 0 {
205            0.0
206        } else {
207            self.offset as f64 / max as f64
208        }
209    }
210
211    /// Get thumb size as fraction of viewport (0.0-1.0).
212    #[must_use]
213    pub fn thumb_size(&self) -> f64 {
214        if self.content_length == 0 {
215            1.0
216        } else {
217            (self.viewport_length as f64 / self.content_length as f64).min(1.0)
218        }
219    }
220
221    /// Get maximum scroll offset.
222    #[must_use]
223    pub fn max_offset(&self) -> usize {
224        self.content_length.saturating_sub(self.viewport_length)
225    }
226
227    /// Check if content is scrollable.
228    #[must_use]
229    pub fn is_scrollable(&self) -> bool {
230        self.content_length > self.viewport_length
231    }
232
233    /// Scroll by delta (positive = down/right, negative = up/left).
234    pub fn scroll(&mut self, delta: i32) {
235        if delta >= 0 {
236            self.offset = (self.offset + delta as usize).min(self.max_offset());
237        } else {
238            self.offset = self.offset.saturating_sub((-delta) as usize);
239        }
240    }
241
242    /// Scroll up/left by one unit.
243    pub fn scroll_start(&mut self) {
244        self.scroll(-1);
245    }
246
247    /// Scroll down/right by one unit.
248    pub fn scroll_end(&mut self) {
249        self.scroll(1);
250    }
251
252    /// Page up/left.
253    pub fn page_start(&mut self) {
254        let page = self.viewport_length.max(1);
255        self.offset = self.offset.saturating_sub(page);
256    }
257
258    /// Page down/right.
259    pub fn page_end(&mut self) {
260        let page = self.viewport_length.max(1);
261        self.offset = (self.offset + page).min(self.max_offset());
262    }
263
264    /// Jump to position (0.0-1.0).
265    pub fn jump_to(&mut self, position: f64) {
266        let pos = position.clamp(0.0, 1.0);
267        self.offset = (pos * self.max_offset() as f64).round() as usize;
268    }
269
270    /// Jump to top/left.
271    pub fn jump_start(&mut self) {
272        self.offset = 0;
273    }
274
275    /// Jump to bottom/right.
276    pub fn jump_end(&mut self) {
277        self.offset = self.max_offset();
278    }
279
280    /// Update content and viewport lengths.
281    pub fn update_lengths(&mut self, content_length: usize, viewport_length: usize) {
282        self.content_length = content_length;
283        self.viewport_length = viewport_length;
284        self.offset = self.offset.min(self.max_offset());
285    }
286
287    /// Get the orientation.
288    #[must_use]
289    pub fn orientation(&self) -> ScrollOrientation {
290        self.orientation
291    }
292
293    /// Get content length.
294    #[must_use]
295    pub fn content_length(&self) -> usize {
296        self.content_length
297    }
298
299    /// Get viewport length.
300    #[must_use]
301    pub fn viewport_length(&self) -> usize {
302        self.viewport_length
303    }
304}
305
306impl Brick for Scrollbar {
307    fn brick_name(&self) -> &'static str {
308        "scrollbar"
309    }
310
311    fn assertions(&self) -> &[BrickAssertion] {
312        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
313        ASSERTIONS
314    }
315
316    fn budget(&self) -> BrickBudget {
317        BrickBudget::uniform(8)
318    }
319
320    fn verify(&self) -> BrickVerification {
321        BrickVerification {
322            passed: self.assertions().to_vec(),
323            failed: vec![],
324            verification_time: Duration::from_micros(5),
325        }
326    }
327
328    fn to_html(&self) -> String {
329        String::new()
330    }
331
332    fn to_css(&self) -> String {
333        String::new()
334    }
335}
336
337impl Widget for Scrollbar {
338    fn type_id(&self) -> TypeId {
339        TypeId::of::<Self>()
340    }
341
342    fn measure(&self, constraints: Constraints) -> Size {
343        match self.orientation {
344            ScrollOrientation::Vertical => Size::new(1.0, constraints.max_height.clamp(3.0, 20.0)),
345            ScrollOrientation::Horizontal => Size::new(constraints.max_width.clamp(3.0, 20.0), 1.0),
346        }
347    }
348
349    fn layout(&mut self, bounds: Rect) -> LayoutResult {
350        self.bounds = bounds;
351        LayoutResult {
352            size: Size::new(bounds.width, bounds.height),
353        }
354    }
355
356    fn paint(&self, canvas: &mut dyn Canvas) {
357        let track_style = TextStyle {
358            color: self.track_color,
359            ..Default::default()
360        };
361        let thumb_style = TextStyle {
362            color: self.thumb_color,
363            ..Default::default()
364        };
365        let arrow_style = TextStyle {
366            color: self.arrow_color,
367            ..Default::default()
368        };
369
370        match self.orientation {
371            ScrollOrientation::Vertical => {
372                self.paint_vertical(canvas, &track_style, &thumb_style, &arrow_style);
373            }
374            ScrollOrientation::Horizontal => {
375                self.paint_horizontal(canvas, &track_style, &thumb_style, &arrow_style);
376            }
377        }
378    }
379
380    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
381        None
382    }
383
384    fn children(&self) -> &[Box<dyn Widget>] {
385        &[]
386    }
387
388    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
389        &mut []
390    }
391}
392
393impl Scrollbar {
394    fn paint_vertical(
395        &self,
396        canvas: &mut dyn Canvas,
397        track_style: &TextStyle,
398        thumb_style: &TextStyle,
399        arrow_style: &TextStyle,
400    ) {
401        let height = self.bounds.height as usize;
402        if height < 3 {
403            return;
404        }
405
406        let arrow_offset = usize::from(self.show_arrows);
407        let track_start = arrow_offset;
408        let track_end = height.saturating_sub(arrow_offset);
409        let track_len = track_end.saturating_sub(track_start);
410
411        if track_len == 0 {
412            return;
413        }
414
415        // Draw arrows
416        if self.show_arrows {
417            canvas.draw_text(
418                &self.chars.arrow_start.to_string(),
419                Point::new(self.bounds.x, self.bounds.y),
420                arrow_style,
421            );
422            canvas.draw_text(
423                &self.chars.arrow_end.to_string(),
424                Point::new(self.bounds.x, self.bounds.y + (height - 1) as f32),
425                arrow_style,
426            );
427        }
428
429        // Calculate thumb position and size
430        let thumb_size = ((self.thumb_size() * track_len as f64).round() as usize).max(1);
431        let thumb_pos = if self.is_scrollable() {
432            (self.position() * (track_len.saturating_sub(thumb_size)) as f64).round() as usize
433        } else {
434            0
435        };
436
437        // Draw track and thumb
438        for i in 0..track_len {
439            let y = track_start + i;
440            let in_thumb = i >= thumb_pos && i < thumb_pos + thumb_size;
441            let ch = if in_thumb {
442                self.chars.thumb
443            } else {
444                self.chars.track
445            };
446            let style = if in_thumb { thumb_style } else { track_style };
447            canvas.draw_text(
448                &ch.to_string(),
449                Point::new(self.bounds.x, self.bounds.y + y as f32),
450                style,
451            );
452        }
453    }
454
455    fn paint_horizontal(
456        &self,
457        canvas: &mut dyn Canvas,
458        track_style: &TextStyle,
459        thumb_style: &TextStyle,
460        arrow_style: &TextStyle,
461    ) {
462        let width = self.bounds.width as usize;
463        if width < 3 {
464            return;
465        }
466
467        let arrow_offset = usize::from(self.show_arrows);
468        let track_start = arrow_offset;
469        let track_end = width.saturating_sub(arrow_offset);
470        let track_len = track_end.saturating_sub(track_start);
471
472        if track_len == 0 {
473            return;
474        }
475
476        // Draw arrows
477        if self.show_arrows {
478            canvas.draw_text(
479                &self.chars.arrow_start.to_string(),
480                Point::new(self.bounds.x, self.bounds.y),
481                arrow_style,
482            );
483            canvas.draw_text(
484                &self.chars.arrow_end.to_string(),
485                Point::new(self.bounds.x + (width - 1) as f32, self.bounds.y),
486                arrow_style,
487            );
488        }
489
490        // Calculate thumb position and size
491        let thumb_size = ((self.thumb_size() * track_len as f64).round() as usize).max(1);
492        let thumb_pos = if self.is_scrollable() {
493            (self.position() * (track_len.saturating_sub(thumb_size)) as f64).round() as usize
494        } else {
495            0
496        };
497
498        // Draw track and thumb
499        for i in 0..track_len {
500            let x = track_start + i;
501            let in_thumb = i >= thumb_pos && i < thumb_pos + thumb_size;
502            let ch = if in_thumb {
503                self.chars.thumb
504            } else {
505                self.chars.track
506            };
507            let style = if in_thumb { thumb_style } else { track_style };
508            canvas.draw_text(
509                &ch.to_string(),
510                Point::new(self.bounds.x + x as f32, self.bounds.y),
511                style,
512            );
513        }
514    }
515}
516
517impl PartialEq for ScrollbarChars {
518    fn eq(&self, other: &Self) -> bool {
519        self.track == other.track
520            && self.thumb == other.thumb
521            && self.arrow_start == other.arrow_start
522            && self.arrow_end == other.arrow_end
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    struct MockCanvas {
531        texts: Vec<(String, Point)>,
532    }
533
534    impl MockCanvas {
535        fn new() -> Self {
536            Self { texts: vec![] }
537        }
538    }
539
540    impl Canvas for MockCanvas {
541        fn fill_rect(&mut self, _rect: Rect, _color: Color) {}
542        fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
543        fn draw_text(&mut self, text: &str, position: Point, _style: &TextStyle) {
544            self.texts.push((text.to_string(), position));
545        }
546        fn draw_line(&mut self, _from: Point, _to: Point, _color: Color, _width: f32) {}
547        fn fill_circle(&mut self, _center: Point, _radius: f32, _color: Color) {}
548        fn stroke_circle(&mut self, _center: Point, _radius: f32, _color: Color, _width: f32) {}
549        fn fill_arc(&mut self, _c: Point, _r: f32, _s: f32, _e: f32, _color: Color) {}
550        fn draw_path(&mut self, _points: &[Point], _color: Color, _width: f32) {}
551        fn fill_polygon(&mut self, _points: &[Point], _color: Color) {}
552        fn push_clip(&mut self, _rect: Rect) {}
553        fn pop_clip(&mut self) {}
554        fn push_transform(&mut self, _transform: presentar_core::Transform2D) {}
555        fn pop_transform(&mut self) {}
556    }
557
558    // =====================================================
559    // Construction Tests
560    // =====================================================
561
562    #[test]
563    fn test_vertical_scrollbar_creation() {
564        let sb = Scrollbar::vertical(100, 10);
565        assert_eq!(sb.orientation(), ScrollOrientation::Vertical);
566        assert_eq!(sb.content_length(), 100);
567        assert_eq!(sb.viewport_length(), 10);
568    }
569
570    #[test]
571    fn test_horizontal_scrollbar_creation() {
572        let sb = Scrollbar::horizontal(100, 20);
573        assert_eq!(sb.orientation(), ScrollOrientation::Horizontal);
574        assert_eq!(sb.content_length(), 100);
575        assert_eq!(sb.viewport_length(), 20);
576    }
577
578    #[test]
579    fn test_scrollbar_default() {
580        let sb = Scrollbar::default();
581        assert_eq!(sb.orientation(), ScrollOrientation::Vertical);
582        assert_eq!(sb.offset(), 0);
583    }
584
585    // =====================================================
586    // Builder Pattern Tests
587    // =====================================================
588
589    #[test]
590    fn test_with_arrows() {
591        let sb = Scrollbar::vertical(100, 10).with_arrows(false);
592        assert!(!sb.show_arrows);
593    }
594
595    #[test]
596    fn test_with_chars() {
597        let chars = ScrollbarChars::ascii();
598        let sb = Scrollbar::vertical(100, 10).with_chars(chars);
599        assert_eq!(sb.chars.track, '-');
600    }
601
602    #[test]
603    fn test_with_track_color() {
604        let sb = Scrollbar::vertical(100, 10).with_track_color(Color::RED);
605        assert_eq!(sb.track_color, Color::RED);
606    }
607
608    #[test]
609    fn test_with_thumb_color() {
610        let sb = Scrollbar::vertical(100, 10).with_thumb_color(Color::GREEN);
611        assert_eq!(sb.thumb_color, Color::GREEN);
612    }
613
614    #[test]
615    fn test_with_arrow_color() {
616        let sb = Scrollbar::vertical(100, 10).with_arrow_color(Color::BLUE);
617        assert_eq!(sb.arrow_color, Color::BLUE);
618    }
619
620    // =====================================================
621    // ScrollbarChars Tests
622    // =====================================================
623
624    #[test]
625    fn test_chars_unicode() {
626        let chars = ScrollbarChars::unicode();
627        assert_eq!(chars.track, '░');
628        assert_eq!(chars.thumb, '█');
629        assert_eq!(chars.arrow_start, '▲');
630        assert_eq!(chars.arrow_end, '▼');
631    }
632
633    #[test]
634    fn test_chars_unicode_horizontal() {
635        let chars = ScrollbarChars::unicode_horizontal();
636        assert_eq!(chars.arrow_start, '◀');
637        assert_eq!(chars.arrow_end, '▶');
638    }
639
640    #[test]
641    fn test_chars_ascii() {
642        let chars = ScrollbarChars::ascii();
643        assert_eq!(chars.track, '-');
644        assert_eq!(chars.thumb, '#');
645    }
646
647    #[test]
648    fn test_chars_minimal() {
649        let chars = ScrollbarChars::minimal();
650        assert_eq!(chars.track, '│');
651        assert_eq!(chars.thumb, '┃');
652    }
653
654    #[test]
655    fn test_chars_default() {
656        let chars = ScrollbarChars::default();
657        assert_eq!(chars, ScrollbarChars::unicode());
658    }
659
660    // =====================================================
661    // Offset/Position Tests
662    // =====================================================
663
664    #[test]
665    fn test_offset_initial() {
666        let sb = Scrollbar::vertical(100, 10);
667        assert_eq!(sb.offset(), 0);
668    }
669
670    #[test]
671    fn test_set_offset() {
672        let mut sb = Scrollbar::vertical(100, 10);
673        sb.set_offset(50);
674        assert_eq!(sb.offset(), 50);
675    }
676
677    #[test]
678    fn test_set_offset_clamps() {
679        let mut sb = Scrollbar::vertical(100, 10);
680        sb.set_offset(1000);
681        assert_eq!(sb.offset(), 90); // max_offset = 100 - 10
682    }
683
684    #[test]
685    fn test_position_zero() {
686        let sb = Scrollbar::vertical(100, 10);
687        assert!((sb.position() - 0.0).abs() < f64::EPSILON);
688    }
689
690    #[test]
691    fn test_position_mid() {
692        let mut sb = Scrollbar::vertical(100, 10);
693        sb.set_offset(45);
694        assert!((sb.position() - 0.5).abs() < 0.01);
695    }
696
697    #[test]
698    fn test_position_end() {
699        let mut sb = Scrollbar::vertical(100, 10);
700        sb.set_offset(90);
701        assert!((sb.position() - 1.0).abs() < f64::EPSILON);
702    }
703
704    #[test]
705    fn test_position_no_scroll() {
706        let sb = Scrollbar::vertical(10, 20);
707        assert!((sb.position() - 0.0).abs() < f64::EPSILON);
708    }
709
710    // =====================================================
711    // Thumb Size Tests
712    // =====================================================
713
714    #[test]
715    fn test_thumb_size_small_viewport() {
716        let sb = Scrollbar::vertical(100, 10);
717        assert!((sb.thumb_size() - 0.1).abs() < f64::EPSILON);
718    }
719
720    #[test]
721    fn test_thumb_size_large_viewport() {
722        let sb = Scrollbar::vertical(100, 50);
723        assert!((sb.thumb_size() - 0.5).abs() < f64::EPSILON);
724    }
725
726    #[test]
727    fn test_thumb_size_viewport_exceeds() {
728        let sb = Scrollbar::vertical(50, 100);
729        assert!((sb.thumb_size() - 1.0).abs() < f64::EPSILON);
730    }
731
732    #[test]
733    fn test_thumb_size_empty_content() {
734        let sb = Scrollbar::vertical(0, 10);
735        assert!((sb.thumb_size() - 1.0).abs() < f64::EPSILON);
736    }
737
738    // =====================================================
739    // Max Offset Tests
740    // =====================================================
741
742    #[test]
743    fn test_max_offset() {
744        let sb = Scrollbar::vertical(100, 10);
745        assert_eq!(sb.max_offset(), 90);
746    }
747
748    #[test]
749    fn test_max_offset_no_scroll() {
750        let sb = Scrollbar::vertical(10, 20);
751        assert_eq!(sb.max_offset(), 0);
752    }
753
754    #[test]
755    fn test_is_scrollable_true() {
756        let sb = Scrollbar::vertical(100, 10);
757        assert!(sb.is_scrollable());
758    }
759
760    #[test]
761    fn test_is_scrollable_false() {
762        let sb = Scrollbar::vertical(10, 20);
763        assert!(!sb.is_scrollable());
764    }
765
766    // =====================================================
767    // Scroll Methods Tests
768    // =====================================================
769
770    #[test]
771    fn test_scroll_positive() {
772        let mut sb = Scrollbar::vertical(100, 10);
773        sb.scroll(5);
774        assert_eq!(sb.offset(), 5);
775    }
776
777    #[test]
778    fn test_scroll_negative() {
779        let mut sb = Scrollbar::vertical(100, 10);
780        sb.set_offset(10);
781        sb.scroll(-3);
782        assert_eq!(sb.offset(), 7);
783    }
784
785    #[test]
786    fn test_scroll_clamps_max() {
787        let mut sb = Scrollbar::vertical(100, 10);
788        sb.scroll(1000);
789        assert_eq!(sb.offset(), 90);
790    }
791
792    #[test]
793    fn test_scroll_clamps_min() {
794        let mut sb = Scrollbar::vertical(100, 10);
795        sb.scroll(-100);
796        assert_eq!(sb.offset(), 0);
797    }
798
799    #[test]
800    fn test_scroll_start() {
801        let mut sb = Scrollbar::vertical(100, 10);
802        sb.set_offset(10);
803        sb.scroll_start();
804        assert_eq!(sb.offset(), 9);
805    }
806
807    #[test]
808    fn test_scroll_end() {
809        let mut sb = Scrollbar::vertical(100, 10);
810        sb.scroll_end();
811        assert_eq!(sb.offset(), 1);
812    }
813
814    #[test]
815    fn test_page_start() {
816        let mut sb = Scrollbar::vertical(100, 10);
817        sb.set_offset(50);
818        sb.page_start();
819        assert_eq!(sb.offset(), 40);
820    }
821
822    #[test]
823    fn test_page_end() {
824        let mut sb = Scrollbar::vertical(100, 10);
825        sb.page_end();
826        assert_eq!(sb.offset(), 10);
827    }
828
829    // =====================================================
830    // Jump Methods Tests
831    // =====================================================
832
833    #[test]
834    fn test_jump_to_mid() {
835        let mut sb = Scrollbar::vertical(100, 10);
836        sb.jump_to(0.5);
837        assert_eq!(sb.offset(), 45);
838    }
839
840    #[test]
841    fn test_jump_to_start() {
842        let mut sb = Scrollbar::vertical(100, 10);
843        sb.set_offset(50);
844        sb.jump_to(0.0);
845        assert_eq!(sb.offset(), 0);
846    }
847
848    #[test]
849    fn test_jump_to_end() {
850        let mut sb = Scrollbar::vertical(100, 10);
851        sb.jump_to(1.0);
852        assert_eq!(sb.offset(), 90);
853    }
854
855    #[test]
856    fn test_jump_to_clamps() {
857        let mut sb = Scrollbar::vertical(100, 10);
858        sb.jump_to(2.0);
859        assert_eq!(sb.offset(), 90);
860        sb.jump_to(-1.0);
861        assert_eq!(sb.offset(), 0);
862    }
863
864    #[test]
865    fn test_jump_start() {
866        let mut sb = Scrollbar::vertical(100, 10);
867        sb.set_offset(50);
868        sb.jump_start();
869        assert_eq!(sb.offset(), 0);
870    }
871
872    #[test]
873    fn test_jump_end() {
874        let mut sb = Scrollbar::vertical(100, 10);
875        sb.jump_end();
876        assert_eq!(sb.offset(), 90);
877    }
878
879    // =====================================================
880    // Update Lengths Tests
881    // =====================================================
882
883    #[test]
884    fn test_update_lengths() {
885        let mut sb = Scrollbar::vertical(100, 10);
886        sb.update_lengths(200, 20);
887        assert_eq!(sb.content_length(), 200);
888        assert_eq!(sb.viewport_length(), 20);
889    }
890
891    #[test]
892    fn test_update_lengths_clamps_offset() {
893        let mut sb = Scrollbar::vertical(100, 10);
894        sb.set_offset(80);
895        sb.update_lengths(50, 10);
896        assert_eq!(sb.offset(), 40); // new max_offset
897    }
898
899    // =====================================================
900    // Brick Trait Tests
901    // =====================================================
902
903    #[test]
904    fn test_brick_name() {
905        let sb = Scrollbar::vertical(100, 10);
906        assert_eq!(sb.brick_name(), "scrollbar");
907    }
908
909    #[test]
910    fn test_assertions_not_empty() {
911        let sb = Scrollbar::vertical(100, 10);
912        assert!(!sb.assertions().is_empty());
913    }
914
915    #[test]
916    fn test_budget() {
917        let sb = Scrollbar::vertical(100, 10);
918        let budget = sb.budget();
919        assert!(budget.paint_ms > 0);
920    }
921
922    #[test]
923    fn test_verify() {
924        let sb = Scrollbar::vertical(100, 10);
925        assert!(sb.verify().is_valid());
926    }
927
928    #[test]
929    fn test_to_html() {
930        let sb = Scrollbar::vertical(100, 10);
931        assert!(sb.to_html().is_empty());
932    }
933
934    #[test]
935    fn test_to_css() {
936        let sb = Scrollbar::vertical(100, 10);
937        assert!(sb.to_css().is_empty());
938    }
939
940    // =====================================================
941    // Widget Trait Tests
942    // =====================================================
943
944    #[test]
945    fn test_type_id() {
946        let sb = Scrollbar::vertical(100, 10);
947        assert_eq!(Widget::type_id(&sb), TypeId::of::<Scrollbar>());
948    }
949
950    #[test]
951    fn test_measure_vertical() {
952        let sb = Scrollbar::vertical(100, 10);
953        let size = sb.measure(Constraints::loose(Size::new(100.0, 50.0)));
954        assert_eq!(size.width, 1.0);
955        assert!(size.height >= 3.0);
956    }
957
958    #[test]
959    fn test_measure_horizontal() {
960        let sb = Scrollbar::horizontal(100, 10);
961        let size = sb.measure(Constraints::loose(Size::new(50.0, 100.0)));
962        assert_eq!(size.height, 1.0);
963        assert!(size.width >= 3.0);
964    }
965
966    #[test]
967    fn test_layout() {
968        let mut sb = Scrollbar::vertical(100, 10);
969        let bounds = Rect::new(5.0, 10.0, 1.0, 20.0);
970        let result = sb.layout(bounds);
971        assert_eq!(result.size.height, 20.0);
972        assert_eq!(sb.bounds, bounds);
973    }
974
975    #[test]
976    fn test_children() {
977        let sb = Scrollbar::vertical(100, 10);
978        assert!(sb.children().is_empty());
979    }
980
981    #[test]
982    fn test_children_mut() {
983        let mut sb = Scrollbar::vertical(100, 10);
984        assert!(sb.children_mut().is_empty());
985    }
986
987    #[test]
988    fn test_event() {
989        let mut sb = Scrollbar::vertical(100, 10);
990        let event = Event::key_down(presentar_core::Key::Enter);
991        assert!(sb.event(&event).is_none());
992    }
993
994    // =====================================================
995    // Paint Tests
996    // =====================================================
997
998    #[test]
999    fn test_paint_vertical() {
1000        let mut sb = Scrollbar::vertical(100, 10);
1001        sb.bounds = Rect::new(0.0, 0.0, 1.0, 10.0);
1002        let mut canvas = MockCanvas::new();
1003        sb.paint(&mut canvas);
1004        assert!(!canvas.texts.is_empty());
1005    }
1006
1007    #[test]
1008    fn test_paint_vertical_no_arrows() {
1009        let mut sb = Scrollbar::vertical(100, 10).with_arrows(false);
1010        sb.bounds = Rect::new(0.0, 0.0, 1.0, 10.0);
1011        let mut canvas = MockCanvas::new();
1012        sb.paint(&mut canvas);
1013        // Should not contain arrow characters
1014        let has_arrow = canvas.texts.iter().any(|(t, _)| t == "▲" || t == "▼");
1015        assert!(!has_arrow);
1016    }
1017
1018    #[test]
1019    fn test_paint_horizontal() {
1020        let mut sb = Scrollbar::horizontal(100, 10);
1021        sb.bounds = Rect::new(0.0, 0.0, 10.0, 1.0);
1022        let mut canvas = MockCanvas::new();
1023        sb.paint(&mut canvas);
1024        assert!(!canvas.texts.is_empty());
1025    }
1026
1027    #[test]
1028    fn test_paint_small_bounds() {
1029        let mut sb = Scrollbar::vertical(100, 10);
1030        sb.bounds = Rect::new(0.0, 0.0, 1.0, 2.0);
1031        let mut canvas = MockCanvas::new();
1032        sb.paint(&mut canvas);
1033        // Should handle small bounds gracefully
1034    }
1035
1036    #[test]
1037    fn test_paint_with_offset() {
1038        let mut sb = Scrollbar::vertical(100, 10);
1039        sb.set_offset(50);
1040        sb.bounds = Rect::new(0.0, 0.0, 1.0, 10.0);
1041        let mut canvas = MockCanvas::new();
1042        sb.paint(&mut canvas);
1043        assert!(!canvas.texts.is_empty());
1044    }
1045
1046    // =====================================================
1047    // Orientation Enum Tests
1048    // =====================================================
1049
1050    #[test]
1051    fn test_orientation_default() {
1052        assert_eq!(ScrollOrientation::default(), ScrollOrientation::Vertical);
1053    }
1054
1055    #[test]
1056    fn test_orientation_eq() {
1057        assert_eq!(ScrollOrientation::Vertical, ScrollOrientation::Vertical);
1058        assert_ne!(ScrollOrientation::Vertical, ScrollOrientation::Horizontal);
1059    }
1060}