ratatui_widgets/
scrollbar.rs

1//! The [`Scrollbar`] widget is used to display a scrollbar alongside other widgets.
2#![warn(clippy::pedantic)]
3#![allow(
4    clippy::cast_possible_truncation,
5    clippy::cast_precision_loss,
6    clippy::cast_sign_loss,
7    clippy::module_name_repetitions
8)]
9
10use core::iter;
11
12use ratatui_core::buffer::Buffer;
13use ratatui_core::layout::Rect;
14use ratatui_core::style::Style;
15use ratatui_core::symbols::scrollbar::{DOUBLE_HORIZONTAL, DOUBLE_VERTICAL, Set};
16use ratatui_core::widgets::StatefulWidget;
17use strum::{Display, EnumString};
18use unicode_width::UnicodeWidthStr;
19
20#[cfg(not(feature = "std"))]
21use crate::polyfills::F64Polyfills;
22
23/// A widget to display a scrollbar
24///
25/// The following components of the scrollbar are customizable in symbol and style. Note the
26/// scrollbar is represented horizontally but it can also be set vertically (which is actually the
27/// default).
28///
29/// ```text
30/// <--▮------->
31/// ^  ^   ^   ^
32/// │  │   │   └ end
33/// │  │   └──── track
34/// │  └──────── thumb
35/// └─────────── begin
36/// ```
37///
38/// # Important
39///
40/// You must specify the [`ScrollbarState::content_length`] before rendering the `Scrollbar`, or
41/// else the `Scrollbar` will render blank.
42///
43/// # Examples
44///
45/// ```rust
46/// use ratatui::Frame;
47/// use ratatui::layout::{Margin, Rect};
48/// use ratatui::text::Line;
49/// use ratatui::widgets::{
50///     Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget,
51/// };
52///
53/// # fn render_paragraph_with_scrollbar(frame: &mut Frame, area: Rect) {
54/// let vertical_scroll = 0; // from app state
55///
56/// let items = vec![
57///     Line::from("Item 1"),
58///     Line::from("Item 2"),
59///     Line::from("Item 3"),
60/// ];
61/// let paragraph = Paragraph::new(items.clone())
62///     .scroll((vertical_scroll as u16, 0))
63///     .block(Block::new().borders(Borders::RIGHT)); // to show a background for the scrollbar
64///
65/// let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
66///     .begin_symbol(Some("↑"))
67///     .end_symbol(Some("↓"));
68///
69/// let mut scrollbar_state = ScrollbarState::new(items.len()).position(vertical_scroll);
70///
71/// let area = frame.area();
72/// // Note we render the paragraph
73/// frame.render_widget(paragraph, area);
74/// // and the scrollbar, those are separate widgets
75/// frame.render_stateful_widget(
76///     scrollbar,
77///     area.inner(Margin {
78///         // using an inner vertical margin of 1 unit makes the scrollbar inside the block
79///         vertical: 1,
80///         horizontal: 0,
81///     }),
82///     &mut scrollbar_state,
83/// );
84/// # }
85/// ```
86#[derive(Debug, Clone, Eq, PartialEq, Hash)]
87pub struct Scrollbar<'a> {
88    orientation: ScrollbarOrientation,
89    thumb_style: Style,
90    thumb_symbol: &'a str,
91    track_style: Style,
92    track_symbol: Option<&'a str>,
93    begin_symbol: Option<&'a str>,
94    begin_style: Style,
95    end_symbol: Option<&'a str>,
96    end_style: Style,
97}
98
99/// This is the position of the scrollbar around a given area.
100///
101/// ```plain
102///           HorizontalTop
103///             ┌───────┐
104/// VerticalLeft│       │VerticalRight
105///             └───────┘
106///          HorizontalBottom
107/// ```
108#[derive(Debug, Default, Display, EnumString, Clone, Eq, PartialEq, Hash)]
109#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
110pub enum ScrollbarOrientation {
111    /// Positions the scrollbar on the right, scrolling vertically
112    #[default]
113    VerticalRight,
114    /// Positions the scrollbar on the left, scrolling vertically
115    VerticalLeft,
116    /// Positions the scrollbar on the bottom, scrolling horizontally
117    HorizontalBottom,
118    /// Positions the scrollbar on the top, scrolling horizontally
119    HorizontalTop,
120}
121
122/// A struct representing the state of a Scrollbar widget.
123///
124/// # Important
125///
126/// It's essential to set the `content_length` field when using this struct. This field
127/// represents the total length of the scrollable content. The default value is zero
128/// which will result in the Scrollbar not rendering.
129///
130/// For example, in the following list, assume there are 4 bullet points:
131///
132/// - the `content_length` is 4
133/// - the `position` is 0
134/// - the `viewport_content_length` is 2
135///
136/// ```text
137/// ┌───────────────┐
138/// │1. this is a   █
139/// │   single item █
140/// │2. this is a   ║
141/// │   second item ║
142/// └───────────────┘
143/// ```
144///
145/// If you don't have multi-line content, you can leave the `viewport_content_length` set to the
146/// default and it'll use the track size as a `viewport_content_length`.
147#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)]
148#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
149pub struct ScrollbarState {
150    /// The total length of the scrollable content.
151    content_length: usize,
152    /// The current position within the scrollable content.
153    position: usize,
154    /// The length of content in current viewport.
155    ///
156    /// FIXME: this should be `Option<usize>`, but it will break serialization to change it.
157    viewport_content_length: usize,
158}
159
160/// An enum representing a scrolling direction.
161///
162/// This is used with [`ScrollbarState::scroll`].
163///
164/// It is useful for example when you want to store in which direction to scroll.
165#[derive(Debug, Default, Display, EnumString, Clone, Copy, Eq, PartialEq, Hash)]
166#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
167pub enum ScrollDirection {
168    /// Forward scroll direction, usually corresponds to scrolling downwards or rightwards.
169    #[default]
170    Forward,
171    /// Backward scroll direction, usually corresponds to scrolling upwards or leftwards.
172    Backward,
173}
174
175impl Default for Scrollbar<'_> {
176    fn default() -> Self {
177        Self::new(ScrollbarOrientation::default())
178    }
179}
180
181impl<'a> Scrollbar<'a> {
182    /// Creates a new scrollbar with the given orientation.
183    ///
184    /// Most of the time you'll want [`ScrollbarOrientation::VerticalRight`] or
185    /// [`ScrollbarOrientation::HorizontalBottom`]. See [`ScrollbarOrientation`] for more options.
186    #[must_use = "creates the Scrollbar"]
187    pub const fn new(orientation: ScrollbarOrientation) -> Self {
188        let symbols = if orientation.is_vertical() {
189            DOUBLE_VERTICAL
190        } else {
191            DOUBLE_HORIZONTAL
192        };
193        Self::new_with_symbols(orientation, &symbols)
194    }
195
196    /// Creates a new scrollbar with the given orientation and symbol set.
197    #[must_use = "creates the Scrollbar"]
198    const fn new_with_symbols(orientation: ScrollbarOrientation, symbols: &Set<'a>) -> Self {
199        Self {
200            orientation,
201            thumb_symbol: symbols.thumb,
202            thumb_style: Style::new(),
203            track_symbol: Some(symbols.track),
204            track_style: Style::new(),
205            begin_symbol: Some(symbols.begin),
206            begin_style: Style::new(),
207            end_symbol: Some(symbols.end),
208            end_style: Style::new(),
209        }
210    }
211
212    /// Sets the position of the scrollbar.
213    ///
214    /// The orientation of the scrollbar is the position it will take around a [`Rect`]. See
215    /// [`ScrollbarOrientation`] for more details.
216    ///
217    /// Resets the symbols to [`DOUBLE_VERTICAL`] or [`DOUBLE_HORIZONTAL`] based on orientation.
218    ///
219    /// This is a fluent setter method which must be chained or used as it consumes self
220    #[must_use = "method moves the value of self and returns the modified value"]
221    pub const fn orientation(mut self, orientation: ScrollbarOrientation) -> Self {
222        self.orientation = orientation;
223        let symbols = if self.orientation.is_vertical() {
224            DOUBLE_VERTICAL
225        } else {
226            DOUBLE_HORIZONTAL
227        };
228        self.symbols(symbols)
229    }
230
231    /// Sets the orientation and symbols for the scrollbar from a [`Set`].
232    ///
233    /// This has the same effect as calling [`Scrollbar::orientation`] and then
234    /// [`Scrollbar::symbols`]. See those for more details.
235    ///
236    /// This is a fluent setter method which must be chained or used as it consumes self
237    #[must_use = "method moves the value of self and returns the modified value"]
238    pub const fn orientation_and_symbol(
239        mut self,
240        orientation: ScrollbarOrientation,
241        symbols: Set<'a>,
242    ) -> Self {
243        self.orientation = orientation;
244        self.symbols(symbols)
245    }
246
247    /// Sets the symbol that represents the thumb of the scrollbar.
248    ///
249    /// The thumb is the handle representing the progression on the scrollbar. See [`Scrollbar`]
250    /// for a visual example of what this represents.
251    ///
252    /// This is a fluent setter method which must be chained or used as it consumes self
253    #[must_use = "method moves the value of self and returns the modified value"]
254    pub const fn thumb_symbol(mut self, thumb_symbol: &'a str) -> Self {
255        self.thumb_symbol = thumb_symbol;
256        self
257    }
258
259    /// Sets the style on the scrollbar thumb.
260    ///
261    /// The thumb is the handle representing the progression on the scrollbar. See [`Scrollbar`]
262    /// for a visual example of what this represents.
263    ///
264    /// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
265    /// your own type that implements [`Into<Style>`]).
266    ///
267    /// This is a fluent setter method which must be chained or used as it consumes self
268    ///
269    /// [`Color`]: ratatui_core::style::Color
270    #[must_use = "method moves the value of self and returns the modified value"]
271    pub fn thumb_style<S: Into<Style>>(mut self, thumb_style: S) -> Self {
272        self.thumb_style = thumb_style.into();
273        self
274    }
275
276    /// Sets the symbol that represents the track of the scrollbar.
277    ///
278    /// See [`Scrollbar`] for a visual example of what this represents.
279    ///
280    /// This is a fluent setter method which must be chained or used as it consumes self
281    #[must_use = "method moves the value of self and returns the modified value"]
282    pub const fn track_symbol(mut self, track_symbol: Option<&'a str>) -> Self {
283        self.track_symbol = track_symbol;
284        self
285    }
286
287    /// Sets the style that is used for the track of the scrollbar.
288    ///
289    /// See [`Scrollbar`] for a visual example of what this represents.
290    ///
291    /// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
292    /// your own type that implements [`Into<Style>`]).
293    ///
294    /// This is a fluent setter method which must be chained or used as it consumes self
295    ///
296    /// [`Color`]: ratatui_core::style::Color
297    #[must_use = "method moves the value of self and returns the modified value"]
298    pub fn track_style<S: Into<Style>>(mut self, track_style: S) -> Self {
299        self.track_style = track_style.into();
300        self
301    }
302
303    /// Sets the symbol that represents the beginning of the scrollbar.
304    ///
305    /// See [`Scrollbar`] for a visual example of what this represents.
306    ///
307    /// This is a fluent setter method which must be chained or used as it consumes self
308    #[must_use = "method moves the value of self and returns the modified value"]
309    pub const fn begin_symbol(mut self, begin_symbol: Option<&'a str>) -> Self {
310        self.begin_symbol = begin_symbol;
311        self
312    }
313
314    /// Sets the style that is used for the beginning of the scrollbar.
315    ///
316    /// See [`Scrollbar`] for a visual example of what this represents.
317    ///
318    /// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
319    /// your own type that implements [`Into<Style>`]).
320    ///
321    /// This is a fluent setter method which must be chained or used as it consumes self
322    ///
323    /// [`Color`]: ratatui_core::style::Color
324    #[must_use = "method moves the value of self and returns the modified value"]
325    pub fn begin_style<S: Into<Style>>(mut self, begin_style: S) -> Self {
326        self.begin_style = begin_style.into();
327        self
328    }
329
330    /// Sets the symbol that represents the end of the scrollbar.
331    ///
332    /// See [`Scrollbar`] for a visual example of what this represents.
333    ///
334    /// This is a fluent setter method which must be chained or used as it consumes self
335    #[must_use = "method moves the value of self and returns the modified value"]
336    pub const fn end_symbol(mut self, end_symbol: Option<&'a str>) -> Self {
337        self.end_symbol = end_symbol;
338        self
339    }
340
341    /// Sets the style that is used for the end of the scrollbar.
342    ///
343    /// See [`Scrollbar`] for a visual example of what this represents.
344    ///
345    /// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
346    /// your own type that implements [`Into<Style>`]).
347    ///
348    /// This is a fluent setter method which must be chained or used as it consumes self
349    ///
350    /// [`Color`]: ratatui_core::style::Color
351    #[must_use = "method moves the value of self and returns the modified value"]
352    pub fn end_style<S: Into<Style>>(mut self, end_style: S) -> Self {
353        self.end_style = end_style.into();
354        self
355    }
356
357    /// Sets the symbols used for the various parts of the scrollbar from a [`Set`].
358    ///
359    /// ```text
360    /// <--▮------->
361    /// ^  ^   ^   ^
362    /// │  │   │   └ end
363    /// │  │   └──── track
364    /// │  └──────── thumb
365    /// └─────────── begin
366    /// ```
367    ///
368    /// Only sets `begin_symbol`, `end_symbol` and `track_symbol` if they already contain a value.
369    /// If they were set to `None` explicitly, this function will respect that choice. Use their
370    /// respective setters to change their value.
371    ///
372    /// This is a fluent setter method which must be chained or used as it consumes self
373    #[expect(clippy::needless_pass_by_value)] // Breaking change
374    #[must_use = "method moves the value of self and returns the modified value"]
375    pub const fn symbols(mut self, symbols: Set<'a>) -> Self {
376        self.thumb_symbol = symbols.thumb;
377        if self.track_symbol.is_some() {
378            self.track_symbol = Some(symbols.track);
379        }
380        if self.begin_symbol.is_some() {
381            self.begin_symbol = Some(symbols.begin);
382        }
383        if self.end_symbol.is_some() {
384            self.end_symbol = Some(symbols.end);
385        }
386        self
387    }
388
389    /// Sets the style used for the various parts of the scrollbar from a [`Style`].
390    ///
391    /// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
392    /// your own type that implements [`Into<Style>`]).
393    ///
394    /// ```text
395    /// <--▮------->
396    /// ^  ^   ^   ^
397    /// │  │   │   └ end
398    /// │  │   └──── track
399    /// │  └──────── thumb
400    /// └─────────── begin
401    /// ```
402    ///
403    /// This is a fluent setter method which must be chained or used as it consumes self
404    ///
405    /// [`Color`]: ratatui_core::style::Color
406    #[must_use = "method moves the value of self and returns the modified value"]
407    pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
408        let style = style.into();
409        self.track_style = style;
410        self.thumb_style = style;
411        self.begin_style = style;
412        self.end_style = style;
413        self
414    }
415}
416
417impl ScrollbarState {
418    /// Constructs a new [`ScrollbarState`] with the specified content length.
419    ///
420    /// `content_length` is the total number of element, that can be scrolled. See
421    /// [`ScrollbarState`] for more details.
422    #[must_use = "creates the ScrollbarState"]
423    pub const fn new(content_length: usize) -> Self {
424        Self {
425            content_length,
426            position: 0,
427            viewport_content_length: 0,
428        }
429    }
430
431    /// Sets the scroll position of the scrollbar.
432    ///
433    /// This represents the number of scrolled items.
434    ///
435    /// This is a fluent setter method which must be chained or used as it consumes self
436    #[must_use = "method moves the value of self and returns the modified value"]
437    pub const fn position(mut self, position: usize) -> Self {
438        self.position = position;
439        self
440    }
441
442    /// Sets the length of the scrollable content.
443    ///
444    /// This is the number of scrollable items. If items have a length of one, then this is the
445    /// same as the number of scrollable cells.
446    ///
447    /// This is a fluent setter method which must be chained or used as it consumes self
448    #[must_use = "method moves the value of self and returns the modified value"]
449    pub const fn content_length(mut self, content_length: usize) -> Self {
450        self.content_length = content_length;
451        self
452    }
453
454    /// Sets the items' size.
455    ///
456    /// This is a fluent setter method which must be chained or used as it consumes self
457    #[must_use = "method moves the value of self and returns the modified value"]
458    pub const fn viewport_content_length(mut self, viewport_content_length: usize) -> Self {
459        self.viewport_content_length = viewport_content_length;
460        self
461    }
462
463    /// Decrements the scroll position by one, ensuring it doesn't go below zero.
464    pub const fn prev(&mut self) {
465        self.position = self.position.saturating_sub(1);
466    }
467
468    /// Increments the scroll position by one, ensuring it doesn't exceed the length of the content.
469    pub fn next(&mut self) {
470        self.position = self
471            .position
472            .saturating_add(1)
473            .min(self.content_length.saturating_sub(1));
474    }
475
476    /// Sets the scroll position to the start of the scrollable content.
477    pub const fn first(&mut self) {
478        self.position = 0;
479    }
480
481    /// Sets the scroll position to the end of the scrollable content.
482    pub const fn last(&mut self) {
483        self.position = self.content_length.saturating_sub(1);
484    }
485
486    /// Changes the scroll position based on the provided [`ScrollDirection`].
487    pub fn scroll(&mut self, direction: ScrollDirection) {
488        match direction {
489            ScrollDirection::Forward => {
490                self.next();
491            }
492            ScrollDirection::Backward => {
493                self.prev();
494            }
495        }
496    }
497
498    /// Returns the current position within the scrollable content.
499    #[must_use = "returns the current position within the scrollable content"]
500    pub const fn get_position(&self) -> usize {
501        self.position
502    }
503}
504
505impl StatefulWidget for Scrollbar<'_> {
506    type State = ScrollbarState;
507
508    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
509        if state.content_length == 0 || self.track_length_excluding_arrow_heads(area) == 0 {
510            return;
511        }
512
513        if let Some(area) = self.scrollbar_area(area) {
514            let areas = area.columns().flat_map(Rect::rows);
515            let bar_symbols = self.bar_symbols(area, state);
516            for (area, bar) in areas.zip(bar_symbols) {
517                if let Some((symbol, style)) = bar {
518                    buf.set_string(area.x, area.y, symbol, style);
519                }
520            }
521        }
522    }
523}
524
525impl Scrollbar<'_> {
526    /// Returns an iterator over the symbols and styles of the scrollbar.
527    fn bar_symbols(
528        &self,
529        area: Rect,
530        state: &ScrollbarState,
531    ) -> impl Iterator<Item = Option<(&str, Style)>> {
532        let (track_start_len, thumb_len, track_end_len) = self.part_lengths(area, state);
533
534        let begin = self.begin_symbol.map(|s| Some((s, self.begin_style)));
535        let track = Some(self.track_symbol.map(|s| (s, self.track_style)));
536        let thumb = Some(Some((self.thumb_symbol, self.thumb_style)));
537        let end = self.end_symbol.map(|s| Some((s, self.end_style)));
538
539        // `<`
540        iter::once(begin)
541            // `<═══`
542            .chain(iter::repeat_n(track, track_start_len))
543            // `<═══█████`
544            .chain(iter::repeat_n(thumb, thumb_len))
545            // `<═══█████═══════`
546            .chain(iter::repeat_n(track, track_end_len))
547            // `<═══█████═══════>`
548            .chain(iter::once(end))
549            .flatten()
550    }
551
552    /// Returns the lengths of the parts of a scrollbar
553    ///
554    /// The scrollbar has 3 parts of note:
555    /// - `<═══█████═══════>`: full scrollbar
556    /// - ` ═══             `: track start
557    /// - `    █████        `: thumb
558    /// - `         ═══════ `: track end
559    ///
560    /// This method returns the length of the start, thumb, and end as a tuple.
561    fn part_lengths(&self, area: Rect, state: &ScrollbarState) -> (usize, usize, usize) {
562        let track_length = f64::from(self.track_length_excluding_arrow_heads(area));
563        let viewport_length = self.viewport_length(state, area) as f64;
564
565        // Ensure that the position of the thumb is within the bounds of the content taking into
566        // account the content and viewport length. When the last line of the content is at the top
567        // of the viewport, the thumb should be at the bottom of the track.
568        let max_position = state.content_length.saturating_sub(1) as f64;
569        let start_position = (state.position as f64).clamp(0.0, max_position);
570        let max_viewport_position = max_position + viewport_length;
571        let end_position = start_position + viewport_length;
572
573        // Calculate the start and end positions of the thumb. The size will be proportional to the
574        // viewport length compared to the total amount of possible visible rows.
575        let thumb_start = start_position * track_length / max_viewport_position;
576        let thumb_end = end_position * track_length / max_viewport_position;
577
578        // Make sure that the thumb is at least 1 cell long by ensuring that the start of the thumb
579        // is less than the track_len. We use the positions instead of the sizes and use nearest
580        // integer instead of floor / ceil to avoid problems caused by rounding errors.
581        let thumb_start = thumb_start.round().clamp(0.0, track_length - 1.0) as usize;
582        let thumb_end = thumb_end.round().clamp(0.0, track_length) as usize;
583
584        let thumb_length = thumb_end.saturating_sub(thumb_start).max(1);
585        let track_end_length = (track_length as usize).saturating_sub(thumb_start + thumb_length);
586
587        (thumb_start, thumb_length, track_end_length)
588    }
589
590    fn scrollbar_area(&self, area: Rect) -> Option<Rect> {
591        match self.orientation {
592            ScrollbarOrientation::VerticalLeft => area.columns().next(),
593            ScrollbarOrientation::VerticalRight => area.columns().next_back(),
594            ScrollbarOrientation::HorizontalTop => area.rows().next(),
595            ScrollbarOrientation::HorizontalBottom => area.rows().next_back(),
596        }
597    }
598
599    /// Calculates length of the track excluding the arrow heads
600    ///
601    /// ```plain
602    ///        ┌────────── track_length
603    ///  vvvvvvvvvvvvvvv
604    /// <═══█████═══════>
605    /// ```
606    fn track_length_excluding_arrow_heads(&self, area: Rect) -> u16 {
607        let start_len = self.begin_symbol.map_or(0, |s| s.width() as u16);
608        let end_len = self.end_symbol.map_or(0, |s| s.width() as u16);
609        let arrows_len = start_len.saturating_add(end_len);
610        if self.orientation.is_vertical() {
611            area.height.saturating_sub(arrows_len)
612        } else {
613            area.width.saturating_sub(arrows_len)
614        }
615    }
616
617    const fn viewport_length(&self, state: &ScrollbarState, area: Rect) -> usize {
618        if state.viewport_content_length != 0 {
619            state.viewport_content_length
620        } else if self.orientation.is_vertical() {
621            area.height as usize
622        } else {
623            area.width as usize
624        }
625    }
626}
627
628impl ScrollbarOrientation {
629    /// Returns `true` if the scrollbar is vertical.
630    #[must_use = "returns the requested kind of the scrollbar"]
631    pub const fn is_vertical(&self) -> bool {
632        matches!(self, Self::VerticalRight | Self::VerticalLeft)
633    }
634
635    /// Returns `true` if the scrollbar is horizontal.
636    #[must_use = "returns the requested kind of the scrollbar"]
637    pub const fn is_horizontal(&self) -> bool {
638        matches!(self, Self::HorizontalBottom | Self::HorizontalTop)
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use alloc::format;
645    use alloc::string::ToString;
646    use core::str::FromStr;
647
648    use ratatui_core::text::Text;
649    use ratatui_core::widgets::Widget;
650    use rstest::{fixture, rstest};
651    use strum::ParseError;
652
653    use super::*;
654
655    #[test]
656    fn scroll_direction_to_string() {
657        assert_eq!(ScrollDirection::Forward.to_string(), "Forward");
658        assert_eq!(ScrollDirection::Backward.to_string(), "Backward");
659    }
660
661    #[test]
662    fn scroll_direction_from_str() {
663        assert_eq!("Forward".parse(), Ok(ScrollDirection::Forward));
664        assert_eq!("Backward".parse(), Ok(ScrollDirection::Backward));
665        assert_eq!(
666            ScrollDirection::from_str(""),
667            Err(ParseError::VariantNotFound)
668        );
669    }
670
671    #[test]
672    fn scrollbar_orientation_to_string() {
673        use ScrollbarOrientation::*;
674        assert_eq!(VerticalRight.to_string(), "VerticalRight");
675        assert_eq!(VerticalLeft.to_string(), "VerticalLeft");
676        assert_eq!(HorizontalBottom.to_string(), "HorizontalBottom");
677        assert_eq!(HorizontalTop.to_string(), "HorizontalTop");
678    }
679
680    #[test]
681    fn scrollbar_orientation_from_str() {
682        use ScrollbarOrientation::*;
683        assert_eq!("VerticalRight".parse(), Ok(VerticalRight));
684        assert_eq!("VerticalLeft".parse(), Ok(VerticalLeft));
685        assert_eq!("HorizontalBottom".parse(), Ok(HorizontalBottom));
686        assert_eq!("HorizontalTop".parse(), Ok(HorizontalTop));
687        assert_eq!(
688            ScrollbarOrientation::from_str(""),
689            Err(ParseError::VariantNotFound)
690        );
691    }
692
693    #[fixture]
694    fn scrollbar_no_arrows() -> Scrollbar<'static> {
695        Scrollbar::new(ScrollbarOrientation::HorizontalTop)
696            .begin_symbol(None)
697            .end_symbol(None)
698            .track_symbol(Some("-"))
699            .thumb_symbol("#")
700    }
701
702    #[rstest]
703    #[case::area_2_position_0("#-", 0, 2)]
704    #[case::area_2_position_1("-#", 1, 2)]
705    fn render_scrollbar_simplest(
706        #[case] expected: &str,
707        #[case] position: usize,
708        #[case] content_length: usize,
709        scrollbar_no_arrows: Scrollbar,
710    ) {
711        let mut buffer = Buffer::empty(Rect::new(0, 0, expected.width() as u16, 1));
712        let mut state = ScrollbarState::new(content_length).position(position);
713        scrollbar_no_arrows.render(buffer.area, &mut buffer, &mut state);
714        assert_eq!(buffer, Buffer::with_lines([expected]));
715    }
716
717    #[rstest]
718    #[case::position_0("#####-----", 0, 10)]
719    #[case::position_1("-#####----", 1, 10)]
720    #[case::position_2("-#####----", 2, 10)]
721    #[case::position_3("--#####---", 3, 10)]
722    #[case::position_4("--#####---", 4, 10)]
723    #[case::position_5("---#####--", 5, 10)]
724    #[case::position_6("---#####--", 6, 10)]
725    #[case::position_7("----#####-", 7, 10)]
726    #[case::position_8("----#####-", 8, 10)]
727    #[case::position_9("-----#####", 9, 10)]
728    fn render_scrollbar_simple(
729        #[case] expected: &str,
730        #[case] position: usize,
731        #[case] content_length: usize,
732        scrollbar_no_arrows: Scrollbar,
733    ) {
734        let mut buffer = Buffer::empty(Rect::new(0, 0, expected.width() as u16, 1));
735        let mut state = ScrollbarState::new(content_length).position(position);
736        scrollbar_no_arrows.render(buffer.area, &mut buffer, &mut state);
737        assert_eq!(buffer, Buffer::with_lines([expected]));
738    }
739
740    #[rstest]
741    #[case::position_0("          ", 0, 0)]
742    fn render_scrollbar_nobar(
743        #[case] expected: &str,
744        #[case] position: usize,
745        #[case] content_length: usize,
746        scrollbar_no_arrows: Scrollbar,
747    ) {
748        let size = expected.width();
749        let mut buffer = Buffer::empty(Rect::new(0, 0, size as u16, 1));
750        let mut state = ScrollbarState::new(content_length).position(position);
751        scrollbar_no_arrows.render(buffer.area, &mut buffer, &mut state);
752        assert_eq!(buffer, Buffer::with_lines([expected]));
753    }
754
755    #[rstest]
756    #[case::fullbar_position_0("##########", 0, 1)]
757    #[case::almost_fullbar_position_0("#########-", 0, 2)]
758    #[case::almost_fullbar_position_1("-#########", 1, 2)]
759    fn render_scrollbar_fullbar(
760        #[case] expected: &str,
761        #[case] position: usize,
762        #[case] content_length: usize,
763        scrollbar_no_arrows: Scrollbar,
764    ) {
765        let size = expected.width();
766        let mut buffer = Buffer::empty(Rect::new(0, 0, size as u16, 1));
767        let mut state = ScrollbarState::new(content_length).position(position);
768        scrollbar_no_arrows.render(buffer.area, &mut buffer, &mut state);
769        assert_eq!(buffer, Buffer::with_lines([expected]));
770    }
771
772    #[rstest]
773    #[case::position_0("#########-", 0, 2)]
774    #[case::position_1("-#########", 1, 2)]
775    fn render_scrollbar_almost_fullbar(
776        #[case] expected: &str,
777        #[case] position: usize,
778        #[case] content_length: usize,
779        scrollbar_no_arrows: Scrollbar,
780    ) {
781        let size = expected.width();
782        let mut buffer = Buffer::empty(Rect::new(0, 0, size as u16, 1));
783        let mut state = ScrollbarState::new(content_length).position(position);
784        scrollbar_no_arrows.render(buffer.area, &mut buffer, &mut state);
785        assert_eq!(buffer, Buffer::with_lines([expected]));
786    }
787
788    #[rstest]
789    #[case::position_0("█████═════", 0, 10)]
790    #[case::position_1("═█████════", 1, 10)]
791    #[case::position_2("═█████════", 2, 10)]
792    #[case::position_3("══█████═══", 3, 10)]
793    #[case::position_4("══█████═══", 4, 10)]
794    #[case::position_5("═══█████══", 5, 10)]
795    #[case::position_6("═══█████══", 6, 10)]
796    #[case::position_7("════█████═", 7, 10)]
797    #[case::position_8("════█████═", 8, 10)]
798    #[case::position_9("═════█████", 9, 10)]
799    #[case::position_out_of_bounds("═════█████", 100, 10)]
800    fn render_scrollbar_without_symbols(
801        #[case] expected: &str,
802        #[case] position: usize,
803        #[case] content_length: usize,
804    ) {
805        let size = expected.width() as u16;
806        let mut buffer = Buffer::empty(Rect::new(0, 0, size, 1));
807        let mut state = ScrollbarState::new(content_length).position(position);
808        Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
809            .begin_symbol(None)
810            .end_symbol(None)
811            .render(buffer.area, &mut buffer, &mut state);
812        assert_eq!(buffer, Buffer::with_lines([expected]));
813    }
814
815    #[rstest]
816    #[case::position_0("█████     ", 0, 10)]
817    #[case::position_1(" █████    ", 1, 10)]
818    #[case::position_2(" █████    ", 2, 10)]
819    #[case::position_3("  █████   ", 3, 10)]
820    #[case::position_4("  █████   ", 4, 10)]
821    #[case::position_5("   █████  ", 5, 10)]
822    #[case::position_6("   █████  ", 6, 10)]
823    #[case::position_7("    █████ ", 7, 10)]
824    #[case::position_8("    █████ ", 8, 10)]
825    #[case::position_9("     █████", 9, 10)]
826    #[case::position_out_of_bounds("     █████", 100, 10)]
827    fn render_scrollbar_without_track_symbols(
828        #[case] expected: &str,
829        #[case] position: usize,
830        #[case] content_length: usize,
831    ) {
832        let size = expected.width() as u16;
833        let mut buffer = Buffer::empty(Rect::new(0, 0, size, 1));
834        let mut state = ScrollbarState::new(content_length).position(position);
835        Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
836            .track_symbol(None)
837            .begin_symbol(None)
838            .end_symbol(None)
839            .render(buffer.area, &mut buffer, &mut state);
840        assert_eq!(buffer, Buffer::with_lines([expected]));
841    }
842
843    #[rstest]
844    #[case::position_0("█████-----", 0, 10)]
845    #[case::position_1("-█████----", 1, 10)]
846    #[case::position_2("-█████----", 2, 10)]
847    #[case::position_3("--█████---", 3, 10)]
848    #[case::position_4("--█████---", 4, 10)]
849    #[case::position_5("---█████--", 5, 10)]
850    #[case::position_6("---█████--", 6, 10)]
851    #[case::position_7("----█████-", 7, 10)]
852    #[case::position_8("----█████-", 8, 10)]
853    #[case::position_9("-----█████", 9, 10)]
854    #[case::position_out_of_bounds("-----█████", 100, 10)]
855    fn render_scrollbar_without_track_symbols_over_content(
856        #[case] expected: &str,
857        #[case] position: usize,
858        #[case] content_length: usize,
859    ) {
860        let size = expected.width() as u16;
861        let mut buffer = Buffer::empty(Rect::new(0, 0, size, 1));
862        let width = buffer.area.width as usize;
863        let s = "";
864        Text::from(format!("{s:-^width$}")).render(buffer.area, &mut buffer);
865        let mut state = ScrollbarState::new(content_length).position(position);
866        Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
867            .track_symbol(None)
868            .begin_symbol(None)
869            .end_symbol(None)
870            .render(buffer.area, &mut buffer, &mut state);
871        assert_eq!(buffer, Buffer::with_lines([expected]));
872    }
873
874    #[rstest]
875    #[case::position_0("<####---->", 0, 10)]
876    #[case::position_1("<#####--->", 1, 10)]
877    #[case::position_2("<-####--->", 2, 10)]
878    #[case::position_3("<-####--->", 3, 10)]
879    #[case::position_4("<--####-->", 4, 10)]
880    #[case::position_5("<--####-->", 5, 10)]
881    #[case::position_6("<---####->", 6, 10)]
882    #[case::position_7("<---####->", 7, 10)]
883    #[case::position_8("<---#####>", 8, 10)]
884    #[case::position_9("<----####>", 9, 10)]
885    #[case::position_one_out_of_bounds("<----####>", 10, 10)]
886    #[case::position_few_out_of_bounds("<----####>", 15, 10)]
887    #[case::position_very_many_out_of_bounds("<----####>", 500, 10)]
888    fn render_scrollbar_with_symbols(
889        #[case] expected: &str,
890        #[case] position: usize,
891        #[case] content_length: usize,
892    ) {
893        let size = expected.width() as u16;
894        let mut buffer = Buffer::empty(Rect::new(0, 0, size, 1));
895        let mut state = ScrollbarState::new(content_length).position(position);
896        Scrollbar::new(ScrollbarOrientation::HorizontalTop)
897            .begin_symbol(Some("<"))
898            .end_symbol(Some(">"))
899            .track_symbol(Some("-"))
900            .thumb_symbol("#")
901            .render(buffer.area, &mut buffer, &mut state);
902        assert_eq!(buffer, Buffer::with_lines([expected]));
903    }
904
905    #[rstest]
906    #[case::position_0("█████═════", 0, 10)]
907    #[case::position_1("═█████════", 1, 10)]
908    #[case::position_2("═█████════", 2, 10)]
909    #[case::position_3("══█████═══", 3, 10)]
910    #[case::position_4("══█████═══", 4, 10)]
911    #[case::position_5("═══█████══", 5, 10)]
912    #[case::position_6("═══█████══", 6, 10)]
913    #[case::position_7("════█████═", 7, 10)]
914    #[case::position_8("════█████═", 8, 10)]
915    #[case::position_9("═════█████", 9, 10)]
916    #[case::position_out_of_bounds("═════█████", 100, 10)]
917    fn render_scrollbar_horizontal_bottom(
918        #[case] expected: &str,
919        #[case] position: usize,
920        #[case] content_length: usize,
921    ) {
922        let size = expected.width() as u16;
923        let mut buffer = Buffer::empty(Rect::new(0, 0, size, 2));
924        let mut state = ScrollbarState::new(content_length).position(position);
925        Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
926            .begin_symbol(None)
927            .end_symbol(None)
928            .render(buffer.area, &mut buffer, &mut state);
929        let empty_string = " ".repeat(size as usize);
930        assert_eq!(buffer, Buffer::with_lines([&empty_string, expected]));
931    }
932
933    #[rstest]
934    #[case::position_0("█████═════", 0, 10)]
935    #[case::position_1("═█████════", 1, 10)]
936    #[case::position_2("═█████════", 2, 10)]
937    #[case::position_3("══█████═══", 3, 10)]
938    #[case::position_4("══█████═══", 4, 10)]
939    #[case::position_5("═══█████══", 5, 10)]
940    #[case::position_6("═══█████══", 6, 10)]
941    #[case::position_7("════█████═", 7, 10)]
942    #[case::position_8("════█████═", 8, 10)]
943    #[case::position_9("═════█████", 9, 10)]
944    #[case::position_out_of_bounds("═════█████", 100, 10)]
945    fn render_scrollbar_horizontal_top(
946        #[case] expected: &str,
947        #[case] position: usize,
948        #[case] content_length: usize,
949    ) {
950        let size = expected.width() as u16;
951        let mut buffer = Buffer::empty(Rect::new(0, 0, size, 2));
952        let mut state = ScrollbarState::new(content_length).position(position);
953        Scrollbar::new(ScrollbarOrientation::HorizontalTop)
954            .begin_symbol(None)
955            .end_symbol(None)
956            .render(buffer.area, &mut buffer, &mut state);
957        let empty_string = " ".repeat(size as usize);
958        assert_eq!(buffer, Buffer::with_lines([expected, &empty_string]));
959    }
960
961    #[rstest]
962    #[case::position_0("<####---->", 0, 10)]
963    #[case::position_1("<#####--->", 1, 10)]
964    #[case::position_2("<-####--->", 2, 10)]
965    #[case::position_3("<-####--->", 3, 10)]
966    #[case::position_4("<--####-->", 4, 10)]
967    #[case::position_5("<--####-->", 5, 10)]
968    #[case::position_6("<---####->", 6, 10)]
969    #[case::position_7("<---####->", 7, 10)]
970    #[case::position_8("<---#####>", 8, 10)]
971    #[case::position_9("<----####>", 9, 10)]
972    #[case::position_one_out_of_bounds("<----####>", 10, 10)]
973    #[case::position_few_out_of_bounds("<----####>", 15, 10)]
974    #[case::position_very_many_out_of_bounds("<----####>", 500, 10)]
975    fn render_scrollbar_vertical_left(
976        #[case] expected: &str,
977        #[case] position: usize,
978        #[case] content_length: usize,
979    ) {
980        let size = expected.width() as u16;
981        let mut buffer = Buffer::empty(Rect::new(0, 0, 5, size));
982        let mut state = ScrollbarState::new(content_length).position(position);
983        Scrollbar::new(ScrollbarOrientation::VerticalLeft)
984            .begin_symbol(Some("<"))
985            .end_symbol(Some(">"))
986            .track_symbol(Some("-"))
987            .thumb_symbol("#")
988            .render(buffer.area, &mut buffer, &mut state);
989        let bar = expected.chars().map(|c| format!("{c}    "));
990        assert_eq!(buffer, Buffer::with_lines(bar));
991    }
992
993    #[rstest]
994    #[case::position_0("<####---->", 0, 10)]
995    #[case::position_1("<#####--->", 1, 10)]
996    #[case::position_2("<-####--->", 2, 10)]
997    #[case::position_3("<-####--->", 3, 10)]
998    #[case::position_4("<--####-->", 4, 10)]
999    #[case::position_5("<--####-->", 5, 10)]
1000    #[case::position_6("<---####->", 6, 10)]
1001    #[case::position_7("<---####->", 7, 10)]
1002    #[case::position_8("<---#####>", 8, 10)]
1003    #[case::position_9("<----####>", 9, 10)]
1004    #[case::position_one_out_of_bounds("<----####>", 10, 10)]
1005    #[case::position_few_out_of_bounds("<----####>", 15, 10)]
1006    #[case::position_very_many_out_of_bounds("<----####>", 500, 10)]
1007    fn render_scrollbar_vertical_right(
1008        #[case] expected: &str,
1009        #[case] position: usize,
1010        #[case] content_length: usize,
1011    ) {
1012        let size = expected.width() as u16;
1013        let mut buffer = Buffer::empty(Rect::new(0, 0, 5, size));
1014        let mut state = ScrollbarState::new(content_length).position(position);
1015        Scrollbar::new(ScrollbarOrientation::VerticalRight)
1016            .begin_symbol(Some("<"))
1017            .end_symbol(Some(">"))
1018            .track_symbol(Some("-"))
1019            .thumb_symbol("#")
1020            .render(buffer.area, &mut buffer, &mut state);
1021        let bar = expected.chars().map(|c| format!("    {c}"));
1022        assert_eq!(buffer, Buffer::with_lines(bar));
1023    }
1024
1025    #[rstest]
1026    #[case::position_0("##--------", 0, 10)]
1027    #[case::position_1("-##-------", 1, 10)]
1028    #[case::position_2("--##------", 2, 10)]
1029    #[case::position_3("---##-----", 3, 10)]
1030    #[case::position_4("----#-----", 4, 10)]
1031    #[case::position_5("-----#----", 5, 10)]
1032    #[case::position_6("-----##---", 6, 10)]
1033    #[case::position_7("------##--", 7, 10)]
1034    #[case::position_8("-------##-", 8, 10)]
1035    #[case::position_9("--------##", 9, 10)]
1036    #[case::position_one_out_of_bounds("--------##", 10, 10)]
1037    fn custom_viewport_length(
1038        #[case] expected: &str,
1039        #[case] position: usize,
1040        #[case] content_length: usize,
1041        scrollbar_no_arrows: Scrollbar,
1042    ) {
1043        let size = expected.width() as u16;
1044        let mut buffer = Buffer::empty(Rect::new(0, 0, size, 1));
1045        let mut state = ScrollbarState::new(content_length)
1046            .position(position)
1047            .viewport_content_length(2);
1048        scrollbar_no_arrows.render(buffer.area, &mut buffer, &mut state);
1049        assert_eq!(buffer, Buffer::with_lines([expected]));
1050    }
1051
1052    /// Fixes <https://github.com/ratatui/ratatui/pull/959> which was a bug that would not
1053    /// render a thumb when the viewport was very small in comparison to the content length.
1054    #[rstest]
1055    #[case::position_0("#----", 0, 100)]
1056    #[case::position_10("#----", 10, 100)]
1057    #[case::position_20("-#---", 20, 100)]
1058    #[case::position_30("-#---", 30, 100)]
1059    #[case::position_40("--#--", 40, 100)]
1060    #[case::position_50("--#--", 50, 100)]
1061    #[case::position_60("---#-", 60, 100)]
1062    #[case::position_70("---#-", 70, 100)]
1063    #[case::position_80("----#", 80, 100)]
1064    #[case::position_90("----#", 90, 100)]
1065    #[case::position_one_out_of_bounds("----#", 100, 100)]
1066    fn thumb_visible_on_very_small_track(
1067        #[case] expected: &str,
1068        #[case] position: usize,
1069        #[case] content_length: usize,
1070        scrollbar_no_arrows: Scrollbar,
1071    ) {
1072        let size = expected.width() as u16;
1073        let mut buffer = Buffer::empty(Rect::new(0, 0, size, 1));
1074        let mut state = ScrollbarState::new(content_length)
1075            .position(position)
1076            .viewport_content_length(2);
1077        scrollbar_no_arrows.render(buffer.area, &mut buffer, &mut state);
1078        assert_eq!(buffer, Buffer::with_lines([expected]));
1079    }
1080
1081    #[rstest]
1082    #[case::scrollbar_height_0(10, 0)]
1083    #[case::scrollbar_width_0(0, 10)]
1084    fn do_not_render_with_empty_area(#[case] width: u16, #[case] height: u16) {
1085        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
1086            .begin_symbol(Some("<"))
1087            .end_symbol(Some(">"))
1088            .track_symbol(Some("-"))
1089            .thumb_symbol("#");
1090        let zero_width_area = Rect::new(0, 0, width, height);
1091        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 10));
1092
1093        let mut state = ScrollbarState::new(10);
1094        scrollbar.render(zero_width_area, &mut buffer, &mut state);
1095    }
1096
1097    #[rstest]
1098    #[case::vertical_left(ScrollbarOrientation::VerticalLeft)]
1099    #[case::vertical_right(ScrollbarOrientation::VerticalRight)]
1100    #[case::horizontal_top(ScrollbarOrientation::HorizontalTop)]
1101    #[case::horizontal_bottom(ScrollbarOrientation::HorizontalBottom)]
1102    fn render_in_minimal_buffer(#[case] orientation: ScrollbarOrientation) {
1103        let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1));
1104        let scrollbar = Scrollbar::new(orientation);
1105        let mut state = ScrollbarState::new(10).position(5);
1106        // This should not panic, even if the buffer is too small to render the scrollbar.
1107        scrollbar.render(buffer.area, &mut buffer, &mut state);
1108        assert_eq!(buffer, Buffer::with_lines([" "]));
1109    }
1110
1111    #[rstest]
1112    #[case::vertical_left(ScrollbarOrientation::VerticalLeft)]
1113    #[case::vertical_right(ScrollbarOrientation::VerticalRight)]
1114    #[case::horizontal_top(ScrollbarOrientation::HorizontalTop)]
1115    #[case::horizontal_bottom(ScrollbarOrientation::HorizontalBottom)]
1116    fn render_in_zero_size_buffer(#[case] orientation: ScrollbarOrientation) {
1117        let mut buffer = Buffer::empty(Rect::ZERO);
1118        let scrollbar = Scrollbar::new(orientation);
1119        let mut state = ScrollbarState::new(10).position(5);
1120        // This should not panic, even if the buffer has zero size.
1121        scrollbar.render(buffer.area, &mut buffer, &mut state);
1122    }
1123}