use crate::{AnyElement, Component, layout_style::LayoutStyle};
use crate::{
Hook, PointerOptions, State, UseEventHandler, UsePointer, UseState,
input::{EventOptions, EventPriority, EventResult, EventScope},
};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect, Size},
widgets::{Block, Borders},
};
use ratatui_kit_macros::{Props, with_layout_style};
use std::{
cell::{Cell, RefCell},
rc::Rc,
};
mod state;
pub use state::ScrollViewState;
mod scrollbars;
use scrollbars::ResolvedLayout;
pub(crate) use scrollbars::{AxisGeometry, ScrollbarGeometry, ScrollbarInteraction};
pub use scrollbars::{ScrollbarStyle, ScrollbarVisibility, Scrollbars};
#[with_layout_style]
#[derive(Props)]
pub struct ScrollViewProps<'a> {
pub children: Vec<AnyElement<'a>>,
pub scrollbars: Scrollbars<'static>,
pub state: Option<State<ScrollViewState>>,
pub block: Option<Block<'static>>,
pub active: bool,
}
impl Default for ScrollViewProps<'_> {
fn default() -> Self {
Self {
children: Vec::new(),
scrollbars: Scrollbars::default(),
state: None,
block: None,
active: true,
margin: Default::default(),
offset: Default::default(),
width: Default::default(),
height: Default::default(),
gap: Default::default(),
flex_direction: Default::default(),
justify_content: Default::default(),
}
}
}
pub struct ScrollView {
scrollbars: Scrollbars<'static>,
block: Option<Block<'static>>,
border_ring: bool,
outer: Option<Rect>,
scroll_view_state: Option<State<ScrollViewState>>,
resolved: Rc<Cell<Option<ResolvedLayout>>>,
}
fn has_border_ring(block: &Block<'_>) -> bool {
[
Borders::ALL,
Borders::RIGHT | Borders::BOTTOM,
Borders::LEFT | Borders::RIGHT | Borders::BOTTOM,
Borders::TOP | Borders::RIGHT | Borders::BOTTOM,
]
.into_iter()
.any(|borders| *block == block.clone().borders(borders))
}
fn clamp_u16(value: u128) -> u16 {
value.min(u16::MAX as u128) as u16
}
fn constraints_to_lengths(constraints: impl Iterator<Item = Constraint>, len: u16) -> Vec<u16> {
constraints
.map(|constraint| match constraint {
Constraint::Length(value) => value,
Constraint::Percentage(percent) => {
clamp_u16(u128::from(len) * u128::from(percent) / 100)
}
Constraint::Ratio(numerator, denominator) => {
if denominator == 0 {
0
} else {
clamp_u16(u128::from(len) * u128::from(numerator) / u128::from(denominator))
}
}
Constraint::Min(value) => value,
Constraint::Max(value) => value,
Constraint::Fill(weight) => clamp_u16(u128::from(len) * u128::from(weight)),
})
.collect()
}
fn gap_sum(count: usize, gap: i32) -> u16 {
if count == 0 {
return 0;
}
let total = count.saturating_sub(1) as i128 * i128::from(gap);
if total <= 0 {
0
} else {
clamp_u16(total as u128)
}
}
fn sum_with_gap(lengths: &[u16], gap: i32) -> u16 {
if lengths.is_empty() {
return 0;
}
let sum = lengths
.iter()
.fold(0u128, |sum, value| sum.saturating_add(u128::from(*value)));
clamp_u16(sum.saturating_add(u128::from(gap_sum(lengths.len(), gap))))
}
fn cross_direction(direction: Direction) -> Direction {
match direction {
Direction::Horizontal => Direction::Vertical,
Direction::Vertical => Direction::Horizontal,
}
}
fn area_len(area: Rect, direction: Direction) -> u16 {
match direction {
Direction::Horizontal => area.width,
Direction::Vertical => area.height,
}
}
fn lengths_to_constraints(lengths: &[u16]) -> Vec<Constraint> {
lengths
.iter()
.map(|length| Constraint::Length(*length))
.collect()
}
fn content_size(
direction: Direction,
main_lengths: &[u16],
cross_lengths: &[u16],
gap: i32,
) -> (u16, u16) {
let main = sum_with_gap(main_lengths, gap);
let cross = cross_lengths.iter().max().copied().unwrap_or_default();
match direction {
Direction::Horizontal => (main, cross),
Direction::Vertical => (cross, main),
}
}
impl Component for ScrollView {
type Props<'a> = ScrollViewProps<'a>;
fn new(props: &Self::Props<'_>) -> Self {
Self {
scrollbars: props.scrollbars.clone(),
block: props.block.clone(),
border_ring: props.block.as_ref().is_some_and(has_border_ring),
outer: None,
scroll_view_state: None,
resolved: Rc::default(),
}
}
fn update(
&mut self,
props: &mut Self::Props<'_>,
mut hooks: crate::Hooks,
updater: &mut crate::ComponentUpdater,
) {
let mut hooks = hooks.with_context_stack(updater.component_context_stack());
let layout_style = props.layout_style();
let this_scroll_view_state = hooks.use_state(ScrollViewState::default);
let state = props.state.unwrap_or(this_scroll_view_state);
let active = props.active;
if self.block != props.block {
self.block = props.block.clone();
self.border_ring = self.block.as_ref().is_some_and(has_border_ring);
}
let pointer_shared = {
let hook = hooks.use_hook(|| UseScrollImpl {
scrollbars: props.scrollbars.clone(),
resolved: Rc::clone(&self.resolved),
pointer: Rc::default(),
});
hook.scrollbars = props.scrollbars.clone();
Rc::clone(&hook.pointer)
};
hooks.use_event_handler_with_options(
EventScope::Current,
EventPriority::Normal,
EventOptions { hit_test: true },
move |event| {
if active && state.write().handle_event(&event) {
EventResult::Consumed
} else {
EventResult::Ignored
}
},
);
if crate::hooks::use_pointer::pointer_enabled(&hooks) {
hooks.use_pointer(
PointerOptions {
track_move: true,
..Default::default()
},
move |event| {
pointer_shared
.borrow_mut()
.handle(event, |target, vertical| {
let mut view = state.write();
let mut offset = view.offset;
if vertical {
offset.y = target as u16;
} else {
offset.x = target as u16;
}
view.set_offset(offset);
})
},
);
}
self.scrollbars = props.scrollbars.clone();
self.scroll_view_state = Some(state);
updater.set_layout_style(layout_style);
updater.update_children(&mut props.children, None);
}
fn calc_children_areas(
&self,
children: &crate::Components,
layout_style: &LayoutStyle,
drawer: &mut crate::ComponentDrawer<'_, '_>,
) -> Vec<ratatui::prelude::Rect> {
let inner = drawer.area;
let main_direction = layout_style.flex_direction;
let cross_axis = cross_direction(main_direction);
let main_lengths = constraints_to_lengths(
children.constraints(main_direction),
area_len(inner, main_direction),
);
let mut cross_lengths = Vec::new();
let mut resolved = self.scrollbars.resolve_layout(
self.outer.unwrap_or(inner),
inner,
self.border_ring,
|viewport| {
cross_lengths = constraints_to_lengths(
children.constraints(cross_axis),
match cross_axis {
Direction::Horizontal => viewport.width,
Direction::Vertical => viewport.height,
},
);
let (width, height) = content_size(
main_direction,
&main_lengths,
&cross_lengths,
layout_style.gap,
);
Size::new(width, height)
},
);
let offset = if let Some(state) = self.scroll_view_state {
let mut state = state.write_no_update();
state.size = Some(resolved.content);
state.page_size = Some(resolved.viewport.as_size());
state.offset.x = state.offset.x.min(
resolved
.content
.width
.saturating_sub(resolved.viewport.width),
);
state.offset.y = state.offset.y.min(
resolved
.content
.height
.saturating_sub(resolved.viewport.height),
);
state.offset
} else {
Default::default()
};
resolved.offset = offset;
self.resolved.set(Some(resolved));
let justify_constraints = lengths_to_constraints(&main_lengths);
let align_constraints = lengths_to_constraints(&cross_lengths);
let rect = Rect::new(0, 0, resolved.content.width, resolved.content.height);
drawer.push_scroll_buffer(Buffer::empty(rect), resolved.viewport, offset);
drawer.area = drawer.buffer_mut().area;
let layout = layout_style.get_layout().constraints(justify_constraints);
let areas = layout.split(drawer.area);
let mut new_areas = Vec::with_capacity(areas.len());
let rev_direction = cross_direction(layout_style.flex_direction);
for (area, constraint) in areas.iter().zip(align_constraints.iter()) {
let area = Layout::new(rev_direction, [constraint]).split(*area)[0];
new_areas.push(area);
}
if let Some(state) = self.scroll_view_state {
state.write_no_update().child_areas = new_areas.clone();
}
new_areas
}
fn draw(&mut self, drawer: &mut crate::ComponentDrawer<'_, '_>) {
self.outer = Some(drawer.area);
if let Some(block) = self.block.as_ref() {
let inner_area = block.inner(drawer.area);
drawer.render_widget(block, drawer.area);
drawer.area = inner_area;
}
}
}
struct UseScrollImpl {
scrollbars: Scrollbars<'static>,
resolved: Rc<Cell<Option<ResolvedLayout>>>,
pointer: Rc<RefCell<ScrollbarInteraction>>,
}
impl Hook for UseScrollImpl {
fn post_component_draw(&mut self, drawer: &mut crate::ComponentDrawer) {
let Some(buffer) = drawer.pop_scroll_buffer() else {
return;
};
let Some(resolved) = self.resolved.get() else {
return;
};
let geometry = self.scrollbars.render_ref(
resolved,
drawer.buffer_mut(),
&buffer,
&self.pointer.borrow(),
);
self.pointer.borrow_mut().geometry = geometry;
}
}
#[cfg(test)]
mod drag_tests {
use super::*;
use crate::{
Hooks,
components::{Text, View},
mouse_test::Mounted,
};
use crossterm::event::{MouseButton, MouseEventKind};
use ratatui::layout::Direction;
use ratatui_kit_macros::{Props, component};
use std::{cell::RefCell, rc::Rc};
type StateSlot = Rc<RefCell<Option<State<ScrollViewState>>>>;
#[derive(Clone, Default, Props)]
struct HarnessProps {
slot: StateSlot,
}
#[component]
fn VerticalHarness(props: &HarnessProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
let state = hooks.use_state(ScrollViewState::default);
if props.slot.borrow_mut().is_none() {
*props.slot.borrow_mut() = Some(state);
}
crate::element!(ScrollView(state: state) {
for i in 0..30u16 {
View(key: i, height: Constraint::Length(1)) { Text(text: format!("row {i}")) }
}
})
}
#[component]
fn HorizontalHarness(props: &HarnessProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
let state = hooks.use_state(ScrollViewState::default);
if props.slot.borrow_mut().is_none() {
*props.slot.borrow_mut() = Some(state);
}
crate::element!(ScrollView(state: state, flex_direction: Direction::Horizontal) {
for i in 0..3u16 {
View(key: i, width: Constraint::Length(30), height: Constraint::Length(1)) {
Text(text: format!("col {i}"))
}
}
})
}
#[component]
fn PercentageHarness(props: &HarnessProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
let state = hooks.use_state(ScrollViewState::default);
*props.slot.borrow_mut() = Some(state);
crate::element!(ScrollView(state: state) {
View(width: Constraint::Fill(1), height: Constraint::Percentage(100)) {
Text(text: "first")
}
View(width: Constraint::Fill(1), height: Constraint::Length(1)) {
Text(text: "last")
}
})
}
#[test]
fn percentage_content_and_scrollbar_share_one_resolution() {
use crossterm::event::KeyCode;
let slot: StateSlot = Default::default();
let mut mounted = Mounted::mount_sized(
crate::element!(PercentageHarness(slot: slot.clone())),
true,
10,
5,
);
let state = slot.borrow().unwrap();
assert_eq!(state.read().size(), Some(Size::new(9, 6)));
assert_eq!(state.read().page_size(), Some(Size::new(9, 5)));
assert_eq!(state.read().child_area(1), Some(Rect::new(0, 5, 9, 1)));
state.write().scroll_to_index(1);
mounted.dispatch_key(KeyCode::End);
assert_eq!(state.read().offset().y, 1);
assert!(state.read().is_at_bottom());
assert_eq!(
crate::mouse_test::find_text(&mounted.buffer(), "last"),
Some((0, 4))
);
}
fn mount_vertical(slot: &StateSlot) -> Mounted {
let m = Mounted::mount(crate::element!(VerticalHarness(slot: slot.clone())), true);
assert!(slot.borrow().is_some(), "外部 state 应已捕获");
m
}
fn offset_y(slot: &StateSlot) -> u16 {
slot.borrow().unwrap().read().offset().y
}
fn offset_x(slot: &StateSlot) -> u16 {
slot.borrow().unwrap().read().offset().x
}
#[test]
fn dragging_thumb_scrolls_proportionally() {
let slot: StateSlot = Default::default();
let mut m = mount_vertical(&slot);
m.dispatch(MouseEventKind::Down(MouseButton::Left), 39, 2);
assert_eq!(offset_y(&slot), 0, "按在滑块上不产生跳动");
m.dispatch(MouseEventKind::Drag(MouseButton::Left), 39, 5);
assert_eq!(offset_y(&slot), 11);
}
#[test]
fn scrollbar_feedback_clears_on_release() {
use ratatui::style::Modifier;
let slot: StateSlot = Default::default();
let mut m = mount_vertical(&slot);
m.dispatch(MouseEventKind::Moved, 39, 2);
assert!(m.buffer()[(39, 2)].modifier.contains(Modifier::BOLD));
m.dispatch(MouseEventKind::Down(MouseButton::Left), 39, 2);
assert!(m.buffer()[(39, 2)].modifier.contains(Modifier::REVERSED));
m.dispatch(MouseEventKind::Up(MouseButton::Left), 39, 2);
assert!(!m.buffer()[(39, 2)].modifier.contains(Modifier::REVERSED));
}
#[test]
fn drag_reaches_both_ends_exactly() {
let slot: StateSlot = Default::default();
let mut m = mount_vertical(&slot);
m.dispatch(MouseEventKind::Down(MouseButton::Left), 39, 2);
m.dispatch(MouseEventKind::Drag(MouseButton::Left), 39, 9);
assert_eq!(offset_y(&slot), 20, "拖到底 = max_position");
m.dispatch(MouseEventKind::Up(MouseButton::Left), 39, 9);
m.dispatch(MouseEventKind::Down(MouseButton::Left), 39, 2);
m.dispatch(MouseEventKind::Drag(MouseButton::Left), 39, 0);
assert_eq!(offset_y(&slot), 0, "拖到顶 = 0(经过再渲染后回程也成立)");
}
#[test]
fn clicking_track_jumps_thumb_to_pointer() {
let slot: StateSlot = Default::default();
let mut m = mount_vertical(&slot);
m.dispatch(MouseEventKind::Down(MouseButton::Left), 39, 7);
assert_eq!(offset_y(&slot), 17, "中心 6 → round((6-1.5)*30/8) = 17");
}
#[test]
fn clicking_content_does_not_start_drag() {
let slot: StateSlot = Default::default();
let mut m = mount_vertical(&slot);
m.dispatch(MouseEventKind::Down(MouseButton::Left), 10, 5);
assert_eq!(offset_y(&slot), 0);
m.dispatch(MouseEventKind::Drag(MouseButton::Left), 39, 5);
assert_eq!(offset_y(&slot), 0, "未抓住滑块的 Drag 不滚动");
}
#[test]
fn drag_survives_leaving_track_column() {
let slot: StateSlot = Default::default();
let mut m = mount_vertical(&slot);
m.dispatch(MouseEventKind::Down(MouseButton::Left), 39, 2);
m.dispatch(MouseEventKind::Drag(MouseButton::Left), 30, 6);
assert_eq!(
offset_y(&slot),
15,
"只用主轴坐标:y=6 → 中心 5.5 → round(4*30/8) = 15"
);
}
#[test]
fn dragging_horizontal_thumb_scrolls() {
let slot: StateSlot = Default::default();
let mut m = Mounted::mount(crate::element!(HorizontalHarness(slot: slot.clone())), true);
m.dispatch(MouseEventKind::Down(MouseButton::Left), 10, 9);
m.dispatch(MouseEventKind::Drag(MouseButton::Left), 30, 9);
assert_eq!(offset_x(&slot), 47);
}
}