use crate::{
Hooks, PointerOptions, UsePointer,
components::scroll_view::{
AxisGeometry, ScrollbarGeometry, ScrollbarInteraction, ScrollbarStyle,
},
hooks::use_pointer::pointer_enabled,
};
use ratatui::{
layout::Rect,
widgets::{ScrollbarOrientation, ScrollbarState},
};
use std::{cell::RefCell, rc::Rc};
mod private {
pub trait Sealed {}
impl Sealed for crate::hooks::Hooks<'_, '_> {}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrackPlacement {
RightInward(u16),
LeftInward(u16),
BottomInward(u16),
TopInward(u16),
}
impl TrackPlacement {
pub fn is_vertical(&self) -> bool {
matches!(self, Self::RightInward(_) | Self::LeftInward(_))
}
fn orientation(&self) -> ScrollbarOrientation {
match self {
Self::RightInward(_) => ScrollbarOrientation::VerticalRight,
Self::LeftInward(_) => ScrollbarOrientation::VerticalLeft,
Self::BottomInward(_) => ScrollbarOrientation::HorizontalBottom,
Self::TopInward(_) => ScrollbarOrientation::HorizontalTop,
}
}
}
impl Default for TrackPlacement {
fn default() -> Self {
Self::RightInward(1)
}
}
#[derive(Default)]
pub struct ScrollbarOptions {
pub placement: TrackPlacement,
pub content_length: usize,
pub viewport_length: usize,
pub position: usize,
pub scrollbar: ScrollbarStyle<'static>,
}
fn track_rect(area: Rect, placement: TrackPlacement) -> Rect {
if area.is_empty() {
return area;
}
match placement {
TrackPlacement::RightInward(n) => Rect {
x: area.right().saturating_sub(1),
y: area.y.saturating_add(n),
width: 1,
height: area.height.saturating_sub(n.saturating_mul(2)),
},
TrackPlacement::LeftInward(n) => Rect {
x: area.x,
y: area.y.saturating_add(n),
width: 1,
height: area.height.saturating_sub(n.saturating_mul(2)),
},
TrackPlacement::BottomInward(n) => Rect {
x: area.x.saturating_add(n),
y: area.bottom().saturating_sub(1),
width: area.width.saturating_sub(n.saturating_mul(2)),
height: 1,
},
TrackPlacement::TopInward(n) => Rect {
x: area.x.saturating_add(n),
y: area.y,
width: area.width.saturating_sub(n.saturating_mul(2)),
height: 1,
},
}
}
fn axis_geometry(area: Rect, options: &ScrollbarOptions) -> Option<AxisGeometry> {
let scrollable = options
.content_length
.saturating_sub(options.viewport_length);
if scrollable == 0 {
return None;
}
AxisGeometry::new(
track_rect(area, options.placement),
options.viewport_length,
scrollable,
options.position.min(scrollable),
options.placement.is_vertical(),
options.scrollbar.arrow_lengths(),
)
}
struct ScrollbarHook {
options: ScrollbarOptions,
area: Rect,
shared: Rc<RefCell<ScrollbarInteraction>>,
}
impl ScrollbarHook {
fn new() -> Self {
Self {
options: ScrollbarOptions::default(),
area: Rect::default(),
shared: Rc::default(),
}
}
}
impl crate::hooks::Hook for ScrollbarHook {
fn pre_component_draw(&mut self, drawer: &mut crate::ComponentDrawer<'_, '_>) {
self.area = drawer.area;
}
fn post_component_draw(&mut self, drawer: &mut crate::ComponentDrawer<'_, '_>) {
let Some(geometry) = axis_geometry(self.area, &self.options) else {
self.shared.borrow_mut().geometry = ScrollbarGeometry::default();
return;
};
let mut state = ScrollbarState::new(geometry.scrollable.saturating_add(1))
.position(geometry.offset)
.viewport_content_length(geometry.viewport);
let scrollbar = self.options.scrollbar.widget_with_state(
self.options.placement.orientation(),
self.shared
.borrow()
.is_hovered(self.options.placement.is_vertical()),
self.shared
.borrow()
.is_dragging(self.options.placement.is_vertical()),
);
drawer.render_stateful_widget(scrollbar, geometry.track, &mut state);
self.shared.borrow_mut().geometry = if self.options.placement.is_vertical() {
ScrollbarGeometry {
vertical: Some(geometry),
horizontal: None,
}
} else {
ScrollbarGeometry {
vertical: None,
horizontal: Some(geometry),
}
};
}
}
pub trait UseScrollbar: private::Sealed {
fn use_scrollbar<F>(&mut self, options: ScrollbarOptions, on_seek: F)
where
F: FnMut(usize) + 'static;
}
impl UseScrollbar for Hooks<'_, '_> {
fn use_scrollbar<F>(&mut self, options: ScrollbarOptions, on_seek: F)
where
F: FnMut(usize) + 'static,
{
let shared = {
let hook = self.use_hook(ScrollbarHook::new);
hook.options = options;
Rc::clone(&hook.shared)
};
if pointer_enabled(self) {
let mut on_seek = on_seek;
self.use_pointer(
PointerOptions {
track_move: true,
..Default::default()
},
move |event| {
shared
.borrow_mut()
.handle(event, |target, _| on_seek(target))
},
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::layout::Rect;
fn area() -> Rect {
Rect::new(0, 0, 80, 24)
}
#[test]
fn track_rect_places_all_four_edges() {
let a = area();
assert_eq!(
track_rect(a, TrackPlacement::RightInward(1)),
Rect::new(79, 1, 1, 22)
);
assert_eq!(
track_rect(a, TrackPlacement::LeftInward(2)),
Rect::new(0, 2, 1, 20)
);
assert_eq!(
track_rect(a, TrackPlacement::BottomInward(1)),
Rect::new(1, 23, 78, 1)
);
assert_eq!(
track_rect(a, TrackPlacement::TopInward(3)),
Rect::new(3, 0, 74, 1)
);
}
#[test]
fn track_rect_saturates_oversized_inset() {
assert_eq!(
track_rect(area(), TrackPlacement::RightInward(20)),
Rect::new(79, 20, 1, 0)
);
}
#[test]
fn axis_geometry_hidden_when_content_fits() {
let options = ScrollbarOptions {
content_length: 10,
viewport_length: 10,
..Default::default()
};
assert!(axis_geometry(area(), &options).is_none());
}
#[test]
fn large_content_reaches_the_full_usize_range() {
let options = ScrollbarOptions {
content_length: 200_000,
viewport_length: 10,
position: 150_000,
..Default::default()
};
let geometry = axis_geometry(area(), &options).unwrap();
assert_eq!(geometry.offset, 150_000);
assert_eq!(
geometry.offset_for_thumb_center(f64::from(geometry.usable)),
199_990
);
}
#[test]
fn empty_and_arrow_only_tracks_have_no_hit_geometry() {
let options = ScrollbarOptions {
content_length: 100,
viewport_length: 10,
..Default::default()
};
for area in [
Rect::new(0, 0, 0, 24),
Rect::new(0, 0, 80, 0),
Rect::new(0, 0, 80, 4),
] {
assert!(axis_geometry(area, &options).is_none());
}
}
#[test]
fn arrowless_track_uses_every_cell_for_geometry() {
let options = ScrollbarOptions {
content_length: 100,
viewport_length: 10,
scrollbar: ScrollbarStyle {
arrows: false,
..Default::default()
},
..Default::default()
};
let geometry = axis_geometry(area(), &options).unwrap();
assert_eq!(geometry.begin_length, 0);
assert_eq!(geometry.usable, 22);
assert_eq!(geometry.offset_for_thumb_center(22.0), 90);
assert_eq!(
geometry.along_clamped(ratatui::layout::Position::new(79, 1), true),
0.0
);
}
#[test]
fn axis_geometry_maps_units_to_scrollable_range() {
let options = ScrollbarOptions {
content_length: 100,
viewport_length: 10,
position: 90,
..Default::default()
};
let geometry = axis_geometry(area(), &options).expect("scrollable");
assert_eq!(geometry.scrollable, 90);
assert_eq!(geometry.offset, 90);
assert_eq!(
geometry.thumb_start(),
geometry.usable - geometry.thumb_len()
);
}
}
#[cfg(test)]
mod interaction_tests {
use super::*;
use crate::mouse_test::Mounted;
use crate::prelude::Fragment;
use crate::{AnyElement, UseState};
use crossterm::event::{MouseButton, MouseEventKind};
use ratatui_kit_macros::{Props, component, element};
use std::sync::{Arc, Mutex};
type Log = Arc<Mutex<Vec<usize>>>;
#[derive(Clone, Default, Props)]
struct HarnessProps {
log: Log,
}
#[component]
fn Harness(props: &HarnessProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
let position = hooks.use_state(|| 0usize);
let log = props.log.clone();
hooks.use_scrollbar(
ScrollbarOptions {
content_length: 100,
viewport_length: 10,
position: position.get(),
..Default::default()
},
{
let mut position = position;
move |target: usize| {
position.set(target);
log.lock().unwrap().push(target);
}
},
);
element!(Fragment)
}
#[component]
fn RenderHarness(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
hooks.use_scrollbar(
ScrollbarOptions {
content_length: 100,
viewport_length: 10,
position: 37,
scrollbar: ScrollbarStyle {
symbols: Some(ratatui::symbols::scrollbar::Set {
thumb: "█",
track: "░",
begin: "↑",
end: "↓",
}),
..Default::default()
},
..Default::default()
},
|_| {},
);
element!(Fragment)
}
#[test]
fn rendered_thumb_matches_hit_geometry_with_custom_viewport() {
let mut m = Mounted::mount_sized(element!(RenderHarness), true, 80, 24);
let geometry = axis_geometry(
Rect::new(0, 0, 80, 24),
&ScrollbarOptions {
content_length: 100,
viewport_length: 10,
position: 37,
..Default::default()
},
)
.unwrap();
let buffer = m.buffer();
let thumb_rows: Vec<u16> = (1..23)
.filter(|&y| buffer[(79, y)].symbol() == "█")
.collect();
assert_eq!(buffer[(79, 1)].symbol(), "↑");
assert_eq!(buffer[(79, 22)].symbol(), "↓");
let start = 2 + geometry.thumb_start();
assert_eq!(
thumb_rows,
(start..start + geometry.thumb_len()).collect::<Vec<_>>()
);
}
fn mount(log: &Log) -> Mounted {
Mounted::mount_sized(crate::element!(Harness(log: log.clone())), true, 80, 24)
}
fn click(m: &mut Mounted, x: u16, y: u16) {
m.dispatch(MouseEventKind::Down(MouseButton::Left), x, y);
m.dispatch(MouseEventKind::Up(MouseButton::Left), x, y);
}
#[test]
fn clicking_track_jumps_thumb_center_to_pointer() {
let log: Log = Default::default();
let mut m = mount(&log);
click(&mut m, 79, 12);
assert_eq!(log.lock().unwrap().as_slice(), [45]);
}
#[test]
fn dragging_thumb_reaches_both_ends_exactly() {
let log: Log = Default::default();
let mut m = mount(&log);
m.dispatch(MouseEventKind::Down(MouseButton::Left), 79, 2); assert!(log.lock().unwrap().is_empty(), "抓住滑块不改变偏移");
m.dispatch(MouseEventKind::Drag(MouseButton::Left), 79, 22); assert_eq!(*log.lock().unwrap().last().unwrap(), 90);
m.dispatch(MouseEventKind::Up(MouseButton::Left), 79, 22);
}
#[test]
fn clicking_off_track_is_ignored() {
let log: Log = Default::default();
let mut m = mount(&log);
click(&mut m, 10, 12);
assert!(log.lock().unwrap().is_empty());
}
}