tui_scrollbar/scrollbar/mod.rs
1//! Rendering and interaction for proportional scrollbars.
2//!
3//! This module provides the widget, glyph selection, and interaction helpers. The pure math lives
4//! in [`crate::metrics`].
5//!
6//! # How the parts interact
7//!
8//! 1. Your app owns `content_len`, `viewport_len`, and `offset`.
9//! 2. [`ScrollMetrics`] converts them into thumb geometry.
10//! 3. [`ScrollBar`] renders using the selected [`GlyphSet`].
11//! 4. Input events update `offset` via [`ScrollCommand`].
12//!
13//! The scrollbar renders only a single row or column. If you provide a larger [`Rect`], it will
14//! still render into the first row/column of that area.
15//!
16//! ## Layout choices
17//!
18//! The widget treats the provided area as the track container. When arrows are enabled, one cell
19//! at each end is reserved for the endcaps and the remaining inner area is used for the thumb.
20//!
21//! Arrow endcaps are optional. When enabled, they consume one cell at the start/end of the track,
22//! and the thumb renders inside the remaining inner area.
23//!
24//! ## Interaction choices
25//!
26//! - The widget is stateless: it renders from inputs and returns commands instead of mutating
27//! scroll offsets. This keeps control with the application.
28//! - Dragging stores a grab offset in subcells so the thumb does not jump under the pointer.
29//! - Arrow endcaps consume track space; the inner track is used for metrics and hit testing so
30//! thumb math stays consistent regardless of arrows.
31//!
32//! Partial glyph selection uses [`CellFill::Partial`]: `start == 0` means the partial fill begins
33//! at the leading edge (top/left), so the upper/left glyphs are chosen. Non-zero `start` uses the
34//! lower/right glyphs to indicate a trailing-edge fill.
35//!
36//! Drag operations store a "grab offset" in subcells (1/8 of a cell; see [`crate::SUBCELL`]) so the
37//! thumb does not jump when the pointer starts dragging; subsequent drag events subtract that
38//! offset to keep the grab point stable.
39//!
40//! Wheel events are ignored unless their axis matches the scrollbar orientation. Positive deltas
41//! scroll down/right.
42//!
43//! The example below renders a vertical scrollbar into a buffer. It demonstrates how the widget
44//! uses `content_len`, `viewport_len`, and `offset` to decide the thumb size and position.
45//!
46//! ```rust
47//! use ratatui_core::buffer::Buffer;
48//! use ratatui_core::layout::Rect;
49//! use ratatui_core::widgets::Widget;
50//! use tui_scrollbar::{ScrollBar, ScrollLengths};
51//!
52//! let area = Rect::new(0, 0, 1, 4);
53//! let lengths = ScrollLengths {
54//! content_len: 120,
55//! viewport_len: 40,
56//! };
57//! let scrollbar = ScrollBar::vertical(lengths).offset(20);
58//!
59//! let mut buffer = Buffer::empty(area);
60//! scrollbar.render(area, &mut buffer);
61//! ```
62//!
63//! [`Rect`]: ratatui_core::layout::Rect
64
65use ratatui_core::layout::Rect;
66use ratatui_core::style::{Color, Style};
67
68use crate::glyphs::GlyphSet;
69
70mod interaction;
71mod render;
72
73/// Axis the scrollbar is laid out on.
74///
75/// Orientation determines whether the track length is derived from height or width.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ScrollBarOrientation {
78 /// A vertical scrollbar that fills a single column.
79 Vertical,
80 /// A horizontal scrollbar that fills a single row.
81 Horizontal,
82}
83
84/// Behavior when the user clicks on the track outside the thumb.
85///
86/// Page clicks move by `viewport_len`. Jump-to-click centers the thumb near the click.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum TrackClickBehavior {
89 /// Move by one viewport length toward the click position.
90 Page,
91 /// Jump the thumb toward the click position.
92 JumpToClick,
93}
94
95/// Which arrow endcaps to render on the track.
96#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
97pub enum ScrollBarArrows {
98 /// Do not render arrow endcaps.
99 None,
100 /// Render the arrow at the start of the track (top/left).
101 Start,
102 /// Render the arrow at the end of the track (bottom/right).
103 End,
104 /// Render arrows at both ends of the track.
105 #[default]
106 Both,
107}
108
109impl ScrollBarArrows {
110 const fn has_start(self) -> bool {
111 matches!(self, Self::Start | Self::Both)
112 }
113
114 const fn has_end(self) -> bool {
115 matches!(self, Self::End | Self::Both)
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120enum ArrowHit {
121 Start,
122 End,
123}
124
125#[derive(Debug, Clone, Copy)]
126struct ArrowLayout {
127 track_area: Rect,
128 start: Option<(u16, u16)>,
129 end: Option<(u16, u16)>,
130}
131
132/// A proportional scrollbar widget with fractional thumb rendering.
133///
134/// # Key methods
135///
136/// - [`Self::new`]
137/// - [`Self::orientation`]
138/// - [`Self::arrows`]
139/// - [`Self::content_len`]
140/// - [`Self::viewport_len`]
141/// - [`Self::offset`]
142///
143/// # Important
144///
145/// - `content_len` and `viewport_len` are in logical units.
146/// - Zero values are treated as 1.
147/// - The scrollbar renders into a single row or column.
148///
149/// # Behavior
150///
151/// The thumb length is proportional to `viewport_len / content_len` and clamped to at least one
152/// full cell for usability. When `content_len <= viewport_len`, the thumb fills the track. Areas
153/// with zero width or height render nothing.
154///
155/// Arrow endcaps, when enabled, consume one cell at the start/end of the track. The thumb and
156/// track render in the remaining inner area. Clicking an arrow steps the offset by `scroll_step`.
157///
158/// # Styling
159///
160/// Track glyphs use `track_style`. Thumb glyphs use `thumb_style`. Arrow endcaps use
161/// `arrow_style`, which defaults to white on dark gray.
162///
163/// # State
164///
165/// This widget is stateless. Pointer drag state lives in [`ScrollBarInteraction`].
166///
167/// # Examples
168///
169/// ```rust
170/// use ratatui_core::buffer::Buffer;
171/// use ratatui_core::layout::Rect;
172/// use ratatui_core::widgets::Widget;
173/// use tui_scrollbar::{ScrollBar, ScrollLengths};
174///
175/// let area = Rect::new(0, 0, 1, 5);
176/// let lengths = ScrollLengths {
177/// content_len: 200,
178/// viewport_len: 40,
179/// };
180/// let scrollbar = ScrollBar::vertical(lengths).offset(60);
181///
182/// let mut buffer = Buffer::empty(area);
183/// scrollbar.render(area, &mut buffer);
184/// ```
185///
186/// ## Updating offsets on input
187///
188/// This is the typical pattern for pointer handling: feed events to the scrollbar and apply the
189/// returned command to your stored offset.
190///
191/// ```rust,no_run
192/// use ratatui_core::layout::Rect;
193/// use tui_scrollbar::{
194/// PointerButton, PointerEvent, PointerEventKind, ScrollBar, ScrollBarInteraction,
195/// ScrollCommand, ScrollEvent, ScrollLengths,
196/// };
197///
198/// let area = Rect::new(0, 0, 1, 10);
199/// let lengths = ScrollLengths {
200/// content_len: 400,
201/// viewport_len: 80,
202/// };
203/// let scrollbar = ScrollBar::vertical(lengths).offset(0);
204/// let mut interaction = ScrollBarInteraction::new();
205/// let mut offset = 0;
206///
207/// let event = ScrollEvent::Pointer(PointerEvent {
208/// column: 0,
209/// row: 3,
210/// kind: PointerEventKind::Down,
211/// button: PointerButton::Primary,
212/// });
213///
214/// if let Some(ScrollCommand::SetOffset(next)) =
215/// scrollbar.handle_event(area, event, &mut interaction)
216/// {
217/// offset = next;
218/// }
219/// # let _ = offset;
220/// ```
221///
222/// ## Track click behavior
223///
224/// Choose between classic page jumps or jump-to-click behavior.
225///
226/// ```rust
227/// use tui_scrollbar::{ScrollBar, ScrollLengths, TrackClickBehavior};
228///
229/// let lengths = ScrollLengths {
230/// content_len: 10,
231/// viewport_len: 5,
232/// };
233/// let scrollbar =
234/// ScrollBar::vertical(lengths).track_click_behavior(TrackClickBehavior::JumpToClick);
235/// ```
236///
237/// ## Arrow endcaps
238///
239/// Arrow endcaps are optional. When enabled, they reserve one cell at each end of the track.
240///
241/// ```rust
242/// use tui_scrollbar::{ScrollBar, ScrollBarArrows, ScrollLengths};
243///
244/// let lengths = ScrollLengths {
245/// content_len: 120,
246/// viewport_len: 24,
247/// };
248/// let scrollbar = ScrollBar::vertical(lengths).arrows(ScrollBarArrows::Both);
249/// ```
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct ScrollBar {
252 orientation: ScrollBarOrientation,
253 content_len: usize,
254 viewport_len: usize,
255 offset: usize,
256 track_style: Style,
257 thumb_style: Style,
258 arrow_style: Option<Style>,
259 glyph_set: GlyphSet,
260 arrows: ScrollBarArrows,
261 track_click_behavior: TrackClickBehavior,
262 scroll_step: usize,
263}
264
265impl ScrollBar {
266 /// Creates a scrollbar with the given orientation and lengths.
267 ///
268 /// Zero lengths are treated as 1.
269 ///
270 /// ```rust
271 /// use tui_scrollbar::{ScrollBar, ScrollBarOrientation, ScrollLengths};
272 ///
273 /// let lengths = ScrollLengths {
274 /// content_len: 120,
275 /// viewport_len: 40,
276 /// };
277 /// let scrollbar = ScrollBar::new(ScrollBarOrientation::Vertical, lengths);
278 /// ```
279 pub fn new(orientation: ScrollBarOrientation, lengths: crate::ScrollLengths) -> Self {
280 Self {
281 orientation,
282 content_len: lengths.content_len,
283 viewport_len: lengths.viewport_len,
284 offset: 0,
285 track_style: Style::new().bg(Color::DarkGray),
286 thumb_style: Style::new().fg(Color::White).bg(Color::DarkGray),
287 arrow_style: Some(Style::new().fg(Color::White).bg(Color::DarkGray)),
288 glyph_set: GlyphSet::default(),
289 arrows: ScrollBarArrows::default(),
290 track_click_behavior: TrackClickBehavior::Page,
291 scroll_step: 1,
292 }
293 }
294
295 /// Creates a vertical scrollbar with the given content and viewport lengths.
296 pub fn vertical(lengths: crate::ScrollLengths) -> Self {
297 Self::new(ScrollBarOrientation::Vertical, lengths)
298 }
299
300 /// Creates a horizontal scrollbar with the given content and viewport lengths.
301 pub fn horizontal(lengths: crate::ScrollLengths) -> Self {
302 Self::new(ScrollBarOrientation::Horizontal, lengths)
303 }
304
305 /// Sets the scrollbar orientation.
306 pub const fn orientation(mut self, orientation: ScrollBarOrientation) -> Self {
307 self.orientation = orientation;
308 self
309 }
310
311 /// Sets the total scrollable content length in logical units.
312 ///
313 /// Larger values shrink the thumb, while smaller values enlarge it.
314 ///
315 /// Zero values are treated as 1.
316 pub const fn content_len(mut self, content_len: usize) -> Self {
317 self.content_len = content_len;
318 self
319 }
320
321 /// Sets the visible viewport length in logical units.
322 ///
323 /// When `viewport_len >= content_len`, the thumb fills the track.
324 ///
325 /// Zero values are treated as 1.
326 pub const fn viewport_len(mut self, viewport_len: usize) -> Self {
327 self.viewport_len = viewport_len;
328 self
329 }
330
331 /// Sets the current scroll offset in logical units.
332 ///
333 /// Offsets are clamped to `content_len - viewport_len` during rendering.
334 pub const fn offset(mut self, offset: usize) -> Self {
335 self.offset = offset;
336 self
337 }
338
339 /// Sets the style applied to track glyphs.
340 ///
341 /// Track styling applies only where the thumb is not rendered.
342 pub const fn track_style(mut self, style: Style) -> Self {
343 self.track_style = style;
344 self
345 }
346
347 /// Sets the style applied to thumb glyphs.
348 ///
349 /// Thumb styling overrides track styling for covered cells.
350 pub const fn thumb_style(mut self, style: Style) -> Self {
351 self.thumb_style = style;
352 self
353 }
354
355 /// Sets the style applied to arrow glyphs.
356 ///
357 /// Defaults to white on dark gray.
358 pub const fn arrow_style(mut self, style: Style) -> Self {
359 self.arrow_style = Some(style);
360 self
361 }
362
363 /// Selects the glyph set used to render the track and thumb.
364 ///
365 /// [`GlyphSet::symbols_for_legacy_computing`] uses additional symbols for 1/8th upper/right
366 /// fills. Use [`GlyphSet::unicode`] if you want to avoid the legacy supplement.
367 pub const fn glyph_set(mut self, glyph_set: GlyphSet) -> Self {
368 self.glyph_set = glyph_set;
369 self
370 }
371
372 /// Sets which arrow endcaps are rendered.
373 pub const fn arrows(mut self, arrows: ScrollBarArrows) -> Self {
374 self.arrows = arrows;
375 self
376 }
377
378 /// Sets behavior for clicks on the track outside the thumb.
379 ///
380 /// Use [`TrackClickBehavior::Page`] for classic page-up/down behavior, or
381 /// [`TrackClickBehavior::JumpToClick`] to move the thumb toward the click.
382 pub const fn track_click_behavior(mut self, behavior: TrackClickBehavior) -> Self {
383 self.track_click_behavior = behavior;
384 self
385 }
386
387 /// Sets the scroll step used for wheel events.
388 ///
389 /// The wheel delta is multiplied by this value (in your logical units) and then clamped.
390 pub fn scroll_step(mut self, step: usize) -> Self {
391 self.scroll_step = step.max(1);
392 self
393 }
394
395 /// Computes the inner track area and arrow cell positions for this orientation.
396 fn arrow_layout(&self, area: Rect) -> ArrowLayout {
397 let mut track_area = area;
398 let (start, end) = match self.orientation {
399 ScrollBarOrientation::Vertical => {
400 let start_enabled = self.arrows.has_start() && area.height > 0;
401 let end_enabled = self.arrows.has_end() && area.height > start_enabled as u16;
402 let start = start_enabled.then_some((area.x, area.y));
403 let end = end_enabled
404 .then_some((area.x, area.y.saturating_add(area.height).saturating_sub(1)));
405 if start_enabled {
406 track_area.y = track_area.y.saturating_add(1);
407 track_area.height = track_area.height.saturating_sub(1);
408 }
409 if end_enabled {
410 track_area.height = track_area.height.saturating_sub(1);
411 }
412 (start, end)
413 }
414 ScrollBarOrientation::Horizontal => {
415 let start_enabled = self.arrows.has_start() && area.width > 0;
416 let end_enabled = self.arrows.has_end() && area.width > start_enabled as u16;
417 let start = start_enabled.then_some((area.x, area.y));
418 let end = end_enabled
419 .then_some((area.x.saturating_add(area.width).saturating_sub(1), area.y));
420 if start_enabled {
421 track_area.x = track_area.x.saturating_add(1);
422 track_area.width = track_area.width.saturating_sub(1);
423 }
424 if end_enabled {
425 track_area.width = track_area.width.saturating_sub(1);
426 }
427 (start, end)
428 }
429 };
430
431 ArrowLayout {
432 track_area,
433 start,
434 end,
435 }
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use ratatui_core::style::{Color, Style};
442
443 use super::*;
444 use crate::glyphs::GlyphSet;
445 use crate::ScrollLengths;
446
447 #[test]
448 fn builder_methods_update_fields() {
449 let lengths = ScrollLengths {
450 content_len: 10,
451 viewport_len: 4,
452 };
453 let track_style = Style::new().fg(Color::Red);
454 let thumb_style = Style::new().bg(Color::Blue);
455 let arrow_style = Style::new().fg(Color::Green);
456 let glyphs = GlyphSet::unicode();
457
458 let scrollbar = ScrollBar::new(ScrollBarOrientation::Vertical, lengths)
459 .orientation(ScrollBarOrientation::Horizontal)
460 .content_len(20)
461 .viewport_len(5)
462 .offset(3)
463 .track_style(track_style)
464 .thumb_style(thumb_style)
465 .arrow_style(arrow_style)
466 .glyph_set(glyphs.clone())
467 .arrows(ScrollBarArrows::End)
468 .track_click_behavior(TrackClickBehavior::JumpToClick)
469 .scroll_step(0);
470
471 assert_eq!(scrollbar.orientation, ScrollBarOrientation::Horizontal);
472 assert_eq!(scrollbar.content_len, 20);
473 assert_eq!(scrollbar.viewport_len, 5);
474 assert_eq!(scrollbar.offset, 3);
475 assert_eq!(scrollbar.track_style, track_style);
476 assert_eq!(scrollbar.thumb_style, thumb_style);
477 assert_eq!(scrollbar.arrow_style, Some(arrow_style));
478 assert_eq!(scrollbar.glyph_set, glyphs);
479 assert_eq!(scrollbar.arrows, ScrollBarArrows::End);
480 assert_eq!(
481 scrollbar.track_click_behavior,
482 TrackClickBehavior::JumpToClick
483 );
484 assert_eq!(scrollbar.scroll_step, 1);
485 }
486
487 #[test]
488 fn constructors_set_orientation() {
489 let lengths = ScrollLengths {
490 content_len: 10,
491 viewport_len: 4,
492 };
493 let vertical = ScrollBar::vertical(lengths);
494 let horizontal = ScrollBar::horizontal(lengths);
495
496 assert_eq!(vertical.orientation, ScrollBarOrientation::Vertical);
497 assert_eq!(horizontal.orientation, ScrollBarOrientation::Horizontal);
498 }
499
500 #[test]
501 fn reserves_track_cells_for_arrows() {
502 let lengths = ScrollLengths {
503 content_len: 10,
504 viewport_len: 4,
505 };
506 let scrollbar = ScrollBar::vertical(lengths).arrows(ScrollBarArrows::Both);
507 let area = Rect::new(0, 0, 1, 5);
508 let layout = scrollbar.arrow_layout(area);
509
510 assert_eq!(layout.track_area.height, 3);
511 assert_eq!(layout.start, Some((area.x, area.y)));
512 assert_eq!(
513 layout.end,
514 Some((area.x, area.y.saturating_add(area.height).saturating_sub(1)))
515 );
516 }
517
518 #[test]
519 fn reserves_track_cells_for_horizontal_arrows() {
520 let lengths = ScrollLengths {
521 content_len: 10,
522 viewport_len: 4,
523 };
524 let scrollbar = ScrollBar::horizontal(lengths).arrows(ScrollBarArrows::Both);
525 let area = Rect::new(0, 0, 5, 1);
526 let layout = scrollbar.arrow_layout(area);
527
528 assert_eq!(layout.track_area.width, 3);
529 assert_eq!(layout.start, Some((area.x, area.y)));
530 assert_eq!(
531 layout.end,
532 Some((area.x.saturating_add(area.width).saturating_sub(1), area.y))
533 );
534 }
535}