tui_scrollbar/
scrollbar.rs

1//! Rendering and interaction for proportional scrollbars.
2//!
3//! This module provides the widget, glyph selection, and interaction helpers. The pure math lives
4//! in [`crate::metrics`].
5//!
6//! # How the parts interact
7//!
8//! 1. Your app owns `content_len`, `viewport_len`, and `offset`.
9//! 2. [`ScrollMetrics`] converts them into thumb geometry.
10//! 3. [`ScrollBar`] renders using the selected [`GlyphSet`].
11//! 4. Input events update `offset` via [`ScrollCommand`].
12//!
13//! The scrollbar renders only a single row or column. If you provide a larger [`Rect`], it will
14//! still render into the first row/column of that area.
15//!
16//! Arrow endcaps are optional. When enabled, they consume one cell at the start/end of the track,
17//! and the thumb renders inside the remaining inner area.
18//!
19//! Partial glyph selection uses [`CellFill::Partial`]: `start == 0` means the partial fill begins
20//! at the leading edge (top/left), so the upper/left glyphs are chosen. Non-zero `start` uses the
21//! lower/right glyphs to indicate a trailing-edge fill.
22//!
23//! Drag operations store a "grab offset" in subcells (1/8 of a cell; see [`crate::SUBCELL`]) so the
24//! thumb does not jump when the pointer starts dragging; subsequent drag events subtract that
25//! offset to keep the grab point stable.
26//!
27//! Wheel events are ignored unless their axis matches the scrollbar orientation. Positive deltas
28//! scroll down/right.
29//!
30//! The example below renders a vertical scrollbar into a buffer. It demonstrates how the widget
31//! uses `content_len`, `viewport_len`, and `offset` to decide the thumb size and position.
32//!
33//! ```rust
34//! use ratatui_core::buffer::Buffer;
35//! use ratatui_core::layout::Rect;
36//! use ratatui_core::widgets::Widget;
37//! use tui_scrollbar::{ScrollBar, ScrollLengths};
38//!
39//! let area = Rect::new(0, 0, 1, 4);
40//! let lengths = ScrollLengths {
41//!     content_len: 120,
42//!     viewport_len: 40,
43//! };
44//! let scrollbar = ScrollBar::vertical(lengths).offset(20);
45//!
46//! let mut buffer = Buffer::empty(area);
47//! scrollbar.render(area, &mut buffer);
48//! ```
49//!
50//! [`Rect`]: ratatui_core::layout::Rect
51
52#[cfg(feature = "crossterm")]
53use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
54use ratatui_core::buffer::Buffer;
55use ratatui_core::layout::Rect;
56use ratatui_core::style::Style;
57use ratatui_core::widgets::Widget;
58
59use crate::metrics::{CellFill, HitTest, ScrollMetrics, SUBCELL};
60
61/// Axis the scrollbar is laid out on.
62///
63/// Orientation determines whether the track length is derived from height or width.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ScrollBarOrientation {
66    /// A vertical scrollbar that fills a single column.
67    Vertical,
68    /// A horizontal scrollbar that fills a single row.
69    Horizontal,
70}
71
72/// Behavior when the user clicks on the track outside the thumb.
73///
74/// Page clicks move by `viewport_len`. Jump-to-click centers the thumb near the click.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum TrackClickBehavior {
77    /// Move by one viewport length toward the click position.
78    Page,
79    /// Jump the thumb toward the click position.
80    JumpToClick,
81}
82
83/// Which arrow endcaps to render on the track.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum ScrollBarArrows {
86    /// Do not render arrow endcaps.
87    None,
88    /// Render the arrow at the start of the track (top/left).
89    Start,
90    /// Render the arrow at the end of the track (bottom/right).
91    End,
92    /// Render arrows at both ends of the track.
93    Both,
94}
95
96impl ScrollBarArrows {
97    const fn has_start(self) -> bool {
98        matches!(self, Self::Start | Self::Both)
99    }
100
101    const fn has_end(self) -> bool {
102        matches!(self, Self::End | Self::Both)
103    }
104}
105
106impl Default for ScrollBarArrows {
107    fn default() -> Self {
108        Self::Both
109    }
110}
111
112/// Action requested by a pointer or wheel event.
113///
114/// Apply these to your stored offsets.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum ScrollCommand {
117    /// Update the content offset in the same logical units you supplied.
118    SetOffset(usize),
119}
120
121/// Axis for scroll wheel events.
122///
123/// The scrollbar ignores wheel events that do not match its orientation.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum ScrollAxis {
126    /// Wheel scroll in the vertical direction.
127    Vertical,
128    /// Wheel scroll in the horizontal direction.
129    Horizontal,
130}
131
132/// Pointer button used for interaction.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum PointerButton {
135    /// Primary pointer button (usually left mouse button).
136    Primary,
137}
138
139/// Kind of pointer interaction.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum PointerEventKind {
142    /// Pointer pressed down.
143    Down,
144    /// Pointer moved while pressed down.
145    Drag,
146    /// Pointer released.
147    Up,
148}
149
150/// Pointer input in terminal cell coordinates.
151///
152/// Use this to describe a pointer action relative to the scrollbar area.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub struct PointerEvent {
155    /// Column of the event, in terminal cells.
156    pub column: u16,
157    /// Row of the event, in terminal cells.
158    pub row: u16,
159    /// Event kind.
160    pub kind: PointerEventKind,
161    /// Pointer button.
162    pub button: PointerButton,
163}
164
165/// Scroll wheel input with an axis and a signed delta.
166///
167/// Positive deltas scroll down/right, negative deltas scroll up/left. The `column` and `row` are
168/// used to ignore wheel events outside the scrollbar area.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub struct ScrollWheel {
171    /// Axis the wheel is scrolling.
172    pub axis: ScrollAxis,
173    /// Signed delta. Positive values scroll down/right.
174    pub delta: isize,
175    /// Column where the wheel event occurred.
176    pub column: u16,
177    /// Row where the wheel event occurred.
178    pub row: u16,
179}
180
181/// Backend-agnostic input event for a scrollbar.
182///
183/// Use this in input handling when you want to stay backend-agnostic.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum ScrollEvent {
186    /// Pointer down/drag/up events.
187    Pointer(PointerEvent),
188    /// Scroll wheel input.
189    ScrollWheel(ScrollWheel),
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193enum ArrowHit {
194    Start,
195    End,
196}
197
198#[derive(Debug, Clone, Copy)]
199struct ArrowLayout {
200    track_area: Rect,
201    start: Option<(u16, u16)>,
202    end: Option<(u16, u16)>,
203}
204
205/// Drag state that should persist between frames.
206///
207/// Store this in your app state so drags remain active across draw calls.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub struct ScrollBarInteraction {
210    drag_state: DragState,
211}
212
213/// Internal drag capture state stored by [`ScrollBarInteraction`].
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215enum DragState {
216    /// No active drag.
217    Idle,
218    /// A drag is active; `grab_offset` is in subcells.
219    Dragging { grab_offset: usize },
220}
221
222impl Default for DragState {
223    fn default() -> Self {
224        Self::Idle
225    }
226}
227
228impl Default for ScrollBarInteraction {
229    fn default() -> Self {
230        Self {
231            drag_state: DragState::default(),
232        }
233    }
234}
235
236impl ScrollBarInteraction {
237    /// Creates a fresh interaction state with no active drag.
238    pub fn new() -> Self {
239        Self::default()
240    }
241
242    fn start_drag(&mut self, grab_offset: usize) {
243        self.drag_state = DragState::Dragging { grab_offset };
244    }
245
246    fn stop_drag(&mut self) {
247        self.drag_state = DragState::Idle;
248    }
249}
250
251/// Glyphs used to render the track, arrows, and thumb.
252///
253/// Arrays use indices 0..=7 to represent 1/8th through full coverage.
254#[derive(Debug, Clone, PartialEq, Eq)]
255pub struct GlyphSet {
256    /// Track glyph for vertical scrollbars.
257    pub track_vertical: char,
258    /// Track glyph for horizontal scrollbars.
259    pub track_horizontal: char,
260    /// Arrow glyph for the start of a vertical scrollbar (top).
261    pub arrow_vertical_start: char,
262    /// Arrow glyph for the end of a vertical scrollbar (bottom).
263    pub arrow_vertical_end: char,
264    /// Arrow glyph for the start of a horizontal scrollbar (left).
265    pub arrow_horizontal_start: char,
266    /// Arrow glyph for the end of a horizontal scrollbar (right).
267    pub arrow_horizontal_end: char,
268    /// Thumb glyphs for vertical lower fills (1/8th through full).
269    pub thumb_vertical_lower: [char; 8],
270    /// Thumb glyphs for vertical upper fills (1/8th through full).
271    pub thumb_vertical_upper: [char; 8],
272    /// Thumb glyphs for horizontal left fills (1/8th through full).
273    pub thumb_horizontal_left: [char; 8],
274    /// Thumb glyphs for horizontal right fills (1/8th through full).
275    pub thumb_horizontal_right: [char; 8],
276}
277
278impl GlyphSet {
279    /// Glyphs that mix standard block elements with legacy supplement glyphs.
280    ///
281    /// Use this to get full 1/8th coverage for upper and right edges that the standard block set
282    /// lacks; these glyphs come from [Symbols for Legacy Computing].
283    pub const fn legacy() -> Self {
284        let vertical_lower = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
285        let vertical_upper = ['▔', '🮂', '🮃', '▀', '🮄', '🮅', '🮆', '█'];
286        let horizontal_left = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
287        let horizontal_right = ['▕', '🮇', '🮈', '▐', '🮉', '🮊', '🮋', '█'];
288        Self {
289            track_vertical: '│',
290            track_horizontal: '─',
291            arrow_vertical_start: '▲',
292            arrow_vertical_end: '▼',
293            arrow_horizontal_start: '◄',
294            arrow_horizontal_end: '►',
295            thumb_vertical_lower: vertical_lower,
296            thumb_vertical_upper: vertical_upper,
297            thumb_horizontal_left: horizontal_left,
298            thumb_horizontal_right: horizontal_right,
299        }
300    }
301
302    /// Glyphs using only standard Unicode block elements.
303    ///
304    /// Use this if your font lacks the legacy glyphs; upper/right partials will use the same
305    /// glyphs as lower/left partials.
306    pub const fn unicode() -> Self {
307        let vertical = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
308        let horizontal = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
309        Self {
310            track_vertical: '│',
311            track_horizontal: '─',
312            arrow_vertical_start: '▲',
313            arrow_vertical_end: '▼',
314            arrow_horizontal_start: '◄',
315            arrow_horizontal_end: '►',
316            thumb_vertical_lower: vertical,
317            thumb_vertical_upper: vertical,
318            thumb_horizontal_left: horizontal,
319            thumb_horizontal_right: horizontal,
320        }
321    }
322}
323
324impl Default for GlyphSet {
325    fn default() -> Self {
326        Self::legacy()
327    }
328}
329
330/// [Symbols for Legacy Computing]: https://en.wikipedia.org/wiki/Symbols_for_Legacy_Computing
331
332/// A proportional scrollbar widget with fractional thumb rendering.
333///
334/// # Key methods
335///
336/// - [`Self::new`]
337/// - [`Self::orientation`]
338/// - [`Self::arrows`]
339/// - [`Self::content_len`]
340/// - [`Self::viewport_len`]
341/// - [`Self::offset`]
342///
343/// # Important
344///
345/// - `content_len` and `viewport_len` are in logical units.
346/// - Zero values are treated as 1.
347/// - The scrollbar renders into a single row or column.
348///
349/// # Behavior
350///
351/// The thumb length is proportional to `viewport_len / content_len` and clamped to at least one
352/// full cell for usability. When `content_len <= viewport_len`, the thumb fills the track. Areas
353/// with zero width or height render nothing.
354///
355/// Arrow endcaps, when enabled, consume one cell at the start/end of the track. The thumb and
356/// track render in the remaining inner area. Clicking an arrow steps the offset by `scroll_step`.
357///
358/// # Styling
359///
360/// Track glyphs use `track_style`. Thumb glyphs use `thumb_style`.
361///
362/// # State
363///
364/// This widget is stateless. Pointer drag state lives in [`ScrollBarInteraction`].
365///
366/// # Examples
367///
368/// ```rust
369/// use ratatui_core::buffer::Buffer;
370/// use ratatui_core::layout::Rect;
371/// use ratatui_core::widgets::Widget;
372/// use tui_scrollbar::{ScrollBar, ScrollLengths};
373///
374/// let area = Rect::new(0, 0, 1, 5);
375/// let lengths = ScrollLengths {
376///     content_len: 200,
377///     viewport_len: 40,
378/// };
379/// let scrollbar = ScrollBar::vertical(lengths).offset(60);
380///
381/// let mut buffer = Buffer::empty(area);
382/// scrollbar.render(area, &mut buffer);
383/// ```
384///
385/// ## Updating offsets on input
386///
387/// This is the typical pattern for pointer handling: feed events to the scrollbar and apply the
388/// returned command to your stored offset.
389///
390/// ```rust,no_run
391/// use ratatui_core::layout::Rect;
392/// use tui_scrollbar::{
393///     PointerButton, PointerEvent, PointerEventKind, ScrollBar, ScrollBarInteraction,
394///     ScrollCommand, ScrollEvent, ScrollLengths,
395/// };
396///
397/// let area = Rect::new(0, 0, 1, 10);
398/// let lengths = ScrollLengths {
399///     content_len: 400,
400///     viewport_len: 80,
401/// };
402/// let scrollbar = ScrollBar::vertical(lengths).offset(0);
403/// let mut interaction = ScrollBarInteraction::new();
404/// let mut offset = 0;
405///
406/// let event = ScrollEvent::Pointer(PointerEvent {
407///     column: 0,
408///     row: 3,
409///     kind: PointerEventKind::Down,
410///     button: PointerButton::Primary,
411/// });
412///
413/// if let Some(ScrollCommand::SetOffset(next)) =
414///     scrollbar.handle_event(area, event, &mut interaction)
415/// {
416///     offset = next;
417/// }
418/// # let _ = offset;
419/// ```
420///
421/// ## Track click behavior
422///
423/// Choose between classic page jumps or jump-to-click behavior.
424///
425/// ```rust
426/// use tui_scrollbar::{ScrollBar, ScrollLengths, TrackClickBehavior};
427///
428/// let lengths = ScrollLengths {
429///     content_len: 10,
430///     viewport_len: 5,
431/// };
432/// let scrollbar =
433///     ScrollBar::vertical(lengths).track_click_behavior(TrackClickBehavior::JumpToClick);
434/// ```
435///
436/// ## Arrow endcaps
437///
438/// Arrow endcaps are optional. When enabled, they reserve one cell at each end of the track.
439///
440/// ```rust
441/// use tui_scrollbar::{ScrollBar, ScrollBarArrows, ScrollLengths};
442///
443/// let lengths = ScrollLengths {
444///     content_len: 120,
445///     viewport_len: 24,
446/// };
447/// let scrollbar = ScrollBar::vertical(lengths).arrows(ScrollBarArrows::Both);
448/// ```
449#[derive(Debug, Clone, PartialEq, Eq)]
450pub struct ScrollBar {
451    orientation: ScrollBarOrientation,
452    content_len: usize,
453    viewport_len: usize,
454    offset: usize,
455    track_style: Style,
456    thumb_style: Style,
457    glyph_set: GlyphSet,
458    arrows: ScrollBarArrows,
459    track_click_behavior: TrackClickBehavior,
460    scroll_step: usize,
461}
462
463impl ScrollBar {
464    /// Creates a scrollbar with the given orientation and lengths.
465    ///
466    /// Zero lengths are treated as 1.
467    ///
468    /// ```rust
469    /// use tui_scrollbar::{ScrollBar, ScrollBarOrientation, ScrollLengths};
470    ///
471    /// let lengths = ScrollLengths {
472    ///     content_len: 120,
473    ///     viewport_len: 40,
474    /// };
475    /// let scrollbar = ScrollBar::new(ScrollBarOrientation::Vertical, lengths);
476    /// ```
477    pub fn new(orientation: ScrollBarOrientation, lengths: crate::ScrollLengths) -> Self {
478        Self {
479            orientation,
480            content_len: lengths.content_len,
481            viewport_len: lengths.viewport_len,
482            offset: 0,
483            track_style: Style::default(),
484            thumb_style: Style::default(),
485            glyph_set: GlyphSet::default(),
486            arrows: ScrollBarArrows::default(),
487            track_click_behavior: TrackClickBehavior::Page,
488            scroll_step: 1,
489        }
490    }
491
492    /// Creates a vertical scrollbar with the given content and viewport lengths.
493    pub fn vertical(lengths: crate::ScrollLengths) -> Self {
494        Self::new(ScrollBarOrientation::Vertical, lengths)
495    }
496
497    /// Creates a horizontal scrollbar with the given content and viewport lengths.
498    pub fn horizontal(lengths: crate::ScrollLengths) -> Self {
499        Self::new(ScrollBarOrientation::Horizontal, lengths)
500    }
501
502    /// Sets the scrollbar orientation.
503    pub const fn orientation(mut self, orientation: ScrollBarOrientation) -> Self {
504        self.orientation = orientation;
505        self
506    }
507
508    /// Sets the total scrollable content length in logical units.
509    ///
510    /// Larger values shrink the thumb, while smaller values enlarge it.
511    ///
512    /// Zero values are treated as 1.
513    pub const fn content_len(mut self, content_len: usize) -> Self {
514        self.content_len = content_len;
515        self
516    }
517
518    /// Sets the visible viewport length in logical units.
519    ///
520    /// When `viewport_len >= content_len`, the thumb fills the track.
521    ///
522    /// Zero values are treated as 1.
523    pub const fn viewport_len(mut self, viewport_len: usize) -> Self {
524        self.viewport_len = viewport_len;
525        self
526    }
527
528    /// Sets the current scroll offset in logical units.
529    ///
530    /// Offsets are clamped to `content_len - viewport_len` during rendering.
531    pub const fn offset(mut self, offset: usize) -> Self {
532        self.offset = offset;
533        self
534    }
535
536    /// Sets the style applied to track glyphs.
537    ///
538    /// Track styling applies only where the thumb is not rendered.
539    pub const fn track_style(mut self, style: Style) -> Self {
540        self.track_style = style;
541        self
542    }
543
544    /// Sets the style applied to thumb glyphs.
545    ///
546    /// Thumb styling overrides track styling for covered cells.
547    pub const fn thumb_style(mut self, style: Style) -> Self {
548        self.thumb_style = style;
549        self
550    }
551
552    /// Selects the glyph set used to render the track and thumb.
553    ///
554    /// [`GlyphSet::legacy`] uses additional symbols for 1/8th upper/right fills. Use
555    /// [`GlyphSet::unicode`] if you want to avoid the legacy supplement.
556    pub const fn glyph_set(mut self, glyph_set: GlyphSet) -> Self {
557        self.glyph_set = glyph_set;
558        self
559    }
560
561    /// Sets which arrow endcaps are rendered.
562    pub const fn arrows(mut self, arrows: ScrollBarArrows) -> Self {
563        self.arrows = arrows;
564        self
565    }
566
567    /// Sets behavior for clicks on the track outside the thumb.
568    ///
569    /// Use [`TrackClickBehavior::Page`] for classic page-up/down behavior, or
570    /// [`TrackClickBehavior::JumpToClick`] to move the thumb toward the click.
571    pub const fn track_click_behavior(mut self, behavior: TrackClickBehavior) -> Self {
572        self.track_click_behavior = behavior;
573        self
574    }
575
576    /// Sets the scroll step used for wheel events.
577    ///
578    /// The wheel delta is multiplied by this value (in your logical units) and then clamped.
579    pub fn scroll_step(mut self, step: usize) -> Self {
580        self.scroll_step = step.max(1);
581        self
582    }
583
584    /// Handles a backend-agnostic scrollbar event.
585    ///
586    /// Returns a [`ScrollCommand`] when the event should update the offset.
587    /// Events outside the scrollbar area are ignored.
588    ///
589    /// ```rust
590    /// use ratatui_core::layout::Rect;
591    /// use tui_scrollbar::{
592    ///     PointerButton, PointerEvent, PointerEventKind, ScrollBar, ScrollBarInteraction,
593    ///     ScrollEvent, ScrollLengths,
594    /// };
595    ///
596    /// let area = Rect::new(0, 0, 1, 6);
597    /// let lengths = ScrollLengths {
598    ///     content_len: 120,
599    ///     viewport_len: 24,
600    /// };
601    /// let scrollbar = ScrollBar::vertical(lengths).offset(0);
602    /// let mut interaction = ScrollBarInteraction::new();
603    /// let event = ScrollEvent::Pointer(PointerEvent {
604    ///     column: 0,
605    ///     row: 2,
606    ///     kind: PointerEventKind::Down,
607    ///     button: PointerButton::Primary,
608    /// });
609    ///
610    /// let _ = scrollbar.handle_event(area, event, &mut interaction);
611    /// ```
612    pub fn handle_event(
613        &self,
614        area: Rect,
615        event: ScrollEvent,
616        interaction: &mut ScrollBarInteraction,
617    ) -> Option<ScrollCommand> {
618        if area.width == 0 || area.height == 0 {
619            return None;
620        }
621
622        let layout = self.arrow_layout(area);
623        let lengths = crate::ScrollLengths {
624            content_len: self.content_len,
625            viewport_len: self.viewport_len,
626        };
627        let track_cells = match self.orientation {
628            ScrollBarOrientation::Vertical => layout.track_area.height,
629            ScrollBarOrientation::Horizontal => layout.track_area.width,
630        };
631        let metrics = ScrollMetrics::new(lengths, self.offset, track_cells);
632
633        match event {
634            ScrollEvent::Pointer(event) => {
635                if let Some(command) =
636                    self.handle_arrow_pointer(&layout, metrics, event, interaction)
637                {
638                    return Some(command);
639                }
640                self.handle_pointer_event(layout.track_area, metrics, event, interaction)
641            }
642            ScrollEvent::ScrollWheel(event) => self.handle_scroll_wheel(area, metrics, event),
643        }
644    }
645
646    #[cfg(feature = "crossterm")]
647    /// Handles crossterm mouse events for this scrollbar.
648    ///
649    /// This helper converts crossterm events into [`ScrollEvent`] values before delegating to
650    /// [`Self::handle_event`].
651    pub fn handle_mouse_event(
652        &self,
653        area: Rect,
654        event: MouseEvent,
655        interaction: &mut ScrollBarInteraction,
656    ) -> Option<ScrollCommand> {
657        let event = match event.kind {
658            MouseEventKind::Down(MouseButton::Left) => Some(ScrollEvent::Pointer(PointerEvent {
659                column: event.column,
660                row: event.row,
661                kind: PointerEventKind::Down,
662                button: PointerButton::Primary,
663            })),
664            MouseEventKind::Up(MouseButton::Left) => Some(ScrollEvent::Pointer(PointerEvent {
665                column: event.column,
666                row: event.row,
667                kind: PointerEventKind::Up,
668                button: PointerButton::Primary,
669            })),
670            MouseEventKind::Drag(MouseButton::Left) => Some(ScrollEvent::Pointer(PointerEvent {
671                column: event.column,
672                row: event.row,
673                kind: PointerEventKind::Drag,
674                button: PointerButton::Primary,
675            })),
676            MouseEventKind::ScrollUp => Some(ScrollEvent::ScrollWheel(ScrollWheel {
677                axis: ScrollAxis::Vertical,
678                delta: -1,
679                column: event.column,
680                row: event.row,
681            })),
682            MouseEventKind::ScrollDown => Some(ScrollEvent::ScrollWheel(ScrollWheel {
683                axis: ScrollAxis::Vertical,
684                delta: 1,
685                column: event.column,
686                row: event.row,
687            })),
688            MouseEventKind::ScrollLeft => Some(ScrollEvent::ScrollWheel(ScrollWheel {
689                axis: ScrollAxis::Horizontal,
690                delta: -1,
691                column: event.column,
692                row: event.row,
693            })),
694            MouseEventKind::ScrollRight => Some(ScrollEvent::ScrollWheel(ScrollWheel {
695                axis: ScrollAxis::Horizontal,
696                delta: 1,
697                column: event.column,
698                row: event.row,
699            })),
700            _ => None,
701        };
702
703        event.and_then(|event| self.handle_event(area, event, interaction))
704    }
705
706    fn glyph_for_vertical(&self, fill: CellFill) -> (char, Style) {
707        match fill {
708            CellFill::Empty => (self.glyph_set.track_vertical, self.track_style),
709            CellFill::Full => (self.glyph_set.thumb_vertical_lower[7], self.thumb_style),
710            CellFill::Partial { start, len } => {
711                let index = len.saturating_sub(1) as usize;
712                let glyph = if start == 0 {
713                    self.glyph_set.thumb_vertical_upper[index]
714                } else {
715                    self.glyph_set.thumb_vertical_lower[index]
716                };
717                (glyph, self.thumb_style)
718            }
719        }
720    }
721
722    fn glyph_for_horizontal(&self, fill: CellFill) -> (char, Style) {
723        match fill {
724            CellFill::Empty => (self.glyph_set.track_horizontal, self.track_style),
725            CellFill::Full => (self.glyph_set.thumb_horizontal_left[7], self.thumb_style),
726            CellFill::Partial { start, len } => {
727                let index = len.saturating_sub(1) as usize;
728                let glyph = if start == 0 {
729                    self.glyph_set.thumb_horizontal_left[index]
730                } else {
731                    self.glyph_set.thumb_horizontal_right[index]
732                };
733                (glyph, self.thumb_style)
734            }
735        }
736    }
737
738    fn handle_pointer_event(
739        &self,
740        area: Rect,
741        metrics: ScrollMetrics,
742        event: PointerEvent,
743        interaction: &mut ScrollBarInteraction,
744    ) -> Option<ScrollCommand> {
745        if event.button != PointerButton::Primary {
746            return None;
747        }
748
749        match event.kind {
750            PointerEventKind::Down => {
751                let cell_index = axis_cell_index(area, event.column, event.row, self.orientation)?;
752                let position = cell_index
753                    .saturating_mul(SUBCELL)
754                    .saturating_add(SUBCELL / 2);
755                if metrics.thumb_len() == 0 {
756                    return None;
757                }
758                match metrics.hit_test(position) {
759                    HitTest::Thumb => {
760                        let grab_offset = position.saturating_sub(metrics.thumb_start());
761                        interaction.start_drag(grab_offset);
762                        None
763                    }
764                    HitTest::Track => {
765                        interaction.stop_drag();
766                        self.handle_track_click(metrics, position)
767                    }
768                }
769            }
770            PointerEventKind::Drag => match interaction.drag_state {
771                DragState::Idle => None,
772                DragState::Dragging { grab_offset } => {
773                    let cell_index =
774                        axis_cell_index_clamped(area, event.column, event.row, self.orientation)?;
775                    let position = cell_index
776                        .saturating_mul(SUBCELL)
777                        .saturating_add(SUBCELL / 2);
778                    let thumb_start = position.saturating_sub(grab_offset);
779                    Some(ScrollCommand::SetOffset(
780                        metrics.offset_for_thumb_start(thumb_start),
781                    ))
782                }
783            },
784            PointerEventKind::Up => {
785                interaction.stop_drag();
786                None
787            }
788        }
789    }
790
791    fn handle_scroll_wheel(
792        &self,
793        area: Rect,
794        metrics: ScrollMetrics,
795        event: ScrollWheel,
796    ) -> Option<ScrollCommand> {
797        if !area.contains((event.column, event.row).into()) {
798            return None;
799        }
800
801        let matches_axis = match (self.orientation, event.axis) {
802            (ScrollBarOrientation::Vertical, ScrollAxis::Vertical) => true,
803            (ScrollBarOrientation::Horizontal, ScrollAxis::Horizontal) => true,
804            _ => false,
805        };
806
807        if !matches_axis {
808            return None;
809        }
810
811        let step = self.scroll_step.max(1) as isize;
812        let delta = event.delta.saturating_mul(step);
813        let max_offset = metrics.max_offset() as isize;
814        let next = (metrics.offset() as isize).saturating_add(delta);
815        let next = next.clamp(0, max_offset);
816        Some(ScrollCommand::SetOffset(next as usize))
817    }
818
819    fn handle_track_click(&self, metrics: ScrollMetrics, position: usize) -> Option<ScrollCommand> {
820        if metrics.max_offset() == 0 {
821            return None;
822        }
823
824        match self.track_click_behavior {
825            TrackClickBehavior::Page => {
826                let thumb_end = metrics.thumb_start().saturating_add(metrics.thumb_len());
827                if position < metrics.thumb_start() {
828                    Some(ScrollCommand::SetOffset(
829                        metrics.offset().saturating_sub(metrics.viewport_len()),
830                    ))
831                } else if position >= thumb_end {
832                    Some(ScrollCommand::SetOffset(
833                        (metrics.offset() + metrics.viewport_len()).min(metrics.max_offset()),
834                    ))
835                } else {
836                    None
837                }
838            }
839            TrackClickBehavior::JumpToClick => {
840                let half_thumb = metrics.thumb_len() / 2;
841                let thumb_start = position.saturating_sub(half_thumb);
842                Some(ScrollCommand::SetOffset(
843                    metrics.offset_for_thumb_start(thumb_start),
844                ))
845            }
846        }
847    }
848
849    fn arrow_layout(&self, area: Rect) -> ArrowLayout {
850        let mut track_area = area;
851        let (start, end) = match self.orientation {
852            ScrollBarOrientation::Vertical => {
853                let start_enabled = self.arrows.has_start() && area.height > 0;
854                let end_enabled = self.arrows.has_end() && area.height > start_enabled as u16;
855                let start = start_enabled.then_some((area.x, area.y));
856                let end = end_enabled
857                    .then_some((area.x, area.y.saturating_add(area.height).saturating_sub(1)));
858                if start_enabled {
859                    track_area.y = track_area.y.saturating_add(1);
860                    track_area.height = track_area.height.saturating_sub(1);
861                }
862                if end_enabled {
863                    track_area.height = track_area.height.saturating_sub(1);
864                }
865                (start, end)
866            }
867            ScrollBarOrientation::Horizontal => {
868                let start_enabled = self.arrows.has_start() && area.width > 0;
869                let end_enabled = self.arrows.has_end() && area.width > start_enabled as u16;
870                let start = start_enabled.then_some((area.x, area.y));
871                let end = end_enabled
872                    .then_some((area.x.saturating_add(area.width).saturating_sub(1), area.y));
873                if start_enabled {
874                    track_area.x = track_area.x.saturating_add(1);
875                    track_area.width = track_area.width.saturating_sub(1);
876                }
877                if end_enabled {
878                    track_area.width = track_area.width.saturating_sub(1);
879                }
880                (start, end)
881            }
882        };
883
884        ArrowLayout {
885            track_area,
886            start,
887            end,
888        }
889    }
890
891    fn arrow_hit(&self, layout: &ArrowLayout, event: PointerEvent) -> Option<ArrowHit> {
892        if let Some((x, y)) = layout.start {
893            if event.column == x && event.row == y {
894                return Some(ArrowHit::Start);
895            }
896        }
897        if let Some((x, y)) = layout.end {
898            if event.column == x && event.row == y {
899                return Some(ArrowHit::End);
900            }
901        }
902        None
903    }
904
905    fn handle_arrow_pointer(
906        &self,
907        layout: &ArrowLayout,
908        metrics: ScrollMetrics,
909        event: PointerEvent,
910        interaction: &mut ScrollBarInteraction,
911    ) -> Option<ScrollCommand> {
912        if event.button != PointerButton::Primary || event.kind != PointerEventKind::Down {
913            return None;
914        }
915
916        let hit = self.arrow_hit(layout, event)?;
917        if metrics.max_offset() == 0 {
918            return None;
919        }
920
921        interaction.stop_drag();
922        let step = self.scroll_step.max(1) as isize;
923        let delta = match hit {
924            ArrowHit::Start => -step,
925            ArrowHit::End => step,
926        };
927        let max_offset = metrics.max_offset() as isize;
928        let next = (metrics.offset() as isize).saturating_add(delta);
929        let next = next.clamp(0, max_offset);
930        Some(ScrollCommand::SetOffset(next as usize))
931    }
932
933    fn render_arrows(&self, layout: &ArrowLayout, buf: &mut Buffer) {
934        if let Some((x, y)) = layout.start {
935            let glyph = match self.orientation {
936                ScrollBarOrientation::Vertical => self.glyph_set.arrow_vertical_start,
937                ScrollBarOrientation::Horizontal => self.glyph_set.arrow_horizontal_start,
938            };
939            let cell = &mut buf[(x, y)];
940            cell.set_char(glyph);
941            cell.set_style(self.track_style);
942        }
943        if let Some((x, y)) = layout.end {
944            let glyph = match self.orientation {
945                ScrollBarOrientation::Vertical => self.glyph_set.arrow_vertical_end,
946                ScrollBarOrientation::Horizontal => self.glyph_set.arrow_horizontal_end,
947            };
948            let cell = &mut buf[(x, y)];
949            cell.set_char(glyph);
950            cell.set_style(self.track_style);
951        }
952    }
953}
954
955fn axis_cell_index(
956    area: Rect,
957    column: u16,
958    row: u16,
959    orientation: ScrollBarOrientation,
960) -> Option<usize> {
961    match orientation {
962        ScrollBarOrientation::Vertical => {
963            if row < area.y || row >= area.y.saturating_add(area.height) {
964                None
965            } else {
966                Some(row.saturating_sub(area.y) as usize)
967            }
968        }
969        ScrollBarOrientation::Horizontal => {
970            if column < area.x || column >= area.x.saturating_add(area.width) {
971                None
972            } else {
973                Some(column.saturating_sub(area.x) as usize)
974            }
975        }
976    }
977}
978
979fn axis_cell_index_clamped(
980    area: Rect,
981    column: u16,
982    row: u16,
983    orientation: ScrollBarOrientation,
984) -> Option<usize> {
985    match orientation {
986        ScrollBarOrientation::Vertical => {
987            if area.height == 0 {
988                return None;
989            }
990            let end = area.y.saturating_add(area.height).saturating_sub(1);
991            let row = row.clamp(area.y, end);
992            Some(row.saturating_sub(area.y) as usize)
993        }
994        ScrollBarOrientation::Horizontal => {
995            if area.width == 0 {
996                return None;
997            }
998            let end = area.x.saturating_add(area.width).saturating_sub(1);
999            let column = column.clamp(area.x, end);
1000            Some(column.saturating_sub(area.x) as usize)
1001        }
1002    }
1003}
1004
1005impl Widget for &ScrollBar {
1006    fn render(self, area: Rect, buf: &mut Buffer) {
1007        if area.width == 0 || area.height == 0 {
1008            return;
1009        }
1010
1011        let layout = self.arrow_layout(area);
1012        self.render_arrows(&layout, buf);
1013        if layout.track_area.width == 0 || layout.track_area.height == 0 {
1014            return;
1015        }
1016
1017        match self.orientation {
1018            ScrollBarOrientation::Vertical => {
1019                let metrics = ScrollMetrics::new(
1020                    crate::ScrollLengths {
1021                        content_len: self.content_len,
1022                        viewport_len: self.viewport_len,
1023                    },
1024                    self.offset,
1025                    layout.track_area.height,
1026                );
1027                let x = layout.track_area.x;
1028                for (idx, y) in (layout.track_area.y
1029                    ..layout.track_area.y.saturating_add(layout.track_area.height))
1030                    .enumerate()
1031                {
1032                    let (glyph, style) = self.glyph_for_vertical(metrics.cell_fill(idx));
1033                    let cell = &mut buf[(x, y)];
1034                    cell.set_char(glyph);
1035                    cell.set_style(style);
1036                }
1037            }
1038            ScrollBarOrientation::Horizontal => {
1039                let metrics = ScrollMetrics::new(
1040                    crate::ScrollLengths {
1041                        content_len: self.content_len,
1042                        viewport_len: self.viewport_len,
1043                    },
1044                    self.offset,
1045                    layout.track_area.width,
1046                );
1047                let y = layout.track_area.y;
1048                for (idx, x) in (layout.track_area.x
1049                    ..layout.track_area.x.saturating_add(layout.track_area.width))
1050                    .enumerate()
1051                {
1052                    let (glyph, style) = self.glyph_for_horizontal(metrics.cell_fill(idx));
1053                    let cell = &mut buf[(x, y)];
1054                    cell.set_char(glyph);
1055                    cell.set_style(style);
1056                }
1057            }
1058        }
1059    }
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064    use ratatui_core::buffer::Buffer;
1065    use ratatui_core::layout::Rect;
1066
1067    use super::*;
1068    use crate::ScrollLengths;
1069    #[test]
1070    fn render_vertical_fractional_thumb() {
1071        let scrollbar = ScrollBar::vertical(ScrollLengths {
1072            content_len: 10,
1073            viewport_len: 3,
1074        })
1075        .arrows(ScrollBarArrows::None)
1076        .offset(1);
1077        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 4));
1078        (&scrollbar).render(buf.area, &mut buf);
1079        assert_eq!(buf, Buffer::with_lines(vec!["▅", "▀", "│", "│"]));
1080    }
1081
1082    #[test]
1083    fn render_horizontal_fractional_thumb() {
1084        let scrollbar = ScrollBar::horizontal(ScrollLengths {
1085            content_len: 10,
1086            viewport_len: 3,
1087        })
1088        .arrows(ScrollBarArrows::None)
1089        .offset(1);
1090        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1091        (&scrollbar).render(buf.area, &mut buf);
1092        assert_eq!(buf, Buffer::with_lines(vec!["🮉▌──"]));
1093    }
1094
1095    #[test]
1096    fn render_full_thumb_when_no_scroll() {
1097        let scrollbar = ScrollBar::vertical(ScrollLengths {
1098            content_len: 5,
1099            viewport_len: 10,
1100        })
1101        .arrows(ScrollBarArrows::None);
1102        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 3));
1103        (&scrollbar).render(buf.area, &mut buf);
1104        assert_eq!(buf, Buffer::with_lines(vec!["█", "█", "█"]));
1105    }
1106
1107    #[test]
1108    fn render_vertical_arrows() {
1109        let scrollbar = ScrollBar::vertical(ScrollLengths {
1110            content_len: 5,
1111            viewport_len: 2,
1112        });
1113        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 3));
1114        (&scrollbar).render(buf.area, &mut buf);
1115        assert_eq!(buf, Buffer::with_lines(vec!["▲", "█", "▼"]));
1116    }
1117
1118    #[test]
1119    fn render_horizontal_arrows() {
1120        let scrollbar = ScrollBar::horizontal(ScrollLengths {
1121            content_len: 5,
1122            viewport_len: 2,
1123        });
1124        let mut buf = Buffer::empty(Rect::new(0, 0, 3, 1));
1125        (&scrollbar).render(buf.area, &mut buf);
1126        assert_eq!(buf, Buffer::with_lines(vec!["◄█►"]));
1127    }
1128
1129    #[test]
1130    fn handle_track_click_pages() {
1131        let scrollbar = ScrollBar::vertical(ScrollLengths {
1132            content_len: 100,
1133            viewport_len: 20,
1134        })
1135        .arrows(ScrollBarArrows::None)
1136        .offset(40);
1137        let area = Rect::new(0, 0, 1, 10);
1138        let mut interaction = ScrollBarInteraction::default();
1139        let event = ScrollEvent::Pointer(PointerEvent {
1140            column: 0,
1141            row: 0,
1142            kind: PointerEventKind::Down,
1143            button: PointerButton::Primary,
1144        });
1145        let metrics = ScrollMetrics::new(
1146            ScrollLengths {
1147                content_len: 100,
1148                viewport_len: 20,
1149            },
1150            40,
1151            area.height,
1152        );
1153        let expected = metrics.offset().saturating_sub(metrics.viewport_len());
1154        assert_eq!(
1155            scrollbar.handle_event(area, event, &mut interaction),
1156            Some(ScrollCommand::SetOffset(expected))
1157        );
1158    }
1159
1160    #[test]
1161    fn handle_drag_updates_offset() {
1162        let scrollbar = ScrollBar::vertical(ScrollLengths {
1163            content_len: 100,
1164            viewport_len: 20,
1165        })
1166        .arrows(ScrollBarArrows::None)
1167        .offset(40);
1168        let area = Rect::new(0, 0, 1, 10);
1169        let metrics = ScrollMetrics::new(
1170            ScrollLengths {
1171                content_len: 100,
1172                viewport_len: 20,
1173            },
1174            40,
1175            area.height,
1176        );
1177        let mut interaction = ScrollBarInteraction::default();
1178        let down = ScrollEvent::Pointer(PointerEvent {
1179            column: 0,
1180            row: 4,
1181            kind: PointerEventKind::Down,
1182            button: PointerButton::Primary,
1183        });
1184        assert_eq!(scrollbar.handle_event(area, down, &mut interaction), None);
1185
1186        let drag = ScrollEvent::Pointer(PointerEvent {
1187            column: 0,
1188            row: 6,
1189            kind: PointerEventKind::Drag,
1190            button: PointerButton::Primary,
1191        });
1192        let grab_offset = (4 * SUBCELL + SUBCELL / 2).saturating_sub(metrics.thumb_start());
1193        let expected_thumb_start = (6 * SUBCELL + SUBCELL / 2).saturating_sub(grab_offset);
1194        let expected = metrics.offset_for_thumb_start(expected_thumb_start);
1195        assert_eq!(
1196            scrollbar.handle_event(area, drag, &mut interaction),
1197            Some(ScrollCommand::SetOffset(expected))
1198        );
1199    }
1200
1201    #[test]
1202    fn handle_scroll_wheel_uses_step() {
1203        let scrollbar = ScrollBar::vertical(ScrollLengths {
1204            content_len: 100,
1205            viewport_len: 20,
1206        })
1207        .arrows(ScrollBarArrows::None)
1208        .offset(40)
1209        .scroll_step(3);
1210        let area = Rect::new(0, 0, 1, 10);
1211        let mut interaction = ScrollBarInteraction::default();
1212        let event = ScrollEvent::ScrollWheel(ScrollWheel {
1213            axis: ScrollAxis::Vertical,
1214            delta: 1,
1215            column: 0,
1216            row: 0,
1217        });
1218        assert_eq!(
1219            scrollbar.handle_event(area, event, &mut interaction),
1220            Some(ScrollCommand::SetOffset(43))
1221        );
1222    }
1223
1224    #[test]
1225    fn handle_arrow_click_steps_offset() {
1226        let scrollbar = ScrollBar::vertical(ScrollLengths {
1227            content_len: 100,
1228            viewport_len: 20,
1229        })
1230        .offset(10)
1231        .scroll_step(5);
1232        let area = Rect::new(0, 0, 1, 5);
1233        let mut interaction = ScrollBarInteraction::default();
1234        let up = ScrollEvent::Pointer(PointerEvent {
1235            column: 0,
1236            row: 0,
1237            kind: PointerEventKind::Down,
1238            button: PointerButton::Primary,
1239        });
1240        assert_eq!(
1241            scrollbar.handle_event(area, up, &mut interaction),
1242            Some(ScrollCommand::SetOffset(5))
1243        );
1244
1245        let down = ScrollEvent::Pointer(PointerEvent {
1246            column: 0,
1247            row: 4,
1248            kind: PointerEventKind::Down,
1249            button: PointerButton::Primary,
1250        });
1251        assert_eq!(
1252            scrollbar.handle_event(area, down, &mut interaction),
1253            Some(ScrollCommand::SetOffset(15))
1254        );
1255    }
1256}