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, clear_text_area, 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    /// Cache of the track geometry for hit-testing fallback during drag.
117    track_layout: Option<TrackLayout>,
118}
119
120#[derive(Debug, Clone, Copy)]
121struct TrackLayout {
122    rect: Rect,
123    is_vertical: bool,
124}
125
126impl ScrollbarState {
127    /// Create a new scrollbar state with given content, position, and viewport sizes.
128    #[must_use]
129    pub fn new(content_length: usize, position: usize, viewport_length: usize) -> Self {
130        Self {
131            content_length,
132            position,
133            viewport_length,
134            drag_anchor: None,
135            track_layout: None,
136        }
137    }
138
139    /// Calculate thumb offset and size for a given track length.
140    fn calc_thumb_geometry(&self, track_len: usize) -> (usize, usize) {
141        if track_len == 0 {
142            return (0, 0);
143        }
144        if self.content_length == 0 {
145            return (0, track_len);
146        }
147
148        let viewport_ratio = self.viewport_length as f64 / self.content_length as f64;
149        let thumb_size = (track_len as f64 * viewport_ratio).max(1.0).round() as usize;
150        let thumb_size = thumb_size.min(track_len);
151
152        let max_pos = self.content_length.saturating_sub(self.viewport_length);
153        let pos_ratio = if max_pos == 0 {
154            0.0
155        } else {
156            self.position.min(max_pos) as f64 / max_pos as f64
157        };
158
159        let available_track = track_len.saturating_sub(thumb_size);
160        let thumb_offset = (available_track as f64 * pos_ratio).round() as usize;
161
162        (thumb_offset, thumb_size)
163    }
164
165    /// Handle a mouse event for this scrollbar.
166    ///
167    /// # Hit data convention
168    ///
169    /// The hit data (`u64`) is encoded as:
170    /// `[8: part] [28: track_len] [28: track_pos]`
171    ///
172    /// - `part`: One of `SCROLLBAR_PART_*`.
173    /// - `track_len`: The effective length of the track (excluding buttons).
174    /// - `track_pos`: The position within the effective track (0-based).
175    ///
176    /// # Arguments
177    ///
178    /// * `event` — the mouse event from the terminal
179    /// * `hit` — result of `frame.hit_test(event.x, event.y)`, if available
180    /// * `expected_id` — the `HitId` this scrollbar was rendered with
181    pub fn handle_mouse(
182        &mut self,
183        event: &MouseEvent,
184        hit: Option<(HitId, HitRegion, u64)>,
185        expected_id: HitId,
186    ) -> MouseResult {
187        match event.kind {
188            MouseEventKind::Down(MouseButton::Left) => {
189                if let Some((id, HitRegion::Scrollbar, data)) = hit
190                    && id == expected_id
191                {
192                    let part = data >> 56;
193                    match part {
194                        SCROLLBAR_PART_BEGIN => {
195                            self.scroll_up(1);
196                            return MouseResult::Scrolled;
197                        }
198                        SCROLLBAR_PART_END => {
199                            self.scroll_down(1);
200                            return MouseResult::Scrolled;
201                        }
202                        SCROLLBAR_PART_THUMB => {
203                            let track_len = ((data >> 28) & 0x0FFF_FFFF) as usize;
204                            let track_pos = (data & 0x0FFF_FFFF) as usize;
205                            let (thumb_offset, _) = self.calc_thumb_geometry(track_len);
206                            // Store where we grabbed the thumb (relative to thumb top)
207                            self.drag_anchor = Some(track_pos.saturating_sub(thumb_offset));
208                            // Prevent event bubbling by returning Scrolled
209                            return MouseResult::Scrolled;
210                        }
211                        SCROLLBAR_PART_TRACK => {
212                            let track_len = ((data >> 28) & 0x0FFF_FFFF) as usize;
213                            let track_pos = (data & 0x0FFF_FFFF) as usize;
214                            if track_len == 0 {
215                                return MouseResult::Ignored;
216                            }
217
218                            // Unify track click with drag logic: center the thumb on the click.
219                            let (_, thumb_size) = self.calc_thumb_geometry(track_len);
220                            let available = track_len.saturating_sub(thumb_size);
221                            let denom = available.max(1);
222
223                            let target_thumb_top = track_pos.saturating_sub(thumb_size / 2);
224                            let clamped_top = target_thumb_top.min(denom);
225
226                            let max_pos = self.content_length.saturating_sub(self.viewport_length);
227                            self.position = if max_pos == 0 {
228                                0
229                            } else {
230                                let num = (clamped_top as u128) * (max_pos as u128);
231                                let pos = (num + (denom as u128 / 2)) / denom as u128;
232                                pos as usize
233                            };
234
235                            // Update drag anchor to match the new thumb position immediately,
236                            // preventing a jump if the user drags after clicking the track.
237                            let (thumb_offset, _) = self.calc_thumb_geometry(track_len);
238                            self.drag_anchor = Some(track_pos.saturating_sub(thumb_offset));
239
240                            return MouseResult::Scrolled;
241                        }
242                        _ => {}
243                    }
244                }
245                MouseResult::Ignored
246            }
247            MouseEventKind::Drag(MouseButton::Left) => {
248                // Determine track length and mouse position relative to track.
249                // 1. Try Hit data (accurate for wide chars, complex layouts).
250                // 2. Fallback to cached layout (if dragging outside component).
251                let hit_data = if let Some((id, HitRegion::Scrollbar, data)) = hit
252                    && id == expected_id
253                {
254                    let part = data >> 56;
255                    if matches!(part, SCROLLBAR_PART_TRACK | SCROLLBAR_PART_THUMB) {
256                        let len = ((data >> 28) & 0x0FFF_FFFF) as usize;
257                        let pos = (data & 0x0FFF_FFFF) as usize;
258                        Some((len, pos))
259                    } else {
260                        None
261                    }
262                } else {
263                    None
264                };
265
266                let (track_len, track_pos) = if let Some((len, pos)) = hit_data {
267                    (len, pos)
268                } else if self.drag_anchor.is_some()
269                    && let Some(layout) = self.track_layout
270                {
271                    // Fallback: calculate from mouse event relative to track rect
272                    let rel = if layout.is_vertical {
273                        event.y.saturating_sub(layout.rect.y)
274                    } else {
275                        event.x.saturating_sub(layout.rect.x)
276                    };
277                    let len = if layout.is_vertical {
278                        layout.rect.height as usize
279                    } else {
280                        layout.rect.width as usize
281                    };
282                    let pos = rel.min(len.saturating_sub(1) as u16) as usize;
283                    (len, pos)
284                } else {
285                    return MouseResult::Ignored;
286                };
287
288                if track_len == 0 {
289                    return MouseResult::Ignored;
290                }
291
292                let (_, thumb_size) = self.calc_thumb_geometry(track_len);
293                let available = track_len.saturating_sub(thumb_size);
294                let denom = available.max(1);
295
296                // If we have an anchor, align that point on thumb to mouse.
297                // Otherwise (drag started outside thumb?), center it or use raw pos.
298                let anchor = self.drag_anchor.unwrap_or(thumb_size / 2);
299
300                // Desired top of thumb
301                let target_thumb_top = track_pos.saturating_sub(anchor);
302                let clamped_top = target_thumb_top.min(denom);
303
304                let max_pos = self.content_length.saturating_sub(self.viewport_length);
305                self.position = if max_pos == 0 {
306                    0
307                } else {
308                    // Map thumb top range [0, available] to content range [0, max_pos]
309                    let num = (clamped_top as u128) * (max_pos as u128);
310                    // Round to nearest
311                    let pos = (num + (denom as u128 / 2)) / denom as u128;
312                    pos as usize
313                };
314                MouseResult::Scrolled
315            }
316            MouseEventKind::Up(MouseButton::Left) => {
317                let was_dragging = self.drag_anchor.take().is_some();
318                if was_dragging {
319                    MouseResult::Scrolled
320                } else {
321                    MouseResult::Ignored
322                }
323            }
324            MouseEventKind::ScrollUp => {
325                self.scroll_up(3);
326                MouseResult::Scrolled
327            }
328            MouseEventKind::ScrollDown => {
329                self.scroll_down(3);
330                MouseResult::Scrolled
331            }
332            _ => MouseResult::Ignored,
333        }
334    }
335
336    /// Scroll the content up by the given number of lines.
337    pub fn scroll_up(&mut self, lines: usize) {
338        self.position = self.position.saturating_sub(lines);
339    }
340
341    /// Scroll the content down by the given number of lines.
342    ///
343    /// Clamps so that the viewport stays within content bounds.
344    pub fn scroll_down(&mut self, lines: usize) {
345        let max_pos = self.content_length.saturating_sub(self.viewport_length);
346        self.position = self.position.saturating_add(lines).min(max_pos);
347    }
348}
349
350impl<'a> StatefulWidget for Scrollbar<'a> {
351    type State = ScrollbarState;
352
353    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
354        #[cfg(feature = "tracing")]
355        let _span = tracing::debug_span!(
356            "widget_render",
357            widget = "Scrollbar",
358            x = area.x,
359            y = area.y,
360            w = area.width,
361            h = area.height
362        )
363        .entered();
364
365        // Scrollbar is decorative — skip at EssentialOnly+
366        if !frame.buffer.degradation.render_decorative() {
367            state.track_layout = None;
368            clear_text_area(frame, area, Style::default());
369            return;
370        }
371
372        if area.is_empty() || state.content_length == 0 {
373            state.track_layout = None;
374            clear_text_area(frame, area, Style::default());
375            return;
376        }
377
378        let is_vertical = match self.orientation {
379            ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft => true,
380            ScrollbarOrientation::HorizontalBottom | ScrollbarOrientation::HorizontalTop => false,
381        };
382
383        let length = if is_vertical { area.height } else { area.width } as usize;
384        if length == 0 {
385            state.track_layout = None;
386            return;
387        }
388
389        // Calculate layout
390        let start_offset = if let Some(s) = self.begin_symbol {
391            if is_vertical { 1 } else { display_width(s) }
392        } else {
393            0
394        };
395        let end_offset = if let Some(s) = self.end_symbol {
396            if is_vertical { 1 } else { display_width(s) }
397        } else {
398            0
399        };
400
401        // Effective track excludes buttons
402        let track_len = length.saturating_sub(start_offset + end_offset);
403
404        // Calculate thumb size and position within the effective track
405        let (thumb_offset, thumb_size) = state.calc_thumb_geometry(track_len);
406
407        // Symbols
408        let track_char = self
409            .track_symbol
410            .unwrap_or(if is_vertical { "│" } else { "─" });
411        let thumb_char = self.thumb_symbol.unwrap_or("█");
412        let begin_char = self
413            .begin_symbol
414            .unwrap_or(if is_vertical { "▲" } else { "◄" });
415        let end_char = self
416            .end_symbol
417            .unwrap_or(if is_vertical { "▼" } else { "►" });
418
419        // Update track layout for mouse interaction fallback
420        let max_w = display_width(track_char)
421            .max(display_width(thumb_char))
422            .max(1);
423        let track_rect = if is_vertical {
424            let x = match self.orientation {
425                ScrollbarOrientation::VerticalRight => {
426                    area.right().saturating_sub(max_w as u16).max(area.left())
427                }
428                ScrollbarOrientation::VerticalLeft => area.left(),
429                _ => unreachable!(),
430            };
431            Rect::new(
432                x,
433                area.top().saturating_add(start_offset as u16),
434                max_w as u16,
435                track_len as u16,
436            )
437        } else {
438            let y = match self.orientation {
439                ScrollbarOrientation::HorizontalBottom => area.bottom().saturating_sub(1),
440                ScrollbarOrientation::HorizontalTop => area.top(),
441                _ => unreachable!(),
442            };
443            Rect::new(
444                area.left().saturating_add(start_offset as u16),
445                y,
446                track_len as u16,
447                1,
448            )
449        };
450        state.track_layout = Some(TrackLayout {
451            rect: track_rect,
452            is_vertical,
453        });
454
455        // Draw
456        let mut next_draw_index = 0;
457        for i in 0..length {
458            if i < next_draw_index {
459                continue;
460            }
461
462            // Determine part type and relative position
463            let (symbol, part, rel_pos) = if i < start_offset {
464                (begin_char, SCROLLBAR_PART_BEGIN, 0)
465            } else if i >= length.saturating_sub(end_offset) {
466                (end_char, SCROLLBAR_PART_END, 0)
467            } else {
468                let track_idx = i - start_offset;
469                let is_thumb = track_idx >= thumb_offset && track_idx < thumb_offset + thumb_size;
470                if is_thumb {
471                    (thumb_char, SCROLLBAR_PART_THUMB, track_idx)
472                } else {
473                    (track_char, SCROLLBAR_PART_TRACK, track_idx)
474                }
475            };
476
477            let symbol_width = display_width(symbol);
478            if is_vertical {
479                next_draw_index = i + 1;
480            } else {
481                next_draw_index = i + symbol_width;
482            }
483
484            let style = if !frame.buffer.degradation.apply_styling() {
485                Style::default()
486            } else if part == SCROLLBAR_PART_THUMB {
487                self.thumb_style
488            } else {
489                self.track_style
490            };
491
492            let (x, y) = if is_vertical {
493                let x = match self.orientation {
494                    ScrollbarOrientation::VerticalRight => area
495                        .right()
496                        .saturating_sub(symbol_width.max(1) as u16)
497                        .max(area.left()),
498                    ScrollbarOrientation::VerticalLeft => area.left(),
499                    _ => unreachable!(),
500                };
501                (x, area.top().saturating_add(i as u16))
502            } else {
503                let y = match self.orientation {
504                    ScrollbarOrientation::HorizontalBottom => area.bottom().saturating_sub(1),
505                    ScrollbarOrientation::HorizontalTop => area.top(),
506                    _ => unreachable!(),
507                };
508                (area.left().saturating_add(i as u16), y)
509            };
510
511            // Only draw if within bounds
512            if x < area.right() && y < area.bottom() {
513                draw_text_span(frame, x, y, symbol, style, area.right());
514
515                if let Some(id) = self.hit_id {
516                    // Encode data: [8: part] [28: track_len] [28: track_pos]
517                    let data = (part << 56)
518                        | ((track_len as u64 & 0x0FFF_FFFF) << 28)
519                        | (rel_pos as u64 & 0x0FFF_FFFF);
520
521                    let hit_w = (symbol_width.max(1) as u16).min(area.right().saturating_sub(x));
522                    frame.register_hit(Rect::new(x, y, hit_w, 1), id, HitRegion::Scrollbar, data);
523                }
524            }
525        }
526    }
527}
528
529impl<'a> Widget for Scrollbar<'a> {
530    fn render(&self, area: Rect, frame: &mut Frame) {
531        let mut state = ScrollbarState::default();
532        StatefulWidget::render(self, area, frame, &mut state);
533    }
534}
535
536// ============================================================================
537// Accessibility
538// ============================================================================
539
540impl ftui_a11y::Accessible for Scrollbar<'_> {
541    fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
542        use ftui_a11y::node::{A11yNodeInfo, A11yRole, A11yState};
543
544        let id = crate::a11y_node_id(area);
545        let orientation = match self.orientation {
546            ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft => "vertical",
547            ScrollbarOrientation::HorizontalBottom | ScrollbarOrientation::HorizontalTop => {
548                "horizontal"
549            }
550        };
551        let name = format!("{orientation} scrollbar");
552        let node = A11yNodeInfo::new(id, A11yRole::ScrollBar, area)
553            .with_name(name)
554            .with_state(A11yState {
555                // Scrollbars are always interactive but don't themselves hold focus;
556                // the viewport they scroll is the focusable element.
557                ..A11yState::default()
558            });
559        vec![node]
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use ftui_render::grapheme_pool::GraphemePool;
567
568    #[test]
569    fn scrollbar_empty_area() {
570        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
571        let area = Rect::new(0, 0, 0, 0);
572        let mut pool = GraphemePool::new();
573        let mut frame = Frame::new(1, 1, &mut pool);
574        let mut state = ScrollbarState::new(100, 0, 10);
575        StatefulWidget::render(&sb, area, &mut frame, &mut state);
576    }
577
578    #[test]
579    fn scrollbar_zero_content() {
580        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
581        let area = Rect::new(0, 0, 1, 10);
582        let mut pool = GraphemePool::new();
583        let mut frame = Frame::new(1, 10, &mut pool);
584        let mut state = ScrollbarState::new(100, 0, 10);
585        StatefulWidget::render(&sb, area, &mut frame, &mut state);
586
587        state.content_length = 0;
588        StatefulWidget::render(&sb, area, &mut frame, &mut state);
589
590        for y in 0..10u16 {
591            assert_eq!(frame.buffer.get(0, y).unwrap().content.as_char(), Some(' '));
592        }
593        assert!(state.track_layout.is_none());
594    }
595
596    #[test]
597    fn scrollbar_vertical_right_renders() {
598        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
599        let area = Rect::new(0, 0, 1, 10);
600        let mut pool = GraphemePool::new();
601        let mut frame = Frame::new(1, 10, &mut pool);
602        let mut state = ScrollbarState::new(100, 0, 10);
603        StatefulWidget::render(&sb, area, &mut frame, &mut state);
604
605        // Thumb should be at the top (position=0), track should have chars
606        let top_cell = frame.buffer.get(0, 0).unwrap();
607        assert!(top_cell.content.as_char().is_some());
608    }
609
610    #[test]
611    fn scrollbar_vertical_left_renders() {
612        let sb = Scrollbar::new(ScrollbarOrientation::VerticalLeft);
613        let area = Rect::new(0, 0, 1, 10);
614        let mut pool = GraphemePool::new();
615        let mut frame = Frame::new(1, 10, &mut pool);
616        let mut state = ScrollbarState::new(100, 0, 10);
617        StatefulWidget::render(&sb, area, &mut frame, &mut state);
618
619        let top_cell = frame.buffer.get(0, 0).unwrap();
620        assert!(top_cell.content.as_char().is_some());
621    }
622
623    #[test]
624    fn scrollbar_horizontal_renders() {
625        let sb = Scrollbar::new(ScrollbarOrientation::HorizontalBottom);
626        let area = Rect::new(0, 0, 10, 1);
627        let mut pool = GraphemePool::new();
628        let mut frame = Frame::new(10, 1, &mut pool);
629        let mut state = ScrollbarState::new(100, 0, 10);
630        StatefulWidget::render(&sb, area, &mut frame, &mut state);
631
632        let left_cell = frame.buffer.get(0, 0).unwrap();
633        assert!(left_cell.content.as_char().is_some());
634    }
635
636    #[test]
637    fn scrollbar_thumb_moves_with_position() {
638        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
639        let area = Rect::new(0, 0, 1, 10);
640
641        // Position at start
642        let mut pool1 = GraphemePool::new();
643        let mut frame1 = Frame::new(1, 10, &mut pool1);
644        let mut state1 = ScrollbarState::new(100, 0, 10);
645        StatefulWidget::render(&sb, area, &mut frame1, &mut state1);
646
647        // Position at end
648        let mut pool2 = GraphemePool::new();
649        let mut frame2 = Frame::new(1, 10, &mut pool2);
650        let mut state2 = ScrollbarState::new(100, 90, 10);
651        StatefulWidget::render(&sb, area, &mut frame2, &mut state2);
652
653        // The thumb char (█) should be at different positions
654        let thumb_char = '█';
655        let thumb_pos_1 = (0..10u16)
656            .find(|&y| frame1.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
657        let thumb_pos_2 = (0..10u16)
658            .find(|&y| frame2.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
659
660        // At start, thumb should be near top; at end, near bottom
661        assert!(thumb_pos_1.unwrap_or(0) < thumb_pos_2.unwrap_or(0));
662    }
663
664    #[test]
665    fn scrollbar_state_constructor() {
666        let state = ScrollbarState::new(200, 50, 20);
667        assert_eq!(state.content_length, 200);
668        assert_eq!(state.position, 50);
669        assert_eq!(state.viewport_length, 20);
670    }
671
672    #[test]
673    fn scrollbar_content_fits_viewport() {
674        // When viewport >= content, thumb should fill the whole track
675        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
676        let area = Rect::new(0, 0, 1, 10);
677        let mut pool = GraphemePool::new();
678        let mut frame = Frame::new(1, 10, &mut pool);
679        let mut state = ScrollbarState::new(5, 0, 10);
680        StatefulWidget::render(&sb, area, &mut frame, &mut state);
681
682        // All cells should be thumb (█)
683        let thumb_char = '█';
684        for y in 0..10u16 {
685            assert_eq!(
686                frame.buffer.get(0, y).unwrap().content.as_char(),
687                Some(thumb_char)
688            );
689        }
690    }
691
692    #[test]
693    fn scrollbar_horizontal_top_renders() {
694        let sb = Scrollbar::new(ScrollbarOrientation::HorizontalTop);
695        let area = Rect::new(0, 0, 10, 1);
696        let mut pool = GraphemePool::new();
697        let mut frame = Frame::new(10, 1, &mut pool);
698        let mut state = ScrollbarState::new(100, 0, 10);
699        StatefulWidget::render(&sb, area, &mut frame, &mut state);
700
701        let left_cell = frame.buffer.get(0, 0).unwrap();
702        assert!(left_cell.content.as_char().is_some());
703    }
704
705    #[test]
706    fn scrollbar_custom_symbols() {
707        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols(
708            ".",
709            "#",
710            Some("^"),
711            Some("v"),
712        );
713        let area = Rect::new(0, 0, 1, 5);
714        let mut pool = GraphemePool::new();
715        let mut frame = Frame::new(1, 5, &mut pool);
716        let mut state = ScrollbarState::new(50, 0, 10);
717        StatefulWidget::render(&sb, area, &mut frame, &mut state);
718
719        // Should use our custom symbols
720        let mut chars: Vec<Option<char>> = Vec::new();
721        for y in 0..5u16 {
722            chars.push(frame.buffer.get(0, y).unwrap().content.as_char());
723        }
724        // At least some cells should have our custom chars
725        assert!(chars.contains(&Some('#')) || chars.contains(&Some('.')));
726    }
727
728    #[test]
729    fn scrollbar_position_clamped_beyond_max() {
730        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
731        let area = Rect::new(0, 0, 1, 10);
732        let mut pool = GraphemePool::new();
733        let mut frame = Frame::new(1, 10, &mut pool);
734        // Position way beyond content_length
735        let mut state = ScrollbarState::new(100, 500, 10);
736        StatefulWidget::render(&sb, area, &mut frame, &mut state);
737
738        // Should still render without panic, thumb at bottom
739        let thumb_char = '█';
740        let thumb_pos = (0..10u16)
741            .find(|&y| frame.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
742        assert!(thumb_pos.is_some());
743    }
744
745    #[test]
746    fn scrollbar_state_default() {
747        let state = ScrollbarState::default();
748        assert_eq!(state.content_length, 0);
749        assert_eq!(state.position, 0);
750        assert_eq!(state.viewport_length, 0);
751    }
752
753    #[test]
754    fn scrollbar_widget_trait_renders() {
755        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
756        let area = Rect::new(0, 0, 1, 5);
757        let mut pool = GraphemePool::new();
758        let mut frame = Frame::new(1, 5, &mut pool);
759        // Widget trait uses default state (content_length=0, so no rendering)
760        Widget::render(&sb, area, &mut frame);
761        // Should not panic with default state
762    }
763
764    #[test]
765    fn scrollbar_orientation_default_is_vertical_right() {
766        assert_eq!(
767            ScrollbarOrientation::default(),
768            ScrollbarOrientation::VerticalRight
769        );
770    }
771
772    // --- Degradation tests ---
773
774    #[test]
775    fn degradation_essential_only_skips_entirely() {
776        use ftui_render::budget::DegradationLevel;
777
778        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
779        let area = Rect::new(0, 0, 1, 10);
780        let mut pool = GraphemePool::new();
781        let mut frame = Frame::new(1, 10, &mut pool);
782        frame.buffer.degradation = DegradationLevel::EssentialOnly;
783        let mut state = ScrollbarState::new(100, 0, 10);
784        frame.buffer.degradation = DegradationLevel::Full;
785        StatefulWidget::render(&sb, area, &mut frame, &mut state);
786        frame.buffer.degradation = DegradationLevel::EssentialOnly;
787        StatefulWidget::render(&sb, area, &mut frame, &mut state);
788
789        for y in 0..10u16 {
790            assert_eq!(frame.buffer.get(0, y).unwrap().content.as_char(), Some(' '));
791        }
792        assert!(state.track_layout.is_none());
793    }
794
795    #[test]
796    fn degradation_skeleton_skips_entirely() {
797        use ftui_render::budget::DegradationLevel;
798
799        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
800        let area = Rect::new(0, 0, 1, 10);
801        let mut pool = GraphemePool::new();
802        let mut frame = Frame::new(1, 10, &mut pool);
803        frame.buffer.degradation = DegradationLevel::Skeleton;
804        let mut state = ScrollbarState::new(100, 0, 10);
805        frame.buffer.degradation = DegradationLevel::Full;
806        StatefulWidget::render(&sb, area, &mut frame, &mut state);
807        frame.buffer.degradation = DegradationLevel::Skeleton;
808        StatefulWidget::render(&sb, area, &mut frame, &mut state);
809
810        for y in 0..10u16 {
811            assert_eq!(frame.buffer.get(0, y).unwrap().content.as_char(), Some(' '));
812        }
813        assert!(state.track_layout.is_none());
814    }
815
816    #[test]
817    fn degradation_full_renders_scrollbar() {
818        use ftui_render::budget::DegradationLevel;
819
820        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
821        let area = Rect::new(0, 0, 1, 10);
822        let mut pool = GraphemePool::new();
823        let mut frame = Frame::new(1, 10, &mut pool);
824        frame.buffer.degradation = DegradationLevel::Full;
825        let mut state = ScrollbarState::new(100, 0, 10);
826        StatefulWidget::render(&sb, area, &mut frame, &mut state);
827
828        // Should render something (thumb or track)
829        let top_cell = frame.buffer.get(0, 0).unwrap();
830        assert!(top_cell.content.as_char().is_some());
831    }
832
833    #[test]
834    fn degradation_simple_borders_still_renders() {
835        use ftui_render::budget::DegradationLevel;
836
837        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
838        let area = Rect::new(0, 0, 1, 10);
839        let mut pool = GraphemePool::new();
840        let mut frame = Frame::new(1, 10, &mut pool);
841        frame.buffer.degradation = DegradationLevel::SimpleBorders;
842        let mut state = ScrollbarState::new(100, 0, 10);
843        StatefulWidget::render(&sb, area, &mut frame, &mut state);
844
845        // SimpleBorders still renders decorative content
846        let top_cell = frame.buffer.get(0, 0).unwrap();
847        assert!(top_cell.content.as_char().is_some());
848    }
849
850    #[test]
851    fn scrollbar_wide_symbols_horizontal() {
852        let sb =
853            Scrollbar::new(ScrollbarOrientation::HorizontalBottom).symbols("🔴", "👍", None, None);
854        // Area width 4. Expect "🔴🔴" (2 chars * 2 width = 4 cells)
855        let area = Rect::new(0, 0, 4, 1);
856        let mut pool = GraphemePool::new();
857        let mut frame = Frame::new(4, 1, &mut pool);
858        // Track only (thumb size 0 or pos 0?)
859        // Let's make thumb small/invisible or check track part.
860        // If content_length=10, viewport=10, thumb fills all.
861        // Let's fill with thumb "👍"
862        let mut state = ScrollbarState::new(10, 0, 10);
863
864        StatefulWidget::render(&sb, area, &mut frame, &mut state);
865
866        // x=0: Head "👍" (wide emoji stored as grapheme, not direct char)
867        let c0 = frame.buffer.get(0, 0).unwrap();
868        assert!(!c0.is_empty() && !c0.is_continuation()); // Head
869        // x=1: Continuation
870        let c1 = frame.buffer.get(1, 0).unwrap();
871        assert!(c1.is_continuation());
872
873        // x=2: Head "👍"
874        let c2 = frame.buffer.get(2, 0).unwrap();
875        assert!(!c2.is_empty() && !c2.is_continuation()); // Head
876        // x=3: Continuation
877        let c3 = frame.buffer.get(3, 0).unwrap();
878        assert!(c3.is_continuation());
879    }
880
881    #[test]
882    fn scrollbar_wide_symbols_vertical() {
883        let sb =
884            Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols("🔴", "👍", None, None);
885        // Area height 2. Width 2 (to fit the wide char).
886        let area = Rect::new(0, 0, 2, 2);
887        let mut pool = GraphemePool::new();
888        let mut frame = Frame::new(2, 2, &mut pool);
889        let mut state = ScrollbarState::new(10, 0, 10); // Fill with thumb
890
891        StatefulWidget::render(&sb, area, &mut frame, &mut state);
892
893        // Row 0: "👍" at x=0 (wide emoji stored as grapheme, not direct char)
894        let r0_c0 = frame.buffer.get(0, 0).unwrap();
895        assert!(!r0_c0.is_empty() && !r0_c0.is_continuation()); // Head
896        let r0_c1 = frame.buffer.get(1, 0).unwrap();
897        assert!(r0_c1.is_continuation()); // Tail
898
899        // Row 1: "👍" at x=0 (should NOT be skipped)
900        let r1_c0 = frame.buffer.get(0, 1).unwrap();
901        assert!(!r1_c0.is_empty() && !r1_c0.is_continuation()); // Head
902        let r1_c1 = frame.buffer.get(1, 1).unwrap();
903        assert!(r1_c1.is_continuation()); // Tail
904    }
905
906    #[test]
907    fn scrollbar_wide_symbol_clips_drawing_and_hits_to_area() {
908        // Regression: wide symbols must not draw/register hit cells outside the widget area.
909        let sb = Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
910            .symbols("🔴", "👍", None, None)
911            .hit_id(HitId::new(1));
912        let area = Rect::new(0, 0, 3, 1);
913        let mut pool = GraphemePool::new();
914        let mut frame = Frame::with_hit_grid(5, 1, &mut pool);
915        let mut state = ScrollbarState::new(3, 0, 3); // Thumb fills the track.
916
917        StatefulWidget::render(&sb, area, &mut frame, &mut state);
918
919        // x=3 is outside the widget area (area.right() == 3). It must remain untouched.
920        let outside = frame.buffer.get(3, 0).unwrap();
921        assert!(outside.is_empty(), "cell outside area should remain empty");
922        assert!(frame.hit_test(3, 0).is_none(), "no hit outside area");
923    }
924
925    #[test]
926    fn scrollbar_wide_symbol_vertical_clips_drawing_and_hits_to_area() {
927        let sb = Scrollbar::new(ScrollbarOrientation::VerticalLeft)
928            .symbols("🔴", "👍", None, None)
929            .hit_id(HitId::new(1));
930        let area = Rect::new(0, 0, 1, 2);
931        let mut pool = GraphemePool::new();
932        let mut frame = Frame::with_hit_grid(2, 2, &mut pool);
933        let mut state = ScrollbarState::new(10, 0, 10); // Thumb fills the track.
934
935        StatefulWidget::render(&sb, area, &mut frame, &mut state);
936
937        // x=1 is outside the widget area (area.right() == 1). It must remain untouched.
938        let outside = frame.buffer.get(1, 0).unwrap();
939        assert!(outside.is_empty(), "cell outside area should remain empty");
940        assert!(frame.hit_test(1, 0).is_none(), "no hit outside area");
941    }
942
943    #[test]
944    fn scrollbar_vertical_right_never_draws_left_of_area_for_wide_symbols() {
945        // Regression: when the area is narrower than the symbol, VerticalRight must not
946        // shift the draw position left of the widget area.
947        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight)
948            .symbols("🔴", "👍", None, None)
949            .hit_id(HitId::new(1));
950        let area = Rect::new(2, 0, 1, 2);
951        let mut pool = GraphemePool::new();
952        let mut frame = Frame::with_hit_grid(4, 2, &mut pool);
953        let mut state = ScrollbarState::new(10, 0, 10);
954
955        StatefulWidget::render(&sb, area, &mut frame, &mut state);
956
957        // x=1 is left of the widget area (area.left() == 2). It must remain untouched.
958        let outside = frame.buffer.get(1, 0).unwrap();
959        assert!(outside.is_empty(), "cell left of area should remain empty");
960        assert!(frame.hit_test(1, 0).is_none(), "no hit left of area");
961    }
962
963    // --- Mouse handling tests ---
964
965    use crate::mouse::MouseResult;
966    use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
967
968    #[test]
969    fn scrollbar_state_begin_button() {
970        let mut state = ScrollbarState::new(100, 10, 20);
971        let data = SCROLLBAR_PART_BEGIN << 56;
972        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
973        let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
974        let result = state.handle_mouse(&event, hit, HitId::new(1));
975        assert_eq!(result, MouseResult::Scrolled);
976        assert_eq!(state.position, 9);
977    }
978
979    #[test]
980    fn scrollbar_state_end_button() {
981        let mut state = ScrollbarState::new(100, 10, 20);
982        let data = SCROLLBAR_PART_END << 56;
983        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
984        let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
985        let result = state.handle_mouse(&event, hit, HitId::new(1));
986        assert_eq!(result, MouseResult::Scrolled);
987        assert_eq!(state.position, 11);
988    }
989
990    #[test]
991    fn scrollbar_state_track_click() {
992        let mut state = ScrollbarState::new(100, 0, 20);
993        let track_len = 20u64;
994        let track_pos = 10u64;
995        let data = (SCROLLBAR_PART_TRACK << 56)
996            | ((track_len & 0x0FFF_FFFF) << 28)
997            | (track_pos & 0x0FFF_FFFF);
998        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
999        let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
1000        let result = state.handle_mouse(&event, hit, HitId::new(1));
1001        assert_eq!(result, MouseResult::Scrolled);
1002        // Track click maps proportionally to content position, centered on thumb.
1003        assert_eq!(state.position, 40);
1004    }
1005
1006    #[test]
1007    fn scrollbar_state_track_click_clamps() {
1008        let mut state = ScrollbarState::new(100, 0, 20);
1009        let track_len = 20u64;
1010        let track_pos = 95u64;
1011        let data = (SCROLLBAR_PART_TRACK << 56)
1012            | ((track_len & 0x0FFF_FFFF) << 28)
1013            | (track_pos & 0x0FFF_FFFF);
1014        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
1015        let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
1016        let result = state.handle_mouse(&event, hit, HitId::new(1));
1017        assert_eq!(result, MouseResult::Scrolled);
1018        assert_eq!(state.position, 80); // content_length - viewport_length
1019    }
1020
1021    #[test]
1022    fn scrollbar_state_thumb_drag_updates_position() {
1023        let mut state = ScrollbarState::new(100, 0, 20);
1024        let track_len = 20u64;
1025        let track_pos = 19u64;
1026        let data = (SCROLLBAR_PART_THUMB << 56)
1027            | ((track_len & 0x0FFF_FFFF) << 28)
1028            | (track_pos & 0x0FFF_FFFF);
1029        let event = MouseEvent::new(MouseEventKind::Drag(MouseButton::Left), 0, 0);
1030        let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
1031        let result = state.handle_mouse(&event, hit, HitId::new(1));
1032        assert_eq!(result, MouseResult::Scrolled);
1033        assert_eq!(state.position, 80);
1034    }
1035
1036    #[test]
1037    fn scrollbar_state_scroll_wheel_up() {
1038        let mut state = ScrollbarState::new(100, 10, 20);
1039        let event = MouseEvent::new(MouseEventKind::ScrollUp, 0, 0);
1040        let result = state.handle_mouse(&event, None, HitId::new(1));
1041        assert_eq!(result, MouseResult::Scrolled);
1042        assert_eq!(state.position, 7);
1043    }
1044
1045    #[test]
1046    fn scrollbar_state_scroll_wheel_down() {
1047        let mut state = ScrollbarState::new(100, 10, 20);
1048        let event = MouseEvent::new(MouseEventKind::ScrollDown, 0, 0);
1049        let result = state.handle_mouse(&event, None, HitId::new(1));
1050        assert_eq!(result, MouseResult::Scrolled);
1051        assert_eq!(state.position, 13);
1052    }
1053
1054    #[test]
1055    fn scrollbar_state_scroll_down_clamps() {
1056        let mut state = ScrollbarState::new(100, 78, 20);
1057        state.scroll_down(5);
1058        assert_eq!(state.position, 80); // max = 100 - 20
1059    }
1060
1061    #[test]
1062    fn scrollbar_state_scroll_up_clamps() {
1063        let mut state = ScrollbarState::new(100, 2, 20);
1064        state.scroll_up(5);
1065        assert_eq!(state.position, 0);
1066    }
1067
1068    #[test]
1069    fn scrollbar_state_wrong_id_ignored() {
1070        let mut state = ScrollbarState::new(100, 10, 20);
1071        let data = SCROLLBAR_PART_BEGIN << 56;
1072        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
1073        let hit = Some((HitId::new(99), HitRegion::Scrollbar, data));
1074        let result = state.handle_mouse(&event, hit, HitId::new(1));
1075        assert_eq!(result, MouseResult::Ignored);
1076        assert_eq!(state.position, 10);
1077    }
1078
1079    #[test]
1080    fn scrollbar_state_right_click_ignored() {
1081        let mut state = ScrollbarState::new(100, 10, 20);
1082        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Right), 0, 0);
1083        let result = state.handle_mouse(&event, None, HitId::new(1));
1084        assert_eq!(result, MouseResult::Ignored);
1085    }
1086}