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