Skip to main content

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