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::mouse::MouseResult;
8use crate::{StatefulWidget, Widget, draw_text_span};
9use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
10use ftui_core::geometry::Rect;
11use ftui_render::frame::{Frame, HitId, HitRegion};
12use ftui_style::Style;
13use ftui_text::display_width;
14
15/// Scrollbar orientation.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum ScrollbarOrientation {
18    /// Vertical scrollbar on the right side.
19    #[default]
20    VerticalRight,
21    /// Vertical scrollbar on the left side.
22    VerticalLeft,
23    /// Horizontal scrollbar on the bottom.
24    HorizontalBottom,
25    /// Horizontal scrollbar on the top.
26    HorizontalTop,
27}
28
29/// Hit data part for track (background).
30pub const SCROLLBAR_PART_TRACK: u64 = 0;
31/// Hit data part for thumb (draggable).
32pub const SCROLLBAR_PART_THUMB: u64 = 1;
33/// Hit data part for begin button (up/left).
34pub const SCROLLBAR_PART_BEGIN: u64 = 2;
35/// Hit data part for end button (down/right).
36pub const SCROLLBAR_PART_END: u64 = 3;
37
38/// A widget to display a scrollbar.
39#[derive(Debug, Clone, Default)]
40pub struct Scrollbar<'a> {
41    orientation: ScrollbarOrientation,
42    thumb_style: Style,
43    track_style: Style,
44    begin_symbol: Option<&'a str>,
45    end_symbol: Option<&'a str>,
46    track_symbol: Option<&'a str>,
47    thumb_symbol: Option<&'a str>,
48    hit_id: Option<HitId>,
49}
50
51impl<'a> Scrollbar<'a> {
52    /// Create a new scrollbar with the given orientation.
53    #[must_use]
54    pub fn new(orientation: ScrollbarOrientation) -> Self {
55        Self {
56            orientation,
57            thumb_style: Style::default(),
58            track_style: Style::default(),
59            begin_symbol: None,
60            end_symbol: None,
61            track_symbol: None,
62            thumb_symbol: None,
63            hit_id: None,
64        }
65    }
66
67    /// Set the style for the thumb (draggable indicator).
68    #[must_use]
69    pub fn thumb_style(mut self, style: Style) -> Self {
70        self.thumb_style = style;
71        self
72    }
73
74    /// Set the style for the track background.
75    #[must_use]
76    pub fn track_style(mut self, style: Style) -> Self {
77        self.track_style = style;
78        self
79    }
80
81    /// Set custom symbols for track, thumb, begin, and end markers.
82    #[must_use]
83    pub fn symbols(
84        mut self,
85        track: &'a str,
86        thumb: &'a str,
87        begin: Option<&'a str>,
88        end: Option<&'a str>,
89    ) -> Self {
90        self.track_symbol = Some(track);
91        self.thumb_symbol = Some(thumb);
92        self.begin_symbol = begin;
93        self.end_symbol = end;
94        self
95    }
96
97    /// Set a hit ID for mouse interaction.
98    #[must_use]
99    pub fn hit_id(mut self, id: HitId) -> Self {
100        self.hit_id = Some(id);
101        self
102    }
103}
104
105/// Mutable state for a [`Scrollbar`] widget.
106#[derive(Debug, Clone, Default)]
107pub struct ScrollbarState {
108    /// Total number of scrollable content units.
109    pub content_length: usize,
110    /// Current scroll position within the content.
111    pub position: usize,
112    /// Number of content units visible in the viewport.
113    pub viewport_length: usize,
114    /// Drag anchor point (offset from thumb top) to prevent jumping.
115    pub drag_anchor: Option<usize>,
116}
117
118impl ScrollbarState {
119    /// Create a new scrollbar state with given content, position, and viewport sizes.
120    #[must_use]
121    pub fn new(content_length: usize, position: usize, viewport_length: usize) -> Self {
122        Self {
123            content_length,
124            position,
125            viewport_length,
126            drag_anchor: None,
127        }
128    }
129
130    /// Calculate thumb offset and size for a given track length.
131    fn calc_thumb_geometry(&self, track_len: usize) -> (usize, usize) {
132        if track_len == 0 {
133            return (0, 0);
134        }
135        if self.content_length == 0 {
136            return (0, track_len);
137        }
138
139        let viewport_ratio = self.viewport_length as f64 / self.content_length as f64;
140        let thumb_size = (track_len as f64 * viewport_ratio).max(1.0).round() as usize;
141        let thumb_size = thumb_size.min(track_len);
142
143        let max_pos = self.content_length.saturating_sub(self.viewport_length);
144        let pos_ratio = if max_pos == 0 {
145            0.0
146        } else {
147            self.position.min(max_pos) as f64 / max_pos as f64
148        };
149
150        let available_track = track_len.saturating_sub(thumb_size);
151        let thumb_offset = (available_track as f64 * pos_ratio).round() as usize;
152
153        (thumb_offset, thumb_size)
154    }
155
156    /// Handle a mouse event for this scrollbar.
157    ///
158    /// # Hit data convention
159    ///
160    /// The hit data (`u64`) is encoded as:
161    /// `[8: part] [28: track_len] [28: track_pos]`
162    ///
163    /// - `part`: One of `SCROLLBAR_PART_*`.
164    /// - `track_len`: The effective length of the track (excluding buttons).
165    /// - `track_pos`: The position within the effective track (0-based).
166    ///
167    /// # Arguments
168    ///
169    /// * `event` — the mouse event from the terminal
170    /// * `hit` — result of `frame.hit_test(event.x, event.y)`, if available
171    /// * `expected_id` — the `HitId` this scrollbar was rendered with
172    pub fn handle_mouse(
173        &mut self,
174        event: &MouseEvent,
175        hit: Option<(HitId, HitRegion, u64)>,
176        expected_id: HitId,
177    ) -> MouseResult {
178        match event.kind {
179            MouseEventKind::Down(MouseButton::Left) => {
180                if let Some((id, HitRegion::Scrollbar, data)) = hit
181                    && id == expected_id
182                {
183                    let part = data >> 56;
184                    match part {
185                        SCROLLBAR_PART_BEGIN => {
186                            self.scroll_up(1);
187                            return MouseResult::Scrolled;
188                        }
189                        SCROLLBAR_PART_END => {
190                            self.scroll_down(1);
191                            return MouseResult::Scrolled;
192                        }
193                        SCROLLBAR_PART_THUMB => {
194                            let track_len = ((data >> 28) & 0x0FFF_FFFF) as usize;
195                            let track_pos = (data & 0x0FFF_FFFF) as usize;
196                            let (thumb_offset, _) = self.calc_thumb_geometry(track_len);
197                            // Store where we grabbed the thumb (relative to thumb top)
198                            self.drag_anchor = Some(track_pos.saturating_sub(thumb_offset));
199                            // No scroll on initial grab, just setup
200                            return MouseResult::Ignored;
201                        }
202                        SCROLLBAR_PART_TRACK => {
203                            let track_len = ((data >> 28) & 0x0FFF_FFFF) as usize;
204                            let track_pos = (data & 0x0FFF_FFFF) as usize;
205                            if track_len == 0 {
206                                return MouseResult::Ignored;
207                            }
208
209                            // Map click position directly onto content position.
210                            let max_pos = self.content_length.saturating_sub(self.viewport_length);
211                            let denom = track_len.saturating_sub(1).max(1);
212                            let clamped_pos = track_pos.min(denom);
213                            self.position = if max_pos == 0 {
214                                0
215                            } else {
216                                let num = (clamped_pos as u128) * (max_pos as u128);
217                                let pos = (num + (denom as u128 / 2)) / denom as u128;
218                                pos as usize
219                            };
220                            return MouseResult::Scrolled;
221                        }
222                        _ => {}
223                    }
224                }
225                MouseResult::Ignored
226            }
227            MouseEventKind::Drag(MouseButton::Left) => {
228                if let Some((id, HitRegion::Scrollbar, data)) = hit
229                    && id == expected_id
230                {
231                    let part = data >> 56;
232                    if matches!(part, SCROLLBAR_PART_TRACK | SCROLLBAR_PART_THUMB) {
233                        let track_len = ((data >> 28) & 0x0FFF_FFFF) as usize;
234                        let track_pos = (data & 0x0FFF_FFFF) as usize;
235
236                        if track_len == 0 {
237                            return MouseResult::Ignored;
238                        }
239
240                        let (_, thumb_size) = self.calc_thumb_geometry(track_len);
241                        let available = track_len.saturating_sub(thumb_size);
242                        let denom = available.max(1);
243
244                        // If we have an anchor, align that point on thumb to mouse.
245                        // Otherwise (drag started outside thumb?), center it or use raw pos.
246                        let anchor = self.drag_anchor.unwrap_or(thumb_size / 2);
247
248                        // Desired top of thumb
249                        let target_thumb_top = track_pos.saturating_sub(anchor);
250                        let clamped_top = target_thumb_top.min(denom);
251
252                        let max_pos = self.content_length.saturating_sub(self.viewport_length);
253                        self.position = if max_pos == 0 {
254                            0
255                        } else {
256                            // Map thumb top range [0, available] to content range [0, max_pos]
257                            let num = (clamped_top as u128) * (max_pos as u128);
258                            // Round to nearest
259                            let pos = (num + (denom as u128 / 2)) / denom as u128;
260                            pos as usize
261                        };
262                        return MouseResult::Scrolled;
263                    }
264                }
265                MouseResult::Ignored
266            }
267            MouseEventKind::Up(MouseButton::Left) => {
268                self.drag_anchor = None;
269                MouseResult::Ignored
270            }
271            MouseEventKind::ScrollUp => {
272                self.scroll_up(3);
273                MouseResult::Scrolled
274            }
275            MouseEventKind::ScrollDown => {
276                self.scroll_down(3);
277                MouseResult::Scrolled
278            }
279            _ => MouseResult::Ignored,
280        }
281    }
282
283    /// Scroll the content up by the given number of lines.
284    pub fn scroll_up(&mut self, lines: usize) {
285        self.position = self.position.saturating_sub(lines);
286    }
287
288    /// Scroll the content down by the given number of lines.
289    ///
290    /// Clamps so that the viewport stays within content bounds.
291    pub fn scroll_down(&mut self, lines: usize) {
292        let max_pos = self.content_length.saturating_sub(self.viewport_length);
293        self.position = self.position.saturating_add(lines).min(max_pos);
294    }
295}
296
297impl<'a> StatefulWidget for Scrollbar<'a> {
298    type State = ScrollbarState;
299
300    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
301        #[cfg(feature = "tracing")]
302        let _span = tracing::debug_span!(
303            "widget_render",
304            widget = "Scrollbar",
305            x = area.x,
306            y = area.y,
307            w = area.width,
308            h = area.height
309        )
310        .entered();
311
312        // Scrollbar is decorative — skip at EssentialOnly+
313        if !frame.buffer.degradation.render_decorative() {
314            return;
315        }
316
317        if area.is_empty() || state.content_length == 0 {
318            return;
319        }
320
321        let is_vertical = match self.orientation {
322            ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft => true,
323            ScrollbarOrientation::HorizontalBottom | ScrollbarOrientation::HorizontalTop => false,
324        };
325
326        let length = if is_vertical { area.height } else { area.width } as usize;
327        if length == 0 {
328            return;
329        }
330
331        // Calculate layout
332        let start_offset = if self.begin_symbol.is_some() { 1 } else { 0 };
333        let end_offset = if self.end_symbol.is_some() { 1 } else { 0 };
334
335        // Effective track excludes buttons
336        let track_len = length.saturating_sub(start_offset + end_offset);
337
338        // Calculate thumb size and position within the effective track
339        let (thumb_offset, thumb_size) = state.calc_thumb_geometry(track_len);
340
341        // Symbols
342        let track_char = self
343            .track_symbol
344            .unwrap_or(if is_vertical { "│" } else { "─" });
345        let thumb_char = self.thumb_symbol.unwrap_or("█");
346        let begin_char = self
347            .begin_symbol
348            .unwrap_or(if is_vertical { "▲" } else { "◄" });
349        let end_char = self
350            .end_symbol
351            .unwrap_or(if is_vertical { "▼" } else { "►" });
352
353        // Draw
354        let mut next_draw_index = 0;
355        for i in 0..length {
356            if i < next_draw_index {
357                continue;
358            }
359
360            // Determine part type and relative position
361            let (symbol, part, rel_pos) = if i < start_offset {
362                (begin_char, SCROLLBAR_PART_BEGIN, 0)
363            } else if i >= length - end_offset {
364                (end_char, SCROLLBAR_PART_END, 0)
365            } else {
366                let track_idx = i - start_offset;
367                let is_thumb = track_idx >= thumb_offset && track_idx < thumb_offset + thumb_size;
368                if is_thumb {
369                    (thumb_char, SCROLLBAR_PART_THUMB, track_idx)
370                } else {
371                    (track_char, SCROLLBAR_PART_TRACK, track_idx)
372                }
373            };
374
375            let symbol_width = display_width(symbol);
376            if is_vertical {
377                next_draw_index = i + 1;
378            } else {
379                next_draw_index = i + symbol_width;
380            }
381
382            let style = if !frame.buffer.degradation.apply_styling() {
383                Style::default()
384            } else if part == SCROLLBAR_PART_THUMB {
385                self.thumb_style
386            } else {
387                self.track_style
388            };
389
390            let (x, y) = if is_vertical {
391                let x = match self.orientation {
392                    ScrollbarOrientation::VerticalRight => area
393                        .right()
394                        .saturating_sub(symbol_width.max(1) as u16)
395                        .max(area.left()),
396                    ScrollbarOrientation::VerticalLeft => area.left(),
397                    _ => unreachable!(),
398                };
399                (x, area.top().saturating_add(i as u16))
400            } else {
401                let y = match self.orientation {
402                    ScrollbarOrientation::HorizontalBottom => area.bottom().saturating_sub(1),
403                    ScrollbarOrientation::HorizontalTop => area.top(),
404                    _ => unreachable!(),
405                };
406                (area.left().saturating_add(i as u16), y)
407            };
408
409            // Only draw if within bounds
410            if x < area.right() && y < area.bottom() {
411                draw_text_span(frame, x, y, symbol, style, area.right());
412
413                if let Some(id) = self.hit_id {
414                    // Encode data: [8: part] [28: track_len] [28: track_pos]
415                    let data = (part << 56)
416                        | ((track_len as u64 & 0x0FFF_FFFF) << 28)
417                        | (rel_pos as u64 & 0x0FFF_FFFF);
418
419                    let hit_w = (symbol_width.max(1) as u16).min(area.right().saturating_sub(x));
420                    frame.register_hit(Rect::new(x, y, hit_w, 1), id, HitRegion::Scrollbar, data);
421                }
422            }
423        }
424    }
425}
426
427impl<'a> Widget for Scrollbar<'a> {
428    fn render(&self, area: Rect, frame: &mut Frame) {
429        let mut state = ScrollbarState::default();
430        StatefulWidget::render(self, area, frame, &mut state);
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use ftui_render::grapheme_pool::GraphemePool;
438
439    #[test]
440    fn scrollbar_empty_area() {
441        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
442        let area = Rect::new(0, 0, 0, 0);
443        let mut pool = GraphemePool::new();
444        let mut frame = Frame::new(1, 1, &mut pool);
445        let mut state = ScrollbarState::new(100, 0, 10);
446        StatefulWidget::render(&sb, area, &mut frame, &mut state);
447    }
448
449    #[test]
450    fn scrollbar_zero_content() {
451        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
452        let area = Rect::new(0, 0, 1, 10);
453        let mut pool = GraphemePool::new();
454        let mut frame = Frame::new(1, 10, &mut pool);
455        let mut state = ScrollbarState::new(0, 0, 10);
456        StatefulWidget::render(&sb, area, &mut frame, &mut state);
457        // Should not render anything when content_length is 0
458    }
459
460    #[test]
461    fn scrollbar_vertical_right_renders() {
462        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
463        let area = Rect::new(0, 0, 1, 10);
464        let mut pool = GraphemePool::new();
465        let mut frame = Frame::new(1, 10, &mut pool);
466        let mut state = ScrollbarState::new(100, 0, 10);
467        StatefulWidget::render(&sb, area, &mut frame, &mut state);
468
469        // Thumb should be at the top (position=0), track should have chars
470        let top_cell = frame.buffer.get(0, 0).unwrap();
471        assert!(top_cell.content.as_char().is_some());
472    }
473
474    #[test]
475    fn scrollbar_vertical_left_renders() {
476        let sb = Scrollbar::new(ScrollbarOrientation::VerticalLeft);
477        let area = Rect::new(0, 0, 1, 10);
478        let mut pool = GraphemePool::new();
479        let mut frame = Frame::new(1, 10, &mut pool);
480        let mut state = ScrollbarState::new(100, 0, 10);
481        StatefulWidget::render(&sb, area, &mut frame, &mut state);
482
483        let top_cell = frame.buffer.get(0, 0).unwrap();
484        assert!(top_cell.content.as_char().is_some());
485    }
486
487    #[test]
488    fn scrollbar_horizontal_renders() {
489        let sb = Scrollbar::new(ScrollbarOrientation::HorizontalBottom);
490        let area = Rect::new(0, 0, 10, 1);
491        let mut pool = GraphemePool::new();
492        let mut frame = Frame::new(10, 1, &mut pool);
493        let mut state = ScrollbarState::new(100, 0, 10);
494        StatefulWidget::render(&sb, area, &mut frame, &mut state);
495
496        let left_cell = frame.buffer.get(0, 0).unwrap();
497        assert!(left_cell.content.as_char().is_some());
498    }
499
500    #[test]
501    fn scrollbar_thumb_moves_with_position() {
502        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
503        let area = Rect::new(0, 0, 1, 10);
504
505        // Position at start
506        let mut pool1 = GraphemePool::new();
507        let mut frame1 = Frame::new(1, 10, &mut pool1);
508        let mut state1 = ScrollbarState::new(100, 0, 10);
509        StatefulWidget::render(&sb, area, &mut frame1, &mut state1);
510
511        // Position at end
512        let mut pool2 = GraphemePool::new();
513        let mut frame2 = Frame::new(1, 10, &mut pool2);
514        let mut state2 = ScrollbarState::new(100, 90, 10);
515        StatefulWidget::render(&sb, area, &mut frame2, &mut state2);
516
517        // The thumb char (█) should be at different positions
518        let thumb_char = '█';
519        let thumb_pos_1 = (0..10u16)
520            .find(|&y| frame1.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
521        let thumb_pos_2 = (0..10u16)
522            .find(|&y| frame2.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
523
524        // At start, thumb should be near top; at end, near bottom
525        assert!(thumb_pos_1.unwrap_or(0) < thumb_pos_2.unwrap_or(0));
526    }
527
528    #[test]
529    fn scrollbar_state_constructor() {
530        let state = ScrollbarState::new(200, 50, 20);
531        assert_eq!(state.content_length, 200);
532        assert_eq!(state.position, 50);
533        assert_eq!(state.viewport_length, 20);
534    }
535
536    #[test]
537    fn scrollbar_content_fits_viewport() {
538        // When viewport >= content, thumb should fill the whole track
539        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
540        let area = Rect::new(0, 0, 1, 10);
541        let mut pool = GraphemePool::new();
542        let mut frame = Frame::new(1, 10, &mut pool);
543        let mut state = ScrollbarState::new(5, 0, 10);
544        StatefulWidget::render(&sb, area, &mut frame, &mut state);
545
546        // All cells should be thumb (█)
547        let thumb_char = '█';
548        for y in 0..10u16 {
549            assert_eq!(
550                frame.buffer.get(0, y).unwrap().content.as_char(),
551                Some(thumb_char)
552            );
553        }
554    }
555
556    #[test]
557    fn scrollbar_horizontal_top_renders() {
558        let sb = Scrollbar::new(ScrollbarOrientation::HorizontalTop);
559        let area = Rect::new(0, 0, 10, 1);
560        let mut pool = GraphemePool::new();
561        let mut frame = Frame::new(10, 1, &mut pool);
562        let mut state = ScrollbarState::new(100, 0, 10);
563        StatefulWidget::render(&sb, area, &mut frame, &mut state);
564
565        let left_cell = frame.buffer.get(0, 0).unwrap();
566        assert!(left_cell.content.as_char().is_some());
567    }
568
569    #[test]
570    fn scrollbar_custom_symbols() {
571        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols(
572            ".",
573            "#",
574            Some("^"),
575            Some("v"),
576        );
577        let area = Rect::new(0, 0, 1, 5);
578        let mut pool = GraphemePool::new();
579        let mut frame = Frame::new(1, 5, &mut pool);
580        let mut state = ScrollbarState::new(50, 0, 10);
581        StatefulWidget::render(&sb, area, &mut frame, &mut state);
582
583        // Should use our custom symbols
584        let mut chars: Vec<Option<char>> = Vec::new();
585        for y in 0..5u16 {
586            chars.push(frame.buffer.get(0, y).unwrap().content.as_char());
587        }
588        // At least some cells should have our custom chars
589        assert!(chars.contains(&Some('#')) || chars.contains(&Some('.')));
590    }
591
592    #[test]
593    fn scrollbar_position_clamped_beyond_max() {
594        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
595        let area = Rect::new(0, 0, 1, 10);
596        let mut pool = GraphemePool::new();
597        let mut frame = Frame::new(1, 10, &mut pool);
598        // Position way beyond content_length
599        let mut state = ScrollbarState::new(100, 500, 10);
600        StatefulWidget::render(&sb, area, &mut frame, &mut state);
601
602        // Should still render without panic, thumb at bottom
603        let thumb_char = '█';
604        let thumb_pos = (0..10u16)
605            .find(|&y| frame.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
606        assert!(thumb_pos.is_some());
607    }
608
609    #[test]
610    fn scrollbar_state_default() {
611        let state = ScrollbarState::default();
612        assert_eq!(state.content_length, 0);
613        assert_eq!(state.position, 0);
614        assert_eq!(state.viewport_length, 0);
615    }
616
617    #[test]
618    fn scrollbar_widget_trait_renders() {
619        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
620        let area = Rect::new(0, 0, 1, 5);
621        let mut pool = GraphemePool::new();
622        let mut frame = Frame::new(1, 5, &mut pool);
623        // Widget trait uses default state (content_length=0, so no rendering)
624        Widget::render(&sb, area, &mut frame);
625        // Should not panic with default state
626    }
627
628    #[test]
629    fn scrollbar_orientation_default_is_vertical_right() {
630        assert_eq!(
631            ScrollbarOrientation::default(),
632            ScrollbarOrientation::VerticalRight
633        );
634    }
635
636    // --- Degradation tests ---
637
638    #[test]
639    fn degradation_essential_only_skips_entirely() {
640        use ftui_render::budget::DegradationLevel;
641
642        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
643        let area = Rect::new(0, 0, 1, 10);
644        let mut pool = GraphemePool::new();
645        let mut frame = Frame::new(1, 10, &mut pool);
646        frame.buffer.degradation = DegradationLevel::EssentialOnly;
647        let mut state = ScrollbarState::new(100, 0, 10);
648        StatefulWidget::render(&sb, area, &mut frame, &mut state);
649
650        // Scrollbar is decorative, should be skipped at EssentialOnly
651        for y in 0..10u16 {
652            assert!(
653                frame.buffer.get(0, y).unwrap().is_empty(),
654                "cell at y={y} should be empty at EssentialOnly"
655            );
656        }
657    }
658
659    #[test]
660    fn degradation_skeleton_skips_entirely() {
661        use ftui_render::budget::DegradationLevel;
662
663        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
664        let area = Rect::new(0, 0, 1, 10);
665        let mut pool = GraphemePool::new();
666        let mut frame = Frame::new(1, 10, &mut pool);
667        frame.buffer.degradation = DegradationLevel::Skeleton;
668        let mut state = ScrollbarState::new(100, 0, 10);
669        StatefulWidget::render(&sb, area, &mut frame, &mut state);
670
671        for y in 0..10u16 {
672            assert!(
673                frame.buffer.get(0, y).unwrap().is_empty(),
674                "cell at y={y} should be empty at Skeleton"
675            );
676        }
677    }
678
679    #[test]
680    fn degradation_full_renders_scrollbar() {
681        use ftui_render::budget::DegradationLevel;
682
683        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
684        let area = Rect::new(0, 0, 1, 10);
685        let mut pool = GraphemePool::new();
686        let mut frame = Frame::new(1, 10, &mut pool);
687        frame.buffer.degradation = DegradationLevel::Full;
688        let mut state = ScrollbarState::new(100, 0, 10);
689        StatefulWidget::render(&sb, area, &mut frame, &mut state);
690
691        // Should render something (thumb or track)
692        let top_cell = frame.buffer.get(0, 0).unwrap();
693        assert!(top_cell.content.as_char().is_some());
694    }
695
696    #[test]
697    fn degradation_simple_borders_still_renders() {
698        use ftui_render::budget::DegradationLevel;
699
700        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
701        let area = Rect::new(0, 0, 1, 10);
702        let mut pool = GraphemePool::new();
703        let mut frame = Frame::new(1, 10, &mut pool);
704        frame.buffer.degradation = DegradationLevel::SimpleBorders;
705        let mut state = ScrollbarState::new(100, 0, 10);
706        StatefulWidget::render(&sb, area, &mut frame, &mut state);
707
708        // SimpleBorders still renders decorative content
709        let top_cell = frame.buffer.get(0, 0).unwrap();
710        assert!(top_cell.content.as_char().is_some());
711    }
712
713    #[test]
714    fn scrollbar_wide_symbols_horizontal() {
715        let sb =
716            Scrollbar::new(ScrollbarOrientation::HorizontalBottom).symbols("🔴", "👍", None, None);
717        // Area width 4. Expect "🔴🔴" (2 chars * 2 width = 4 cells)
718        let area = Rect::new(0, 0, 4, 1);
719        let mut pool = GraphemePool::new();
720        let mut frame = Frame::new(4, 1, &mut pool);
721        // Track only (thumb size 0 or pos 0?)
722        // Let's make thumb small/invisible or check track part.
723        // If content_length=10, viewport=10, thumb fills all.
724        // Let's fill with thumb "👍"
725        let mut state = ScrollbarState::new(10, 0, 10);
726
727        StatefulWidget::render(&sb, area, &mut frame, &mut state);
728
729        // x=0: Head "👍" (wide emoji stored as grapheme, not direct char)
730        let c0 = frame.buffer.get(0, 0).unwrap();
731        assert!(!c0.is_empty() && !c0.is_continuation()); // Head
732        // x=1: Continuation
733        let c1 = frame.buffer.get(1, 0).unwrap();
734        assert!(c1.is_continuation());
735
736        // x=2: Head "👍"
737        let c2 = frame.buffer.get(2, 0).unwrap();
738        assert!(!c2.is_empty() && !c2.is_continuation()); // Head
739        // x=3: Continuation
740        let c3 = frame.buffer.get(3, 0).unwrap();
741        assert!(c3.is_continuation());
742    }
743
744    #[test]
745    fn scrollbar_wide_symbols_vertical() {
746        let sb =
747            Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols("🔴", "👍", None, None);
748        // Area height 2. Width 2 (to fit the wide char).
749        let area = Rect::new(0, 0, 2, 2);
750        let mut pool = GraphemePool::new();
751        let mut frame = Frame::new(2, 2, &mut pool);
752        let mut state = ScrollbarState::new(10, 0, 10); // Fill with thumb
753
754        StatefulWidget::render(&sb, area, &mut frame, &mut state);
755
756        // Row 0: "👍" at x=0 (wide emoji stored as grapheme, not direct char)
757        let r0_c0 = frame.buffer.get(0, 0).unwrap();
758        assert!(!r0_c0.is_empty() && !r0_c0.is_continuation()); // Head
759        let r0_c1 = frame.buffer.get(1, 0).unwrap();
760        assert!(r0_c1.is_continuation()); // Tail
761
762        // Row 1: "👍" at x=0 (should NOT be skipped)
763        let r1_c0 = frame.buffer.get(0, 1).unwrap();
764        assert!(!r1_c0.is_empty() && !r1_c0.is_continuation()); // Head
765        let r1_c1 = frame.buffer.get(1, 1).unwrap();
766        assert!(r1_c1.is_continuation()); // Tail
767    }
768
769    #[test]
770    fn scrollbar_wide_symbol_clips_drawing_and_hits_to_area() {
771        // Regression: wide symbols must not draw/register hit cells outside the widget area.
772        let sb = Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
773            .symbols("🔴", "👍", None, None)
774            .hit_id(HitId::new(1));
775        let area = Rect::new(0, 0, 3, 1);
776        let mut pool = GraphemePool::new();
777        let mut frame = Frame::with_hit_grid(5, 1, &mut pool);
778        let mut state = ScrollbarState::new(3, 0, 3); // Thumb fills the track.
779
780        StatefulWidget::render(&sb, area, &mut frame, &mut state);
781
782        // x=3 is outside the widget area (area.right() == 3). It must remain untouched.
783        let outside = frame.buffer.get(3, 0).unwrap();
784        assert!(outside.is_empty(), "cell outside area should remain empty");
785        assert!(frame.hit_test(3, 0).is_none(), "no hit outside area");
786    }
787
788    #[test]
789    fn scrollbar_wide_symbol_vertical_clips_drawing_and_hits_to_area() {
790        let sb = Scrollbar::new(ScrollbarOrientation::VerticalLeft)
791            .symbols("🔴", "👍", None, None)
792            .hit_id(HitId::new(1));
793        let area = Rect::new(0, 0, 1, 2);
794        let mut pool = GraphemePool::new();
795        let mut frame = Frame::with_hit_grid(2, 2, &mut pool);
796        let mut state = ScrollbarState::new(10, 0, 10); // Thumb fills the track.
797
798        StatefulWidget::render(&sb, area, &mut frame, &mut state);
799
800        // x=1 is outside the widget area (area.right() == 1). It must remain untouched.
801        let outside = frame.buffer.get(1, 0).unwrap();
802        assert!(outside.is_empty(), "cell outside area should remain empty");
803        assert!(frame.hit_test(1, 0).is_none(), "no hit outside area");
804    }
805
806    #[test]
807    fn scrollbar_vertical_right_never_draws_left_of_area_for_wide_symbols() {
808        // Regression: when the area is narrower than the symbol, VerticalRight must not
809        // shift the draw position left of the widget area.
810        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight)
811            .symbols("🔴", "👍", None, None)
812            .hit_id(HitId::new(1));
813        let area = Rect::new(2, 0, 1, 2);
814        let mut pool = GraphemePool::new();
815        let mut frame = Frame::with_hit_grid(4, 2, &mut pool);
816        let mut state = ScrollbarState::new(10, 0, 10);
817
818        StatefulWidget::render(&sb, area, &mut frame, &mut state);
819
820        // x=1 is left of the widget area (area.left() == 2). It must remain untouched.
821        let outside = frame.buffer.get(1, 0).unwrap();
822        assert!(outside.is_empty(), "cell left of area should remain empty");
823        assert!(frame.hit_test(1, 0).is_none(), "no hit left of area");
824    }
825
826    // --- Mouse handling tests ---
827
828    use crate::mouse::MouseResult;
829    use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
830
831    #[test]
832    fn scrollbar_state_begin_button() {
833        let mut state = ScrollbarState::new(100, 10, 20);
834        let data = SCROLLBAR_PART_BEGIN << 56;
835        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
836        let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
837        let result = state.handle_mouse(&event, hit, HitId::new(1));
838        assert_eq!(result, MouseResult::Scrolled);
839        assert_eq!(state.position, 9);
840    }
841
842    #[test]
843    fn scrollbar_state_end_button() {
844        let mut state = ScrollbarState::new(100, 10, 20);
845        let data = SCROLLBAR_PART_END << 56;
846        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
847        let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
848        let result = state.handle_mouse(&event, hit, HitId::new(1));
849        assert_eq!(result, MouseResult::Scrolled);
850        assert_eq!(state.position, 11);
851    }
852
853    #[test]
854    fn scrollbar_state_track_click() {
855        let mut state = ScrollbarState::new(100, 0, 20);
856        let track_len = 20u64;
857        let track_pos = 10u64;
858        let data = (SCROLLBAR_PART_TRACK << 56)
859            | ((track_len & 0x0FFF_FFFF) << 28)
860            | (track_pos & 0x0FFF_FFFF);
861        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
862        let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
863        let result = state.handle_mouse(&event, hit, HitId::new(1));
864        assert_eq!(result, MouseResult::Scrolled);
865        // Track click maps proportionally to content position.
866        assert_eq!(state.position, 42);
867    }
868
869    #[test]
870    fn scrollbar_state_track_click_clamps() {
871        let mut state = ScrollbarState::new(100, 0, 20);
872        let track_len = 20u64;
873        let track_pos = 95u64;
874        let data = (SCROLLBAR_PART_TRACK << 56)
875            | ((track_len & 0x0FFF_FFFF) << 28)
876            | (track_pos & 0x0FFF_FFFF);
877        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
878        let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
879        let result = state.handle_mouse(&event, hit, HitId::new(1));
880        assert_eq!(result, MouseResult::Scrolled);
881        assert_eq!(state.position, 80); // content_length - viewport_length
882    }
883
884    #[test]
885    fn scrollbar_state_thumb_drag_updates_position() {
886        let mut state = ScrollbarState::new(100, 0, 20);
887        let track_len = 20u64;
888        let track_pos = 19u64;
889        let data = (SCROLLBAR_PART_THUMB << 56)
890            | ((track_len & 0x0FFF_FFFF) << 28)
891            | (track_pos & 0x0FFF_FFFF);
892        let event = MouseEvent::new(MouseEventKind::Drag(MouseButton::Left), 0, 0);
893        let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
894        let result = state.handle_mouse(&event, hit, HitId::new(1));
895        assert_eq!(result, MouseResult::Scrolled);
896        assert_eq!(state.position, 80);
897    }
898
899    #[test]
900    fn scrollbar_state_scroll_wheel_up() {
901        let mut state = ScrollbarState::new(100, 10, 20);
902        let event = MouseEvent::new(MouseEventKind::ScrollUp, 0, 0);
903        let result = state.handle_mouse(&event, None, HitId::new(1));
904        assert_eq!(result, MouseResult::Scrolled);
905        assert_eq!(state.position, 7);
906    }
907
908    #[test]
909    fn scrollbar_state_scroll_wheel_down() {
910        let mut state = ScrollbarState::new(100, 10, 20);
911        let event = MouseEvent::new(MouseEventKind::ScrollDown, 0, 0);
912        let result = state.handle_mouse(&event, None, HitId::new(1));
913        assert_eq!(result, MouseResult::Scrolled);
914        assert_eq!(state.position, 13);
915    }
916
917    #[test]
918    fn scrollbar_state_scroll_down_clamps() {
919        let mut state = ScrollbarState::new(100, 78, 20);
920        state.scroll_down(5);
921        assert_eq!(state.position, 80); // max = 100 - 20
922    }
923
924    #[test]
925    fn scrollbar_state_scroll_up_clamps() {
926        let mut state = ScrollbarState::new(100, 2, 20);
927        state.scroll_up(5);
928        assert_eq!(state.position, 0);
929    }
930
931    #[test]
932    fn scrollbar_state_wrong_id_ignored() {
933        let mut state = ScrollbarState::new(100, 10, 20);
934        let data = SCROLLBAR_PART_BEGIN << 56;
935        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
936        let hit = Some((HitId::new(99), HitRegion::Scrollbar, data));
937        let result = state.handle_mouse(&event, hit, HitId::new(1));
938        assert_eq!(result, MouseResult::Ignored);
939        assert_eq!(state.position, 10);
940    }
941
942    #[test]
943    fn scrollbar_state_right_click_ignored() {
944        let mut state = ScrollbarState::new(100, 10, 20);
945        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Right), 0, 0);
946        let result = state.handle_mouse(&event, None, HitId::new(1));
947        assert_eq!(result, MouseResult::Ignored);
948    }
949}