use ratatui::{
buffer::Buffer,
layout::{Position, Rect, Size},
style::{Modifier, Style},
symbols::scrollbar::Set,
text::Span,
widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget},
};
use ratatui_kit_macros::Props;
#[derive(Debug, Clone, Hash)]
pub struct ScrollbarStyle<'a> {
pub style: Style,
pub thumb_style: Style,
pub track_style: Style,
pub hover_style: Style,
pub drag_style: Style,
pub symbols: Option<Set<'a>>,
pub arrows: bool,
}
impl Default for ScrollbarStyle<'_> {
fn default() -> Self {
Self {
style: Style::default(),
thumb_style: Style::default(),
track_style: Style::default(),
hover_style: Style::default().add_modifier(Modifier::BOLD),
drag_style: Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED),
symbols: None,
arrows: true,
}
}
}
impl ScrollbarStyle<'_> {
pub(crate) fn widget_with_state(
&self,
orientation: ScrollbarOrientation,
hovered: bool,
dragging: bool,
) -> Scrollbar<'_> {
let feedback = if dragging {
self.drag_style
} else if hovered {
self.hover_style
} else {
Style::default()
};
let mut scrollbar = Scrollbar::new(orientation)
.style(self.style)
.thumb_style(self.style.patch(self.thumb_style).patch(feedback))
.track_style(self.style.patch(self.track_style));
if let Some(symbols) = &self.symbols {
scrollbar = scrollbar.symbols(symbols.clone());
}
if !self.arrows {
scrollbar = scrollbar.begin_symbol(None).end_symbol(None);
}
scrollbar
}
pub(crate) fn arrow_lengths(&self) -> (u16, u16) {
if !self.arrows {
return (0, 0);
}
self.symbols.as_ref().map_or((1, 1), |symbols| {
(
Span::raw(symbols.begin).width().min(u16::MAX as usize) as u16,
Span::raw(symbols.end).width().min(u16::MAX as usize) as u16,
)
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AxisGeometry {
pub track: Rect,
pub usable: u16,
pub begin_length: u16,
pub viewport: usize,
pub scrollable: usize,
pub offset: usize,
}
impl AxisGeometry {
pub(crate) fn new(
track: Rect,
viewport: usize,
scrollable: usize,
offset: usize,
vertical: bool,
arrows: (u16, u16),
) -> Option<Self> {
let full = if vertical { track.height } else { track.width };
let usable = full.saturating_sub(arrows.0.saturating_add(arrows.1));
if track.is_empty() || usable == 0 || viewport == 0 || scrollable == 0 {
return None;
}
Some(Self {
track,
usable,
begin_length: arrows.0,
viewport,
scrollable,
offset,
})
}
fn arrow_seek(&self, position: Position, vertical: bool) -> Option<usize> {
let along = if vertical {
position.y - self.track.y
} else {
position.x - self.track.x
};
if along < self.begin_length {
Some(self.offset.saturating_sub(1))
} else if along >= self.begin_length + self.usable {
Some(self.offset.saturating_add(1).min(self.scrollable))
} else {
None
}
}
fn max_position(&self) -> usize {
self.scrollable
}
fn max_viewport_position(&self) -> usize {
self.max_position().saturating_add(self.viewport)
}
pub fn thumb_len(&self) -> u16 {
let raw = (self.viewport as u128 * self.usable as u128
+ self.max_viewport_position() as u128 / 2)
/ self.max_viewport_position().max(1) as u128;
(raw as u16).clamp(1, self.usable)
}
pub fn thumb_start(&self) -> u16 {
let raw = (self.offset as u128 * self.usable as u128
+ self.max_viewport_position() as u128 / 2)
/ self.max_viewport_position().max(1) as u128;
(raw as u16).clamp(0, self.usable - self.thumb_len())
}
pub fn thumb_center(&self) -> f64 {
f64::from(self.thumb_start()) + f64::from(self.thumb_len()) / 2.0
}
pub fn offset_for_thumb_center(&self, center: f64) -> usize {
let half = f64::from(self.thumb_len()) / 2.0;
if center <= half {
return 0;
}
if center >= f64::from(self.usable) - half {
return self.max_position();
}
let raw = ((center - f64::from(self.thumb_len()) / 2.0)
* self.max_viewport_position() as f64
/ f64::from(self.usable))
.round();
(raw.max(0.0) as usize).min(self.max_position())
}
pub fn along_clamped(&self, position: Position, vertical: bool) -> f64 {
let raw = if vertical {
position.y.saturating_sub(self.track.y)
} else {
position.x.saturating_sub(self.track.x)
};
f64::from(raw.saturating_sub(self.begin_length).min(self.usable))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct ScrollbarGeometry {
pub vertical: Option<AxisGeometry>,
pub horizontal: Option<AxisGeometry>,
}
impl ScrollbarGeometry {
pub fn hit(&self, position: Position) -> Option<(&AxisGeometry, f64, bool)> {
if let Some(axis) = &self.vertical
&& axis.track.contains(position)
{
return Some((axis, axis.along_clamped(position, true), true));
}
if let Some(axis) = &self.horizontal
&& axis.track.contains(position)
{
return Some((axis, axis.along_clamped(position, false), false));
}
None
}
}
#[derive(Default)]
pub(crate) struct ScrollbarInteraction {
pub(crate) geometry: ScrollbarGeometry,
drag: Option<DragAxis>,
hovered: Option<bool>,
}
#[derive(Clone, Copy)]
struct DragAxis {
vertical: bool,
grab: f64,
}
impl ScrollbarInteraction {
pub(crate) fn is_hovered(&self, vertical: bool) -> bool {
self.hovered == Some(vertical)
}
pub(crate) fn is_dragging(&self, vertical: bool) -> bool {
self.drag.is_some_and(|drag| drag.vertical == vertical)
}
pub(crate) fn handle(
&mut self,
event: crate::PointerEvent,
mut seek: impl FnMut(usize, bool),
) -> crate::EventResult {
use crate::{EventResult, PointerKind};
use crossterm::event::MouseButton;
match event.kind {
PointerKind::Enter | PointerKind::Move | PointerKind::Leave => {
let next = if matches!(event.kind, PointerKind::Leave) {
None
} else {
self.geometry
.hit(event.position)
.map(|(_, _, vertical)| vertical)
};
if self.hovered != next {
self.hovered = next;
EventResult::Consumed
} else {
EventResult::Ignored
}
}
PointerKind::Down(MouseButton::Left) => {
let Some((axis, along, vertical)) = self.geometry.hit(event.position) else {
return EventResult::Ignored;
};
let axis = *axis;
if let Some(target) = axis.arrow_seek(event.position, vertical) {
seek(target, vertical);
return EventResult::Consumed;
}
let start = f64::from(axis.thumb_start());
let on_thumb = along >= start && along < start + f64::from(axis.thumb_len());
let grab = if on_thumb {
along - axis.thumb_center()
} else {
0.0
};
self.hovered = Some(vertical);
self.drag = Some(DragAxis { vertical, grab });
if !on_thumb {
seek(axis.offset_for_thumb_center(along), vertical);
}
EventResult::Consumed
}
PointerKind::Drag(MouseButton::Left) => {
self.hovered = self
.geometry
.hit(event.position)
.map(|(_, _, vertical)| vertical);
let Some(drag) = self.drag else {
return EventResult::Ignored;
};
let axis = if drag.vertical {
self.geometry.vertical
} else {
self.geometry.horizontal
};
if let Some(axis) = axis {
let along = axis.along_clamped(event.position, drag.vertical);
seek(
axis.offset_for_thumb_center(along - drag.grab),
drag.vertical,
);
}
EventResult::Consumed
}
PointerKind::Cancel(MouseButton::Left) => {
self.drag = None;
self.hovered = None;
EventResult::Ignored
}
PointerKind::Up(MouseButton::Left) => {
self.hovered = self
.geometry
.hit(event.position)
.map(|(_, _, vertical)| vertical);
if self.drag.take().is_some() {
EventResult::Consumed
} else {
EventResult::Ignored
}
}
_ => EventResult::Ignored,
}
}
}
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)]
pub enum ScrollbarVisibility {
#[default]
Automatic,
Always,
Never,
}
#[derive(Props, Clone, Hash)]
pub struct Scrollbars<'a> {
pub vertical_scrollbar_visibility: ScrollbarVisibility,
pub horizontal_scrollbar_visibility: ScrollbarVisibility,
pub vertical_scrollbar: ScrollbarStyle<'a>,
pub horizontal_scrollbar: ScrollbarStyle<'a>,
pub over_border: bool,
}
impl Default for Scrollbars<'_> {
fn default() -> Self {
Self {
vertical_scrollbar_visibility: ScrollbarVisibility::Automatic,
horizontal_scrollbar_visibility: ScrollbarVisibility::Automatic,
vertical_scrollbar: ScrollbarStyle::default(),
horizontal_scrollbar: ScrollbarStyle::default(),
over_border: true,
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ResolvedLayout {
pub content: Size,
pub offset: Position,
pub viewport: Rect,
pub vertical_track: Option<Rect>,
pub horizontal_track: Option<Rect>,
}
impl Scrollbars<'_> {
fn axis_show(visibility: ScrollbarVisibility, space: i32) -> bool {
match visibility {
ScrollbarVisibility::Always => true,
ScrollbarVisibility::Never => false,
ScrollbarVisibility::Automatic => space < 0,
}
}
pub(crate) fn resolve_layout(
&self,
outer: Rect,
inner: Rect,
border_ring: bool,
mut content_size: impl FnMut(Size) -> Size,
) -> ResolvedLayout {
let ring = self.over_border && border_ring;
let resolve = |horizontal: bool, vertical: bool| Rect {
width: inner.width.saturating_sub((vertical && !ring) as u16),
height: inner.height.saturating_sub((horizontal && !ring) as u16),
..inner
};
let build = |content: Size, viewport: Rect, horizontal: bool, vertical: bool| {
let track = |area: Rect| {
let area = area.intersection(outer);
(!area.is_empty()).then_some(area)
};
ResolvedLayout {
content,
offset: Position::ORIGIN,
viewport,
vertical_track: vertical
.then(|| {
track(Rect::new(
if ring {
outer.right().saturating_sub(1)
} else {
viewport.right()
},
viewport.y,
1,
viewport.height,
))
})
.flatten(),
horizontal_track: horizontal
.then(|| {
track(Rect::new(
viewport.x,
if ring {
outer.bottom().saturating_sub(1)
} else {
viewport.bottom()
},
viewport.width,
1,
))
})
.flatten(),
}
};
for (horizontal, vertical) in [(false, false), (false, true), (true, false), (true, true)] {
let viewport = resolve(horizontal, vertical);
let content = content_size(viewport.as_size());
let expected = (
Self::axis_show(
self.horizontal_scrollbar_visibility,
i32::from(viewport.width) - i32::from(content.width),
),
Self::axis_show(
self.vertical_scrollbar_visibility,
i32::from(viewport.height) - i32::from(content.height),
),
);
if expected == (horizontal, vertical) {
return build(content, viewport, horizontal, vertical);
}
}
let horizontal = self.horizontal_scrollbar_visibility != ScrollbarVisibility::Never;
let vertical = self.vertical_scrollbar_visibility != ScrollbarVisibility::Never;
let viewport = resolve(horizontal, vertical);
build(
content_size(viewport.as_size()),
viewport,
horizontal,
vertical,
)
}
fn render_visible_area(&self, dst: Rect, buf: &mut Buffer, src: Rect, scroll_buffer: &Buffer) {
for (src_row, dst_row) in src.rows().zip(dst.rows()) {
for (src_col, dst_col) in src_row.columns().zip(dst_row.columns()) {
buf[dst_col] = scroll_buffer[src_col].clone();
}
}
}
fn render_scrollbar(
scrollbar: Scrollbar<'_>,
area: Rect,
buf: &mut Buffer,
position: u16,
content_len: u16,
viewport_len: u16,
) {
let hidden = content_len.saturating_sub(viewport_len);
let mut scrollbar_state = ScrollbarState::new(hidden as usize + 1)
.position(position as usize)
.viewport_content_length(viewport_len as usize);
scrollbar.render(area, buf, &mut scrollbar_state);
}
pub fn visible_scrollbars(&self, horizontal_space: i32, vertical_space: i32) -> (bool, bool) {
let mut shown = (
Self::axis_show(self.horizontal_scrollbar_visibility, horizontal_space),
Self::axis_show(self.vertical_scrollbar_visibility, vertical_space),
);
loop {
let next = (
Self::axis_show(
self.horizontal_scrollbar_visibility,
horizontal_space.saturating_sub(i32::from(shown.1)),
),
Self::axis_show(
self.vertical_scrollbar_visibility,
vertical_space.saturating_sub(i32::from(shown.0)),
),
);
if next == shown {
return shown;
}
shown = next;
}
}
pub(crate) fn render_ref(
&self,
layout: ResolvedLayout,
buf: &mut Buffer,
scroll_buffer: &Buffer,
interaction: &ScrollbarInteraction,
) -> ScrollbarGeometry {
let content = layout.content;
let viewport = layout.viewport.as_size();
let x = layout.offset.x;
let y = layout.offset.y;
let src = Rect::new(x, y, viewport.width, viewport.height).intersection(scroll_buffer.area);
let dst = layout.viewport;
self.render_visible_area(dst, buf, src, scroll_buffer);
let mut geometry = ScrollbarGeometry::default();
if let Some(area) = layout.vertical_track {
Self::render_scrollbar(
self.vertical_scrollbar.widget_with_state(
ScrollbarOrientation::VerticalRight,
interaction.is_hovered(true),
interaction.is_dragging(true),
),
area,
buf,
y,
content.height,
viewport.height,
);
geometry.vertical = AxisGeometry::new(
area,
viewport.height as usize,
content.height.saturating_sub(viewport.height) as usize,
y as usize,
true,
self.vertical_scrollbar.arrow_lengths(),
);
}
if let Some(area) = layout.horizontal_track {
Self::render_scrollbar(
self.horizontal_scrollbar.widget_with_state(
ScrollbarOrientation::HorizontalBottom,
interaction.is_hovered(false),
interaction.is_dragging(false),
),
area,
buf,
x,
content.width,
viewport.width,
);
geometry.horizontal = AxisGeometry::new(
area,
viewport.width as usize,
content.width.saturating_sub(viewport.width) as usize,
x as usize,
false,
self.horizontal_scrollbar.arrow_lengths(),
);
}
geometry
}
}
#[cfg(test)]
mod interaction_tests {
use super::*;
use crate::{PointerEvent, PointerKind};
use crossterm::event::{KeyModifiers, MouseButton};
#[test]
fn cancellation_clears_drag_feedback_without_seeking() {
let track = Rect::new(2, 3, 1, 12);
let mut interaction = ScrollbarInteraction::default();
interaction.geometry.vertical = AxisGeometry::new(track, 10, 100, 0, true, (1, 1));
let event = |kind| PointerEvent {
kind,
position: Position::new(2, 4),
screen_position: Position::new(2, 4),
modifiers: KeyModifiers::NONE,
area: track,
};
interaction.handle(event(PointerKind::Down(MouseButton::Left)), |_, _| {
panic!("thumb grab must not seek")
});
assert!(interaction.is_dragging(true));
interaction.handle(event(PointerKind::Cancel(MouseButton::Left)), |_, _| {
panic!("cancel must not seek")
});
assert!(!interaction.is_dragging(true));
assert!(!interaction.is_hovered(true));
}
#[test]
fn arrow_clicks_step_without_starting_a_drag() {
for vertical in [true, false] {
let track = if vertical {
Rect::new(2, 3, 1, 12)
} else {
Rect::new(2, 3, 12, 1)
};
let axis = AxisGeometry::new(track, 10, 100, 50, vertical, (1, 1)).unwrap();
let mut interaction = ScrollbarInteraction::default();
if vertical {
interaction.geometry.vertical = Some(axis);
} else {
interaction.geometry.horizontal = Some(axis);
}
for (position, expected) in [
(Position::new(2, 3), 49),
(
if vertical {
Position::new(2, 14)
} else {
Position::new(13, 3)
},
51,
),
] {
let mut target = None;
interaction.handle(
PointerEvent {
kind: PointerKind::Down(MouseButton::Left),
position,
screen_position: position,
modifiers: KeyModifiers::NONE,
area: track,
},
|offset, _| target = Some(offset),
);
assert_eq!(target, Some(expected));
assert!(!interaction.is_dragging(vertical));
}
}
}
}
#[cfg(test)]
mod layout_tests {
use super::*;
use ratatui::widgets::{Block, Borders, Padding};
#[test]
fn resolved_layout_covers_ring_inset_and_partial_borders() {
let outer = Rect::new(4, 7, 20, 10);
let content = Size::new(30, 20);
for block in [
Block::bordered(),
Block::new().borders(Borders::RIGHT),
Block::bordered().padding(Padding::uniform(1)),
Block::new()
.borders(Borders::RIGHT)
.padding(Padding::uniform(1))
.title_bottom("status"),
] {
let inner = block.inner(outer);
for over_border in [true, false] {
let bars = Scrollbars {
over_border,
..Default::default()
};
let border_ring = super::super::has_border_ring(&block);
let layout = bars.resolve_layout(outer, inner, border_ring, |_| content);
let ring = bars.over_border && border_ring;
assert_eq!(layout.viewport.width, inner.width - u16::from(!ring));
assert_eq!(layout.viewport.height, inner.height - u16::from(!ring));
let vertical = layout.vertical_track.unwrap();
let horizontal = layout.horizontal_track.unwrap();
assert_eq!(
vertical,
Rect::new(
if ring {
outer.right() - 1
} else {
layout.viewport.right()
},
inner.y,
1,
layout.viewport.height
)
);
assert_eq!(
horizontal,
Rect::new(
inner.x,
if ring {
outer.bottom() - 1
} else {
layout.viewport.bottom()
},
layout.viewport.width,
1
)
);
assert_eq!(vertical.intersection(outer), vertical);
assert_eq!(horizontal.intersection(outer), horizontal);
}
}
}
#[test]
fn padding_and_titles_do_not_impersonate_missing_border_edges() {
for bits in 0..16 {
let borders = Borders::from_bits_truncate(bits);
let block = Block::new()
.borders(borders)
.padding(Padding::uniform(2))
.title_bottom("status");
assert_eq!(
super::super::has_border_ring(&block),
borders.contains(Borders::RIGHT | Borders::BOTTOM)
);
}
}
#[test]
fn fixed_content_visibility_is_resolved_once_for_all_policies() {
use ScrollbarVisibility::{Always, Automatic, Never};
let outer = Rect::new(0, 0, 10, 5);
for horizontal in [Always, Automatic, Never] {
for vertical in [Always, Automatic, Never] {
let bars = Scrollbars {
horizontal_scrollbar_visibility: horizontal,
vertical_scrollbar_visibility: vertical,
..Default::default()
};
for content in [Size::new(8, 3), Size::new(10, 5), Size::new(12, 8)] {
let layout = bars.resolve_layout(outer, outer, false, |_| content);
let expected = bars.visible_scrollbars(
10 - i32::from(content.width),
5 - i32::from(content.height),
);
assert_eq!(layout.horizontal_track.is_some(), expected.0);
assert_eq!(layout.vertical_track.is_some(), expected.1);
assert_eq!(
layout.viewport.as_size(),
Size::new(10 - u16::from(expected.1), 5 - u16::from(expected.0))
);
assert_eq!(layout.content, content);
}
}
}
}
#[test]
fn tiny_viewports_clip_tracks_and_render_without_out_of_bounds_cells() {
for width in 0..=2 {
for height in 0..=2 {
let area = Rect::new(0, 0, width, height);
for visibility in [
ScrollbarVisibility::Automatic,
ScrollbarVisibility::Always,
ScrollbarVisibility::Never,
] {
let bars = Scrollbars {
horizontal_scrollbar_visibility: visibility,
vertical_scrollbar_visibility: visibility,
..Default::default()
};
let layout = bars.resolve_layout(area, area, false, |page| {
Size::new(page.width.saturating_mul(2), 3)
});
let content =
Buffer::empty(Rect::new(0, 0, layout.content.width, layout.content.height));
let mut destination = Buffer::empty(area);
bars.render_ref(
layout,
&mut destination,
&content,
&ScrollbarInteraction::default(),
);
assert_eq!(destination.area, area);
for track in [layout.horizontal_track, layout.vertical_track]
.into_iter()
.flatten()
{
assert_eq!(track.intersection(area), track);
}
}
}
}
}
}