Skip to main content

ftui_widgets/
scrollbar.rs

1#![forbid(unsafe_code)]
2
3//! Scrollbar widget.
4//!
5//! A widget to display a scrollbar.
6
7use crate::{StatefulWidget, Widget, draw_text_span};
8use ftui_core::geometry::Rect;
9use ftui_render::frame::{Frame, HitId, HitRegion};
10use ftui_style::Style;
11use ftui_text::display_width;
12
13/// Scrollbar orientation.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum ScrollbarOrientation {
16    /// Vertical scrollbar on the right side.
17    #[default]
18    VerticalRight,
19    /// Vertical scrollbar on the left side.
20    VerticalLeft,
21    /// Horizontal scrollbar on the bottom.
22    HorizontalBottom,
23    /// Horizontal scrollbar on the top.
24    HorizontalTop,
25}
26
27/// Hit data part for track (background).
28pub const SCROLLBAR_PART_TRACK: u64 = 0;
29/// Hit data part for thumb (draggable).
30pub const SCROLLBAR_PART_THUMB: u64 = 1;
31/// Hit data part for begin button (up/left).
32pub const SCROLLBAR_PART_BEGIN: u64 = 2;
33/// Hit data part for end button (down/right).
34pub const SCROLLBAR_PART_END: u64 = 3;
35
36/// A widget to display a scrollbar.
37#[derive(Debug, Clone, Default)]
38pub struct Scrollbar<'a> {
39    orientation: ScrollbarOrientation,
40    thumb_style: Style,
41    track_style: Style,
42    begin_symbol: Option<&'a str>,
43    end_symbol: Option<&'a str>,
44    track_symbol: Option<&'a str>,
45    thumb_symbol: Option<&'a str>,
46    hit_id: Option<HitId>,
47}
48
49impl<'a> Scrollbar<'a> {
50    /// Create a new scrollbar with the given orientation.
51    pub fn new(orientation: ScrollbarOrientation) -> Self {
52        Self {
53            orientation,
54            thumb_style: Style::default(),
55            track_style: Style::default(),
56            begin_symbol: None,
57            end_symbol: None,
58            track_symbol: None,
59            thumb_symbol: None,
60            hit_id: None,
61        }
62    }
63
64    /// Set the style for the thumb (draggable indicator).
65    pub fn thumb_style(mut self, style: Style) -> Self {
66        self.thumb_style = style;
67        self
68    }
69
70    /// Set the style for the track background.
71    pub fn track_style(mut self, style: Style) -> Self {
72        self.track_style = style;
73        self
74    }
75
76    /// Set custom symbols for track, thumb, begin, and end markers.
77    pub fn symbols(
78        mut self,
79        track: &'a str,
80        thumb: &'a str,
81        begin: Option<&'a str>,
82        end: Option<&'a str>,
83    ) -> Self {
84        self.track_symbol = Some(track);
85        self.thumb_symbol = Some(thumb);
86        self.begin_symbol = begin;
87        self.end_symbol = end;
88        self
89    }
90
91    /// Set a hit ID for mouse interaction.
92    pub fn hit_id(mut self, id: HitId) -> Self {
93        self.hit_id = Some(id);
94        self
95    }
96}
97
98/// Mutable state for a [`Scrollbar`] widget.
99#[derive(Debug, Clone, Default)]
100pub struct ScrollbarState {
101    /// Total number of scrollable content units.
102    pub content_length: usize,
103    /// Current scroll position within the content.
104    pub position: usize,
105    /// Number of content units visible in the viewport.
106    pub viewport_length: usize,
107}
108
109impl ScrollbarState {
110    /// Create a new scrollbar state with given content, position, and viewport sizes.
111    pub fn new(content_length: usize, position: usize, viewport_length: usize) -> Self {
112        Self {
113            content_length,
114            position,
115            viewport_length,
116        }
117    }
118}
119
120impl<'a> StatefulWidget for Scrollbar<'a> {
121    type State = ScrollbarState;
122
123    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
124        #[cfg(feature = "tracing")]
125        let _span = tracing::debug_span!(
126            "widget_render",
127            widget = "Scrollbar",
128            x = area.x,
129            y = area.y,
130            w = area.width,
131            h = area.height
132        )
133        .entered();
134
135        // Scrollbar is decorative — skip at EssentialOnly+
136        if !frame.buffer.degradation.render_decorative() {
137            return;
138        }
139
140        if area.is_empty() || state.content_length == 0 {
141            return;
142        }
143
144        let is_vertical = match self.orientation {
145            ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft => true,
146            ScrollbarOrientation::HorizontalBottom | ScrollbarOrientation::HorizontalTop => false,
147        };
148
149        let length = if is_vertical { area.height } else { area.width } as usize;
150        if length == 0 {
151            return;
152        }
153
154        // Calculate scrollbar layout
155        // Simplified logic: track is the full length
156        let track_len = length;
157
158        // Calculate thumb size and position
159        let viewport_ratio = state.viewport_length as f64 / state.content_length as f64;
160        let thumb_size = (track_len as f64 * viewport_ratio).max(1.0).round() as usize;
161        let thumb_size = thumb_size.min(track_len);
162
163        let max_pos = state.content_length.saturating_sub(state.viewport_length);
164        let pos_ratio = if max_pos == 0 {
165            0.0
166        } else {
167            state.position.min(max_pos) as f64 / max_pos as f64
168        };
169
170        let available_track = track_len.saturating_sub(thumb_size);
171        let thumb_offset = (available_track as f64 * pos_ratio).round() as usize;
172
173        // Symbols
174        let track_char = self
175            .track_symbol
176            .unwrap_or(if is_vertical { "│" } else { "─" });
177        let thumb_char = self.thumb_symbol.unwrap_or("█");
178        let begin_char = self
179            .begin_symbol
180            .unwrap_or(if is_vertical { "▲" } else { "◄" });
181        let end_char = self
182            .end_symbol
183            .unwrap_or(if is_vertical { "▼" } else { "►" });
184
185        // Draw
186        let mut next_draw_index = 0;
187        for i in 0..track_len {
188            if i < next_draw_index {
189                continue;
190            }
191
192            let is_thumb = i >= thumb_offset && i < thumb_offset + thumb_size;
193            let (symbol, part) = if is_thumb {
194                (thumb_char, SCROLLBAR_PART_THUMB)
195            } else if i == 0 && self.begin_symbol.is_some() {
196                (begin_char, SCROLLBAR_PART_BEGIN)
197            } else if i == track_len - 1 && self.end_symbol.is_some() {
198                (end_char, SCROLLBAR_PART_END)
199            } else {
200                (track_char, SCROLLBAR_PART_TRACK)
201            };
202
203            let symbol_width = display_width(symbol);
204            if is_vertical {
205                next_draw_index = i + 1;
206            } else {
207                next_draw_index = i + symbol_width;
208            }
209
210            let style = if !frame.buffer.degradation.apply_styling() {
211                Style::default()
212            } else if is_thumb {
213                self.thumb_style
214            } else {
215                self.track_style
216            };
217
218            let (x, y) = if is_vertical {
219                let x = match self.orientation {
220                    // For VerticalRight, position so the symbol (including wide chars) fits in the area
221                    ScrollbarOrientation::VerticalRight => {
222                        area.right().saturating_sub(symbol_width.max(1) as u16)
223                    }
224                    ScrollbarOrientation::VerticalLeft => area.left(),
225                    _ => unreachable!(),
226                };
227                (x, area.top().saturating_add(i as u16))
228            } else {
229                let y = match self.orientation {
230                    ScrollbarOrientation::HorizontalBottom => area.bottom().saturating_sub(1),
231                    ScrollbarOrientation::HorizontalTop => area.top(),
232                    _ => unreachable!(),
233                };
234                (area.left().saturating_add(i as u16), y)
235            };
236
237            // Only draw if within bounds (redundant check but safe)
238            if x < area.right() && y < area.bottom() {
239                // Use draw_text_span to handle graphemes correctly.
240                // Pass max_x that accommodates the symbol width for wide characters.
241                draw_text_span(
242                    frame,
243                    x,
244                    y,
245                    symbol,
246                    style,
247                    x.saturating_add(symbol_width as u16),
248                );
249
250                if let Some(id) = self.hit_id {
251                    let data = (part << 56) | (i as u64);
252                    let hit_w = symbol_width.max(1) as u16;
253                    frame.register_hit(Rect::new(x, y, hit_w, 1), id, HitRegion::Scrollbar, data);
254                }
255            }
256        }
257    }
258}
259
260impl<'a> Widget for Scrollbar<'a> {
261    fn render(&self, area: Rect, frame: &mut Frame) {
262        let mut state = ScrollbarState::default();
263        StatefulWidget::render(self, area, frame, &mut state);
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use ftui_render::grapheme_pool::GraphemePool;
271
272    #[test]
273    fn scrollbar_empty_area() {
274        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
275        let area = Rect::new(0, 0, 0, 0);
276        let mut pool = GraphemePool::new();
277        let mut frame = Frame::new(1, 1, &mut pool);
278        let mut state = ScrollbarState::new(100, 0, 10);
279        StatefulWidget::render(&sb, area, &mut frame, &mut state);
280    }
281
282    #[test]
283    fn scrollbar_zero_content() {
284        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
285        let area = Rect::new(0, 0, 1, 10);
286        let mut pool = GraphemePool::new();
287        let mut frame = Frame::new(1, 10, &mut pool);
288        let mut state = ScrollbarState::new(0, 0, 10);
289        StatefulWidget::render(&sb, area, &mut frame, &mut state);
290        // Should not render anything when content_length is 0
291    }
292
293    #[test]
294    fn scrollbar_vertical_right_renders() {
295        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
296        let area = Rect::new(0, 0, 1, 10);
297        let mut pool = GraphemePool::new();
298        let mut frame = Frame::new(1, 10, &mut pool);
299        let mut state = ScrollbarState::new(100, 0, 10);
300        StatefulWidget::render(&sb, area, &mut frame, &mut state);
301
302        // Thumb should be at the top (position=0), track should have chars
303        let top_cell = frame.buffer.get(0, 0).unwrap();
304        assert!(top_cell.content.as_char().is_some());
305    }
306
307    #[test]
308    fn scrollbar_vertical_left_renders() {
309        let sb = Scrollbar::new(ScrollbarOrientation::VerticalLeft);
310        let area = Rect::new(0, 0, 1, 10);
311        let mut pool = GraphemePool::new();
312        let mut frame = Frame::new(1, 10, &mut pool);
313        let mut state = ScrollbarState::new(100, 0, 10);
314        StatefulWidget::render(&sb, area, &mut frame, &mut state);
315
316        let top_cell = frame.buffer.get(0, 0).unwrap();
317        assert!(top_cell.content.as_char().is_some());
318    }
319
320    #[test]
321    fn scrollbar_horizontal_renders() {
322        let sb = Scrollbar::new(ScrollbarOrientation::HorizontalBottom);
323        let area = Rect::new(0, 0, 10, 1);
324        let mut pool = GraphemePool::new();
325        let mut frame = Frame::new(10, 1, &mut pool);
326        let mut state = ScrollbarState::new(100, 0, 10);
327        StatefulWidget::render(&sb, area, &mut frame, &mut state);
328
329        let left_cell = frame.buffer.get(0, 0).unwrap();
330        assert!(left_cell.content.as_char().is_some());
331    }
332
333    #[test]
334    fn scrollbar_thumb_moves_with_position() {
335        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
336        let area = Rect::new(0, 0, 1, 10);
337
338        // Position at start
339        let mut pool1 = GraphemePool::new();
340        let mut frame1 = Frame::new(1, 10, &mut pool1);
341        let mut state1 = ScrollbarState::new(100, 0, 10);
342        StatefulWidget::render(&sb, area, &mut frame1, &mut state1);
343
344        // Position at end
345        let mut pool2 = GraphemePool::new();
346        let mut frame2 = Frame::new(1, 10, &mut pool2);
347        let mut state2 = ScrollbarState::new(100, 90, 10);
348        StatefulWidget::render(&sb, area, &mut frame2, &mut state2);
349
350        // The thumb char (█) should be at different positions
351        let thumb_char = '█';
352        let thumb_pos_1 = (0..10u16)
353            .find(|&y| frame1.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
354        let thumb_pos_2 = (0..10u16)
355            .find(|&y| frame2.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
356
357        // At start, thumb should be near top; at end, near bottom
358        assert!(thumb_pos_1.unwrap_or(0) < thumb_pos_2.unwrap_or(0));
359    }
360
361    #[test]
362    fn scrollbar_state_constructor() {
363        let state = ScrollbarState::new(200, 50, 20);
364        assert_eq!(state.content_length, 200);
365        assert_eq!(state.position, 50);
366        assert_eq!(state.viewport_length, 20);
367    }
368
369    #[test]
370    fn scrollbar_content_fits_viewport() {
371        // When viewport >= content, thumb should fill the whole track
372        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
373        let area = Rect::new(0, 0, 1, 10);
374        let mut pool = GraphemePool::new();
375        let mut frame = Frame::new(1, 10, &mut pool);
376        let mut state = ScrollbarState::new(5, 0, 10);
377        StatefulWidget::render(&sb, area, &mut frame, &mut state);
378
379        // All cells should be thumb (█)
380        let thumb_char = '█';
381        for y in 0..10u16 {
382            assert_eq!(
383                frame.buffer.get(0, y).unwrap().content.as_char(),
384                Some(thumb_char)
385            );
386        }
387    }
388
389    #[test]
390    fn scrollbar_horizontal_top_renders() {
391        let sb = Scrollbar::new(ScrollbarOrientation::HorizontalTop);
392        let area = Rect::new(0, 0, 10, 1);
393        let mut pool = GraphemePool::new();
394        let mut frame = Frame::new(10, 1, &mut pool);
395        let mut state = ScrollbarState::new(100, 0, 10);
396        StatefulWidget::render(&sb, area, &mut frame, &mut state);
397
398        let left_cell = frame.buffer.get(0, 0).unwrap();
399        assert!(left_cell.content.as_char().is_some());
400    }
401
402    #[test]
403    fn scrollbar_custom_symbols() {
404        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols(
405            ".",
406            "#",
407            Some("^"),
408            Some("v"),
409        );
410        let area = Rect::new(0, 0, 1, 5);
411        let mut pool = GraphemePool::new();
412        let mut frame = Frame::new(1, 5, &mut pool);
413        let mut state = ScrollbarState::new(50, 0, 10);
414        StatefulWidget::render(&sb, area, &mut frame, &mut state);
415
416        // Should use our custom symbols
417        let mut chars: Vec<Option<char>> = Vec::new();
418        for y in 0..5u16 {
419            chars.push(frame.buffer.get(0, y).unwrap().content.as_char());
420        }
421        // At least some cells should have our custom chars
422        assert!(chars.contains(&Some('#')) || chars.contains(&Some('.')));
423    }
424
425    #[test]
426    fn scrollbar_position_clamped_beyond_max() {
427        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
428        let area = Rect::new(0, 0, 1, 10);
429        let mut pool = GraphemePool::new();
430        let mut frame = Frame::new(1, 10, &mut pool);
431        // Position way beyond content_length
432        let mut state = ScrollbarState::new(100, 500, 10);
433        StatefulWidget::render(&sb, area, &mut frame, &mut state);
434
435        // Should still render without panic, thumb at bottom
436        let thumb_char = '█';
437        let thumb_pos = (0..10u16)
438            .find(|&y| frame.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
439        assert!(thumb_pos.is_some());
440    }
441
442    #[test]
443    fn scrollbar_state_default() {
444        let state = ScrollbarState::default();
445        assert_eq!(state.content_length, 0);
446        assert_eq!(state.position, 0);
447        assert_eq!(state.viewport_length, 0);
448    }
449
450    #[test]
451    fn scrollbar_widget_trait_renders() {
452        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
453        let area = Rect::new(0, 0, 1, 5);
454        let mut pool = GraphemePool::new();
455        let mut frame = Frame::new(1, 5, &mut pool);
456        // Widget trait uses default state (content_length=0, so no rendering)
457        Widget::render(&sb, area, &mut frame);
458        // Should not panic with default state
459    }
460
461    #[test]
462    fn scrollbar_orientation_default_is_vertical_right() {
463        assert_eq!(
464            ScrollbarOrientation::default(),
465            ScrollbarOrientation::VerticalRight
466        );
467    }
468
469    // --- Degradation tests ---
470
471    #[test]
472    fn degradation_essential_only_skips_entirely() {
473        use ftui_render::budget::DegradationLevel;
474
475        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
476        let area = Rect::new(0, 0, 1, 10);
477        let mut pool = GraphemePool::new();
478        let mut frame = Frame::new(1, 10, &mut pool);
479        frame.buffer.degradation = DegradationLevel::EssentialOnly;
480        let mut state = ScrollbarState::new(100, 0, 10);
481        StatefulWidget::render(&sb, area, &mut frame, &mut state);
482
483        // Scrollbar is decorative, should be skipped at EssentialOnly
484        for y in 0..10u16 {
485            assert!(
486                frame.buffer.get(0, y).unwrap().is_empty(),
487                "cell at y={y} should be empty at EssentialOnly"
488            );
489        }
490    }
491
492    #[test]
493    fn degradation_skeleton_skips_entirely() {
494        use ftui_render::budget::DegradationLevel;
495
496        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
497        let area = Rect::new(0, 0, 1, 10);
498        let mut pool = GraphemePool::new();
499        let mut frame = Frame::new(1, 10, &mut pool);
500        frame.buffer.degradation = DegradationLevel::Skeleton;
501        let mut state = ScrollbarState::new(100, 0, 10);
502        StatefulWidget::render(&sb, area, &mut frame, &mut state);
503
504        for y in 0..10u16 {
505            assert!(
506                frame.buffer.get(0, y).unwrap().is_empty(),
507                "cell at y={y} should be empty at Skeleton"
508            );
509        }
510    }
511
512    #[test]
513    fn degradation_full_renders_scrollbar() {
514        use ftui_render::budget::DegradationLevel;
515
516        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
517        let area = Rect::new(0, 0, 1, 10);
518        let mut pool = GraphemePool::new();
519        let mut frame = Frame::new(1, 10, &mut pool);
520        frame.buffer.degradation = DegradationLevel::Full;
521        let mut state = ScrollbarState::new(100, 0, 10);
522        StatefulWidget::render(&sb, area, &mut frame, &mut state);
523
524        // Should render something (thumb or track)
525        let top_cell = frame.buffer.get(0, 0).unwrap();
526        assert!(top_cell.content.as_char().is_some());
527    }
528
529    #[test]
530    fn degradation_simple_borders_still_renders() {
531        use ftui_render::budget::DegradationLevel;
532
533        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
534        let area = Rect::new(0, 0, 1, 10);
535        let mut pool = GraphemePool::new();
536        let mut frame = Frame::new(1, 10, &mut pool);
537        frame.buffer.degradation = DegradationLevel::SimpleBorders;
538        let mut state = ScrollbarState::new(100, 0, 10);
539        StatefulWidget::render(&sb, area, &mut frame, &mut state);
540
541        // SimpleBorders still renders decorative content
542        let top_cell = frame.buffer.get(0, 0).unwrap();
543        assert!(top_cell.content.as_char().is_some());
544    }
545
546    #[test]
547    fn scrollbar_wide_symbols_horizontal() {
548        let sb =
549            Scrollbar::new(ScrollbarOrientation::HorizontalBottom).symbols("🔴", "👍", None, None);
550        // Area width 4. Expect "🔴🔴" (2 chars * 2 width = 4 cells)
551        let area = Rect::new(0, 0, 4, 1);
552        let mut pool = GraphemePool::new();
553        let mut frame = Frame::new(4, 1, &mut pool);
554        // Track only (thumb size 0 or pos 0?)
555        // Let's make thumb small/invisible or check track part.
556        // If content_length=10, viewport=10, thumb fills all.
557        // Let's fill with thumb "👍"
558        let mut state = ScrollbarState::new(10, 0, 10);
559
560        StatefulWidget::render(&sb, area, &mut frame, &mut state);
561
562        // x=0: Head "👍" (wide emoji stored as grapheme, not direct char)
563        let c0 = frame.buffer.get(0, 0).unwrap();
564        assert!(!c0.is_empty() && !c0.is_continuation()); // Head
565        // x=1: Continuation
566        let c1 = frame.buffer.get(1, 0).unwrap();
567        assert!(c1.is_continuation());
568
569        // x=2: Head "👍"
570        let c2 = frame.buffer.get(2, 0).unwrap();
571        assert!(!c2.is_empty() && !c2.is_continuation()); // Head
572        // x=3: Continuation
573        let c3 = frame.buffer.get(3, 0).unwrap();
574        assert!(c3.is_continuation());
575    }
576
577    #[test]
578    fn scrollbar_wide_symbols_vertical() {
579        let sb =
580            Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols("🔴", "👍", None, None);
581        // Area height 2. Width 2 (to fit the wide char).
582        let area = Rect::new(0, 0, 2, 2);
583        let mut pool = GraphemePool::new();
584        let mut frame = Frame::new(2, 2, &mut pool);
585        let mut state = ScrollbarState::new(10, 0, 10); // Fill with thumb
586
587        StatefulWidget::render(&sb, area, &mut frame, &mut state);
588
589        // Row 0: "👍" at x=0 (wide emoji stored as grapheme, not direct char)
590        let r0_c0 = frame.buffer.get(0, 0).unwrap();
591        assert!(!r0_c0.is_empty() && !r0_c0.is_continuation()); // Head
592        let r0_c1 = frame.buffer.get(1, 0).unwrap();
593        assert!(r0_c1.is_continuation()); // Tail
594
595        // Row 1: "👍" at x=0 (should NOT be skipped)
596        let r1_c0 = frame.buffer.get(0, 1).unwrap();
597        assert!(!r1_c0.is_empty() && !r1_c0.is_continuation()); // Head
598        let r1_c1 = frame.buffer.get(1, 1).unwrap();
599        assert!(r1_c1.is_continuation()); // Tail
600    }
601}