use alloc::vec::Vec;
use crate::anim::{Easing, Tween};
use crate::event::Event;
use crate::object::{DispatchInput, ObjectEvent, ObjectFlags, ObjectNode};
use crate::widget::Rect;
pub const MIN_THROW_VELOCITY_PX_PER_TICK: i32 = 2;
pub const MAX_THROW_VELOCITY_PX_PER_TICK: i32 = 80;
pub const VELOCITY_WINDOW_N: usize = 4;
pub const THROW_DURATION_FACTOR: u32 = 6;
pub const DEFAULT_SNAP_ATTRACTION_RADIUS: i32 = 60;
pub const SNAP_SETTLE_TICKS: u32 = 8;
pub const SCROLLBAR_WIDTH: i32 = 4;
pub const SCROLLBAR_MARGIN: i32 = 2;
pub const SCROLLBAR_MIN_THUMB: i32 = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollAxis {
Vertical,
Horizontal,
Both,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SnapAlign {
#[default]
Start,
Center,
End,
}
#[derive(Debug, Clone)]
pub enum SnapConfig {
None,
Explicit(Vec<i32>),
ChildAligned,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScrollbarMode {
#[default]
Auto,
On,
Off,
}
#[derive(Debug, Clone)]
pub struct ScrollState {
pub offset_x: i32,
pub offset_y: i32,
pub content_w: i32,
pub content_h: i32,
pub axis: ScrollAxis,
pub snap: SnapConfig,
pub snap_align: SnapAlign,
pub scrollbar_mode: ScrollbarMode,
pub snap_attraction_radius: i32,
}
impl ScrollState {
pub fn new() -> Self {
Self {
offset_x: 0,
offset_y: 0,
content_w: 0,
content_h: 0,
axis: ScrollAxis::Vertical,
snap: SnapConfig::None,
snap_align: SnapAlign::Start,
scrollbar_mode: ScrollbarMode::Auto,
snap_attraction_radius: DEFAULT_SNAP_ATTRACTION_RADIUS,
}
}
pub fn max_scroll_y(&self, viewport_h: i32) -> i32 {
(self.content_h - viewport_h).max(0)
}
pub fn max_scroll_x(&self, viewport_w: i32) -> i32 {
(self.content_w - viewport_w).max(0)
}
pub fn set_offset_y(&mut self, new_y: i32, viewport_h: i32) -> bool {
let clamped = new_y.clamp(0, self.max_scroll_y(viewport_h));
if clamped != self.offset_y {
self.offset_y = clamped;
true
} else {
false
}
}
pub fn set_offset_x(&mut self, new_x: i32, viewport_w: i32) -> bool {
let clamped = new_x.clamp(0, self.max_scroll_x(viewport_w));
if clamped != self.offset_x {
self.offset_x = clamped;
true
} else {
false
}
}
pub fn scrollbar_thumb(&self, viewport: Rect) -> Option<Rect> {
if self.scrollbar_mode == ScrollbarMode::Off {
return None;
}
let viewport_h = viewport.height;
if self.content_h <= viewport_h {
return None;
}
let track_h = viewport_h;
let thumb_h = ((track_h as i64 * track_h as i64) / self.content_h as i64) as i32;
let thumb_h = thumb_h.clamp(SCROLLBAR_MIN_THUMB, track_h);
let travel = track_h - thumb_h;
let max = self.max_scroll_y(viewport_h);
let offset = if max > 0 {
((self.offset_y as i64 * travel as i64) / max as i64) as i32
} else {
0
};
Some(Rect {
x: viewport.x + viewport.width - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN,
y: viewport.y + offset,
width: SCROLLBAR_WIDTH,
height: thumb_h,
})
}
}
impl Default for ScrollState {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy)]
struct VelocitySample {
pos: i32,
tick: u32,
}
struct VelocityWindow {
samples: [VelocitySample; VELOCITY_WINDOW_N],
count: usize,
head: usize,
}
impl VelocityWindow {
fn new() -> Self {
Self {
samples: [VelocitySample { pos: 0, tick: 0 }; VELOCITY_WINDOW_N],
count: 0,
head: 0,
}
}
fn push(&mut self, pos: i32, tick: u32) {
self.samples[self.head] = VelocitySample { pos, tick };
self.head = (self.head + 1) % VELOCITY_WINDOW_N;
if self.count < VELOCITY_WINDOW_N {
self.count += 1;
}
}
fn estimate_velocity(&self) -> i32 {
if self.count < 2 {
return 0;
}
let newest_idx = if self.head == 0 {
VELOCITY_WINDOW_N - 1
} else {
self.head - 1
};
let oldest_idx = if self.count < VELOCITY_WINDOW_N {
(self.head + VELOCITY_WINDOW_N - self.count) % VELOCITY_WINDOW_N
} else {
self.head % VELOCITY_WINDOW_N
};
let newest = self.samples[newest_idx];
let oldest = self.samples[oldest_idx];
let dt = newest.tick.wrapping_sub(oldest.tick);
if dt == 0 {
return 0;
}
let dp = newest.pos - oldest.pos;
dp / dt as i32
}
}
struct ScrollSession {
container_path: Vec<usize>,
#[allow(dead_code)]
start_tick: u32,
vel_y: VelocityWindow,
throw_tween: Option<Tween>,
in_throw: bool,
#[allow(dead_code)]
throw_from_offset: i32,
#[allow(dead_code)]
throw_to_offset: i32,
}
impl ScrollSession {
fn new(path: Vec<usize>, tick: u32) -> Self {
Self {
container_path: path,
start_tick: tick,
vel_y: VelocityWindow::new(),
throw_tween: None,
in_throw: false,
throw_from_offset: 0,
throw_to_offset: 0,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ScrollConfig {
pub min_throw_velocity: i32,
pub max_throw_velocity: i32,
pub throw_duration_factor: u32,
}
impl Default for ScrollConfig {
fn default() -> Self {
Self {
min_throw_velocity: MIN_THROW_VELOCITY_PX_PER_TICK,
max_throw_velocity: MAX_THROW_VELOCITY_PX_PER_TICK,
throw_duration_factor: THROW_DURATION_FACTOR,
}
}
}
pub struct ScrollController {
config: ScrollConfig,
tick: u32,
session: Option<ScrollSession>,
last_drag_y: i32,
last_drag_x: i32,
}
impl ScrollController {
pub fn new(config: ScrollConfig) -> Self {
Self {
config,
tick: 0,
session: None,
last_drag_y: 0,
last_drag_x: 0,
}
}
pub fn default_config() -> Self {
Self::new(ScrollConfig::default())
}
pub fn tick(&mut self, root: &mut ObjectNode, sink: &mut dyn FnMut(Rect)) {
self.tick = self.tick.wrapping_add(1);
self.advance_throw(root, sink);
}
pub fn process(&mut self, root: &mut ObjectNode, ev: &Event, sink: &mut dyn FnMut(Rect)) {
match ev {
Event::DragStart {
x,
y,
origin_x,
origin_y,
} => self.on_drag_start(root, *x, *y, *origin_x, *origin_y, sink),
Event::DragMove { x, y } => self.on_drag_move(root, *x, *y, sink),
Event::DragEnd { x, y } => self.on_drag_end(root, *x, *y, sink),
_ => {}
}
}
pub fn is_active(&self) -> bool {
self.session.is_some()
}
pub fn cancel_if_active(
&mut self,
root: &mut ObjectNode,
path: &[usize],
sink: &mut dyn FnMut(Rect),
) {
let is_active = self
.session
.as_ref()
.map(|s| s.container_path.as_slice() == path)
.unwrap_or(false);
if is_active {
self.emit_scroll_end(root, sink);
self.session = None;
}
}
fn on_drag_start(
&mut self,
root: &mut ObjectNode,
x: i32,
y: i32,
_origin_x: i32,
_origin_y: i32,
_sink: &mut dyn FnMut(Rect),
) {
self.session = None;
let path = find_scrollable_ancestor(root, x, y);
if path.is_none() {
return;
}
let path = path.unwrap();
{
let container = node_at_path(root, &path);
if let Some(ss) = &container.scroll {
if ss.axis == ScrollAxis::Horizontal {
return;
}
} else {
return;
}
}
let mut session = ScrollSession::new(path.clone(), self.tick);
let start_y = node_at_path(root, &path)
.scroll
.as_ref()
.map(|s| s.offset_y)
.unwrap_or(0);
session.vel_y.push(start_y, self.tick);
dispatch_to_container(root, &path, ObjectEvent::ScrollBegin);
self.last_drag_y = y;
self.last_drag_x = x;
self.session = Some(session);
}
fn on_drag_move(&mut self, root: &mut ObjectNode, x: i32, y: i32, sink: &mut dyn FnMut(Rect)) {
let in_throw = self.session.as_ref().map(|s| s.in_throw).unwrap_or(true);
if self.session.is_none() || in_throw {
return;
}
let delta_y = self.last_drag_y - y; self.last_drag_y = y;
self.last_drag_x = x;
let path = self.session.as_ref().unwrap().container_path.clone();
let viewport_h = node_at_path(root, &path).widget().borrow().bounds().height;
let viewport_rect = node_at_path(root, &path).widget().borrow().bounds();
let (current_offset_y, max_scroll_y) = {
let node = node_at_path(root, &path);
let ss = node.scroll.as_ref().unwrap();
(ss.offset_y, ss.max_scroll_y(viewport_h))
};
let new_offset_y = (current_offset_y + delta_y).clamp(0, max_scroll_y);
let changed = new_offset_y != current_offset_y;
if changed {
node_at_path_mut(root, &path)
.scroll
.as_mut()
.unwrap()
.offset_y = new_offset_y;
}
let at_edge = new_offset_y == 0 || new_offset_y == max_scroll_y;
let residual = if at_edge && delta_y != 0 {
let unclamped = current_offset_y + delta_y;
unclamped - new_offset_y
} else {
0
};
if changed {
let (ox, oy) = {
let n = node_at_path(root, &path);
let ss = n.scroll.as_ref().unwrap();
(ss.offset_x, ss.offset_y)
};
dispatch_to_container(
root,
&path,
ObjectEvent::Scroll {
scroll_x: ox,
scroll_y: oy,
},
);
sink(viewport_rect);
}
let offset_for_vel = node_at_path(root, &path)
.scroll
.as_ref()
.map(|s| s.offset_y)
.unwrap_or(0);
if let Some(ref mut sess) = self.session {
sess.vel_y.push(offset_for_vel, self.tick);
}
if residual != 0
&& at_edge
&& let Some(ancestor_path) = find_scrollable_ancestor_above(root, &path)
{
self.emit_scroll_end(root, sink);
let ancestor_start_y = node_at_path(root, &ancestor_path)
.scroll
.as_ref()
.map(|s| s.offset_y)
.unwrap_or(0);
let mut new_session = ScrollSession::new(ancestor_path.clone(), self.tick);
new_session.vel_y.push(ancestor_start_y, self.tick);
dispatch_to_container(root, &ancestor_path, ObjectEvent::ScrollBegin);
self.session = Some(new_session);
let ancestor_viewport_h = node_at_path(root, &ancestor_path)
.widget()
.borrow()
.bounds()
.height;
let ancestor_viewport_rect = node_at_path(root, &ancestor_path)
.widget()
.borrow()
.bounds();
let (ancestor_offset_y, ancestor_max) = {
let node = node_at_path(root, &ancestor_path);
let ss = node.scroll.as_ref().unwrap();
(ss.offset_y, ss.max_scroll_y(ancestor_viewport_h))
};
let new_ancestor_y = (ancestor_offset_y + residual).clamp(0, ancestor_max);
if new_ancestor_y != ancestor_offset_y {
node_at_path_mut(root, &ancestor_path)
.scroll
.as_mut()
.unwrap()
.offset_y = new_ancestor_y;
let (aox, aoy) = {
let n = node_at_path(root, &ancestor_path);
let ss = n.scroll.as_ref().unwrap();
(ss.offset_x, ss.offset_y)
};
dispatch_to_container(
root,
&ancestor_path,
ObjectEvent::Scroll {
scroll_x: aox,
scroll_y: aoy,
},
);
sink(ancestor_viewport_rect);
let new_vel_y = node_at_path(root, &ancestor_path)
.scroll
.as_ref()
.map(|s| s.offset_y)
.unwrap_or(0);
if let Some(ref mut sess) = self.session {
sess.vel_y.push(new_vel_y, self.tick);
}
}
}
}
fn on_drag_end(&mut self, root: &mut ObjectNode, _x: i32, _y: i32, sink: &mut dyn FnMut(Rect)) {
if self.session.is_none() {
return;
}
let raw_vel = self.session.as_ref().unwrap().vel_y.estimate_velocity();
let vel = raw_vel.clamp(
-self.config.max_throw_velocity,
self.config.max_throw_velocity,
);
let path = self.session.as_ref().unwrap().container_path.clone();
let viewport_h = node_at_path(root, &path).widget().borrow().bounds().height;
let (current_offset_y, max_scroll_y) = {
let node = node_at_path(root, &path);
let ss = node.scroll.as_ref().unwrap();
(ss.offset_y, ss.max_scroll_y(viewport_h))
};
if vel.abs() >= self.config.min_throw_velocity {
let duration = vel.unsigned_abs() * self.config.throw_duration_factor;
let projected = current_offset_y + vel * duration as i32;
let mut endpoint = projected.clamp(0, max_scroll_y);
endpoint = snap_endpoint(
root,
&path,
endpoint,
current_offset_y,
viewport_h,
self.config.min_throw_velocity,
);
endpoint = endpoint.clamp(0, max_scroll_y);
dispatch_to_container(
root,
&path,
ObjectEvent::ScrollThrow {
vel_x: 0,
vel_y: vel,
},
);
let tween =
Tween::new(current_offset_y, endpoint, duration).with_easing(Easing::EaseOut);
if let Some(ref mut sess) = self.session {
sess.throw_tween = Some(tween);
sess.in_throw = true;
sess.throw_from_offset = current_offset_y;
sess.throw_to_offset = endpoint;
}
} else {
let settled = snap_endpoint(
root,
&path,
current_offset_y,
current_offset_y,
viewport_h,
self.config.min_throw_velocity,
)
.clamp(0, max_scroll_y);
if settled != current_offset_y {
let tween = Tween::new(current_offset_y, settled, SNAP_SETTLE_TICKS)
.with_easing(Easing::EaseOut);
if let Some(ref mut sess) = self.session {
sess.throw_tween = Some(tween);
sess.in_throw = true;
sess.throw_from_offset = current_offset_y;
sess.throw_to_offset = settled;
}
} else {
self.emit_scroll_end(root, sink);
self.session = None;
}
}
}
fn advance_throw(&mut self, root: &mut ObjectNode, sink: &mut dyn FnMut(Rect)) {
let tween_step = match &mut self.session {
Some(s) if s.in_throw => {
if let Some(ref mut t) = s.throw_tween {
let val = t.step();
let finished = t.finished();
let path = s.container_path.clone();
Some((path, val, finished))
} else {
None
}
}
_ => None,
};
let (path, new_offset, tween_done) = match tween_step {
Some(v) => v,
None => return,
};
let viewport_h = node_at_path(root, &path).widget().borrow().bounds().height;
let viewport_rect = node_at_path(root, &path).widget().borrow().bounds();
let max_scroll_y = node_at_path(root, &path)
.scroll
.as_ref()
.map(|s| s.max_scroll_y(viewport_h))
.unwrap_or(0);
let current_offset_y = node_at_path(root, &path)
.scroll
.as_ref()
.map(|s| s.offset_y)
.unwrap_or(0);
let clamped = new_offset.clamp(0, max_scroll_y);
let at_edge = clamped != new_offset; let changed = clamped != current_offset_y;
if changed {
node_at_path_mut(root, &path)
.scroll
.as_mut()
.unwrap()
.offset_y = clamped;
let (ox, oy) = {
let n = node_at_path(root, &path);
let ss = n.scroll.as_ref().unwrap();
(ss.offset_x, ss.offset_y)
};
dispatch_to_container(
root,
&path,
ObjectEvent::Scroll {
scroll_x: ox,
scroll_y: oy,
},
);
sink(viewport_rect);
}
if tween_done || at_edge {
self.emit_scroll_end(root, sink);
self.session = None;
}
}
fn emit_scroll_end(&self, root: &mut ObjectNode, _sink: &mut dyn FnMut(Rect)) {
if let Some(ref session) = self.session {
dispatch_to_container(root, &session.container_path, ObjectEvent::ScrollEnd);
}
}
}
fn snap_endpoint(
root: &ObjectNode,
path: &[usize],
endpoint: i32,
_current_offset: i32,
viewport_h: i32,
_min_vel: i32,
) -> i32 {
let node = node_at_path(root, path);
let ss = match node.scroll.as_ref() {
Some(s) => s,
None => return endpoint,
};
let radius = ss.snap_attraction_radius;
let align = ss.snap_align;
let snap_points: Vec<i32> = match &ss.snap {
SnapConfig::None => return endpoint,
SnapConfig::Explicit(list) => list.clone(),
SnapConfig::ChildAligned => {
node.children()
.iter()
.map(|child| {
let b = child.widget().borrow().bounds();
let raw = b.y; align_offset(raw, b.height, viewport_h, align)
})
.collect()
}
};
let mut best = endpoint;
let mut best_dist = i32::MAX;
for &sp in &snap_points {
let dist = (endpoint - sp).abs();
if dist <= radius && dist < best_dist {
best_dist = dist;
best = sp;
}
}
best
}
fn align_offset(child_top: i32, child_h: i32, viewport_h: i32, align: SnapAlign) -> i32 {
match align {
SnapAlign::Start => child_top,
SnapAlign::Center => child_top - (viewport_h - child_h) / 2,
SnapAlign::End => child_top - (viewport_h - child_h),
}
}
pub fn snap_offset_to_points(offset: i32, points: &[i32], attraction_radius: i32) -> i32 {
let mut best = offset;
let mut best_dist = i32::MAX;
for &sp in points {
let dist = (offset - sp).abs();
if dist <= attraction_radius && dist < best_dist {
best_dist = dist;
best = sp;
}
}
best
}
pub fn dispatch_to_container(root: &mut ObjectNode, path: &[usize], event: ObjectEvent) {
use crate::object::dispatch_object_event;
let _ = dispatch_object_event(
root,
DispatchInput::Container {
path: path.to_vec(),
event,
},
);
}
fn find_scrollable_ancestor(root: &ObjectNode, x: i32, y: i32) -> Option<Vec<usize>> {
let target_path = hit_test_path_for_scroll(root, x, y)?;
for depth in (0..=target_path.len()).rev() {
let path = &target_path[..depth];
let node = node_at_path(root, path);
if node.flags().contains(ObjectFlags::SCROLLABLE) && node.scroll.is_some() {
return Some(path.to_vec());
}
}
None
}
fn find_scrollable_ancestor_above(root: &ObjectNode, path: &[usize]) -> Option<Vec<usize>> {
if path.is_empty() {
return None;
}
for depth in (0..path.len()).rev() {
let ancestor_path = &path[..depth];
let node = node_at_path(root, ancestor_path);
if node.flags().contains(ObjectFlags::SCROLLABLE) && node.scroll.is_some() {
return Some(ancestor_path.to_vec());
}
}
None
}
fn hit_test_path_for_scroll(root: &ObjectNode, x: i32, y: i32) -> Option<Vec<usize>> {
hit_test_scroll_recursive(root, x, y, &mut Vec::new())
}
fn hit_test_scroll_recursive(
node: &ObjectNode,
x: i32,
y: i32,
path: &mut Vec<usize>,
) -> Option<Vec<usize>> {
if node.meta().is_detached() || node.flags().contains(ObjectFlags::HIDDEN) {
return None;
}
for (i, child) in node.children().iter().enumerate().rev() {
path.push(i);
if let Some(p) = hit_test_scroll_recursive(child, x, y, path) {
return Some(p);
}
path.pop();
}
let b = node.widget().borrow().bounds();
if b.width > 0
&& b.height > 0
&& x >= b.x
&& y >= b.y
&& x < b.x + b.width
&& y < b.y + b.height
{
Some(path.clone())
} else {
None
}
}
fn node_at_path<'a>(root: &'a ObjectNode, path: &[usize]) -> &'a ObjectNode {
let mut current = root;
for &idx in path {
current = ¤t.children()[idx];
}
current
}
fn node_at_path_mut<'a>(root: &'a mut ObjectNode, path: &[usize]) -> &'a mut ObjectNode {
let mut current = root;
for &idx in path {
current = &mut current.children_mut()[idx];
}
current
}
#[cfg(test)]
mod tests {
use alloc::rc::Rc;
use alloc::vec;
use alloc::vec::Vec;
use core::cell::RefCell;
use super::*;
use crate::event::Event;
use crate::object::{ObjectEvent, ObjectFlags, ObjectNode};
use crate::renderer::Renderer;
use crate::widget::{Rect, Widget};
struct TestWidget {
bounds: Rect,
}
impl TestWidget {
fn node(bounds: Rect) -> ObjectNode {
ObjectNode::new(Rc::new(RefCell::new(Self { bounds })))
}
}
impl Widget for TestWidget {
fn bounds(&self) -> Rect {
self.bounds
}
fn draw(&self, _renderer: &mut dyn Renderer) {}
fn handle_event(&mut self, _event: &Event) -> bool {
false
}
}
fn scroll_tree() -> ObjectNode {
let mut root = TestWidget::node(Rect {
x: 0,
y: 0,
width: 400,
height: 600,
});
let mut container = TestWidget::node(Rect {
x: 0,
y: 0,
width: 400,
height: 600,
});
let mut ss = ScrollState::new();
ss.content_h = 1200;
container.set_scroll_state(Box::new(ss));
let child = TestWidget::node(Rect {
x: 0,
y: 0,
width: 400,
height: 1200,
});
container.append_child(child);
root.append_child(container);
root
}
fn collect_events(root: &mut ObjectNode, path: &[usize]) -> Rc<RefCell<Vec<ObjectEvent>>> {
let log: Rc<RefCell<Vec<ObjectEvent>>> = Rc::new(RefCell::new(Vec::new()));
let l = log.clone();
let node = node_at_path_mut(root, path);
node.add_target_handler(move |ev, _ctx| {
l.borrow_mut().push(ev.clone());
false
});
log
}
#[test]
fn no_activation_on_non_scrollable_tree() {
let mut root = TestWidget::node(Rect {
x: 0,
y: 0,
width: 400,
height: 600,
});
let child = TestWidget::node(Rect {
x: 0,
y: 0,
width: 400,
height: 600,
});
root.append_child(child);
let mut ctrl = ScrollController::default_config();
let mut dirty: Vec<Rect> = Vec::new();
let mut sink = |r: Rect| dirty.push(r);
ctrl.process(
&mut root,
&Event::DragStart {
x: 200,
y: 100,
origin_x: 200,
origin_y: 150,
},
&mut sink,
);
ctrl.process(&mut root, &Event::DragMove { x: 200, y: 80 }, &mut sink);
ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 80 }, &mut sink);
assert!(
dirty.is_empty(),
"no dirty rects without a scrollable container"
);
}
#[test]
fn activation_on_scrollable_container() {
let mut root = scroll_tree();
let log = collect_events(&mut root, &[0]);
let mut ctrl = ScrollController::default_config();
let mut dirty: Vec<Rect> = Vec::new();
let mut sink = |r: Rect| dirty.push(r);
ctrl.process(
&mut root,
&Event::DragStart {
x: 200,
y: 100,
origin_x: 200,
origin_y: 110,
},
&mut sink,
);
let log_snap = log.borrow().clone();
assert!(
log_snap
.iter()
.any(|e| matches!(e, ObjectEvent::ScrollBegin)),
"ScrollBegin must be emitted on DragStart; got: {:?}",
log_snap
);
}
#[test]
fn scroll_event_ordering() {
let mut root = scroll_tree();
let log = collect_events(&mut root, &[0]);
let mut ctrl = ScrollController::default_config();
let mut sink = |_: Rect| {};
ctrl.process(
&mut root,
&Event::DragStart {
x: 200,
y: 300,
origin_x: 200,
origin_y: 310,
},
&mut sink,
);
ctrl.process(&mut root, &Event::DragMove { x: 200, y: 280 }, &mut sink); ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 280 }, &mut sink);
let log_snap = log.borrow().clone();
let begin_pos = log_snap
.iter()
.position(|e| matches!(e, ObjectEvent::ScrollBegin));
let scroll_pos = log_snap
.iter()
.position(|e| matches!(e, ObjectEvent::Scroll { .. }));
let end_pos = log_snap
.iter()
.rposition(|e| matches!(e, ObjectEvent::ScrollEnd));
assert!(begin_pos.is_some(), "ScrollBegin must be emitted");
if let Some(sp) = scroll_pos {
assert!(begin_pos.unwrap() < sp, "ScrollBegin must precede Scroll");
}
assert!(end_pos.is_some(), "ScrollEnd must be emitted");
let last_scroll_event_idx = log_snap.iter().rposition(|e| {
matches!(
e,
ObjectEvent::ScrollBegin
| ObjectEvent::Scroll { .. }
| ObjectEvent::ScrollEnd
| ObjectEvent::ScrollThrow { .. }
)
});
assert_eq!(
end_pos, last_scroll_event_idx,
"ScrollEnd must be the last scroll event"
);
}
#[test]
fn scroll_end_fires_on_zero_delta_session() {
let mut root = scroll_tree();
let log = collect_events(&mut root, &[0]);
let mut ctrl = ScrollController::default_config();
let mut sink = |_: Rect| {};
ctrl.process(
&mut root,
&Event::DragStart {
x: 200,
y: 300,
origin_x: 200,
origin_y: 305,
},
&mut sink,
);
ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 300 }, &mut sink);
let log_snap = log.borrow().clone();
assert!(
log_snap
.iter()
.any(|e| matches!(e, ObjectEvent::ScrollBegin)),
"ScrollBegin required"
);
assert!(
log_snap.iter().any(|e| matches!(e, ObjectEvent::ScrollEnd)),
"ScrollEnd required on zero-delta"
);
assert!(
!log_snap
.iter()
.any(|e| matches!(e, ObjectEvent::Scroll { .. })),
"no Scroll on zero-delta"
);
}
#[test]
fn offset_clamps_and_emits_scroll() {
let mut root = scroll_tree();
let _log = collect_events(&mut root, &[0]);
let mut ctrl = ScrollController::default_config();
let mut dirty: Vec<Rect> = Vec::new();
let mut sink = |r: Rect| dirty.push(r);
ctrl.process(
&mut root,
&Event::DragStart {
x: 200,
y: 500,
origin_x: 200,
origin_y: 510,
},
&mut sink,
);
ctrl.process(&mut root, &Event::DragMove { x: 200, y: 1000 }, &mut sink);
ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 1000 }, &mut sink);
let offset = node_at_path(&root, &[0]).scroll.as_ref().unwrap().offset_y;
assert_eq!(
offset, 0,
"offset must clamp at 0 on upward drag past start"
);
let mut root2 = scroll_tree();
let mut dirty2: Vec<Rect> = Vec::new();
let mut sink2 = |r: Rect| dirty2.push(r);
let mut ctrl2 = ScrollController::default_config();
ctrl2.process(
&mut root2,
&Event::DragStart {
x: 200,
y: 700,
origin_x: 200,
origin_y: 710,
},
&mut sink2,
);
ctrl2.process(&mut root2, &Event::DragMove { x: 200, y: 0 }, &mut sink2); ctrl2.process(&mut root2, &Event::DragEnd { x: 200, y: 0 }, &mut sink2);
let offset2 = node_at_path(&root2, &[0]).scroll.as_ref().unwrap().offset_y;
let max_y = 1200 - 600; assert_eq!(
offset2, max_y,
"offset must clamp at max_scroll on downward drag"
);
assert!(
!dirty2.is_empty(),
"dirty rect must be pushed on effective offset change"
);
}
#[test]
fn throw_above_threshold_emits_scroll_throw() {
let mut root = scroll_tree();
let log = collect_events(&mut root, &[0]);
let mut ctrl = ScrollController::default_config();
let mut sink = |_: Rect| {};
ctrl.tick(&mut root, &mut sink); ctrl.process(
&mut root,
&Event::DragStart {
x: 200,
y: 500,
origin_x: 200,
origin_y: 510,
},
&mut sink,
);
ctrl.tick(&mut root, &mut sink); ctrl.process(&mut root, &Event::DragMove { x: 200, y: 490 }, &mut sink);
ctrl.tick(&mut root, &mut sink); ctrl.process(&mut root, &Event::DragMove { x: 200, y: 470 }, &mut sink);
ctrl.tick(&mut root, &mut sink); ctrl.process(&mut root, &Event::DragMove { x: 200, y: 450 }, &mut sink);
ctrl.tick(&mut root, &mut sink); ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 450 }, &mut sink);
let log_snap = log.borrow().clone();
let has_throw = log_snap
.iter()
.any(|e| matches!(e, ObjectEvent::ScrollThrow { .. }));
if has_throw {
let throw_idx = log_snap
.iter()
.position(|e| matches!(e, ObjectEvent::ScrollThrow { .. }))
.unwrap();
let begin_idx = log_snap
.iter()
.position(|e| matches!(e, ObjectEvent::ScrollBegin))
.unwrap();
assert!(
begin_idx < throw_idx,
"ScrollBegin must precede ScrollThrow"
);
}
}
#[test]
fn below_threshold_scroll_end_immediately() {
let mut root = scroll_tree();
let log = collect_events(&mut root, &[0]);
let mut ctrl = ScrollController::default_config();
let mut sink = |_: Rect| {};
ctrl.process(
&mut root,
&Event::DragStart {
x: 200,
y: 300,
origin_x: 200,
origin_y: 310,
},
&mut sink,
);
ctrl.process(&mut root, &Event::DragMove { x: 200, y: 299 }, &mut sink); for _ in 0..20 {
ctrl.tick(&mut root, &mut sink);
}
ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 299 }, &mut sink);
let log_snap = log.borrow().clone();
assert!(
!log_snap
.iter()
.any(|e| matches!(e, ObjectEvent::ScrollThrow { .. })),
"slow drag must not emit ScrollThrow; got: {:?}",
log_snap
);
assert!(
log_snap.iter().any(|e| matches!(e, ObjectEvent::ScrollEnd)),
"ScrollEnd must be emitted after below-threshold DragEnd"
);
}
#[test]
fn determinism_identical_input_produces_identical_output() {
let make_events = || -> (Vec<ObjectEvent>, i32) {
let mut root = scroll_tree();
let log = collect_events(&mut root, &[0]);
let mut ctrl = ScrollController::default_config();
let mut sink = |_: Rect| {};
ctrl.tick(&mut root, &mut sink);
ctrl.process(
&mut root,
&Event::DragStart {
x: 200,
y: 400,
origin_x: 200,
origin_y: 420,
},
&mut sink,
);
ctrl.tick(&mut root, &mut sink);
ctrl.process(&mut root, &Event::DragMove { x: 200, y: 380 }, &mut sink);
ctrl.tick(&mut root, &mut sink);
ctrl.process(&mut root, &Event::DragMove { x: 200, y: 360 }, &mut sink);
ctrl.tick(&mut root, &mut sink);
ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 360 }, &mut sink);
for _ in 0..20 {
ctrl.tick(&mut root, &mut sink);
}
let final_offset = node_at_path(&root, &[0]).scroll.as_ref().unwrap().offset_y;
(log.borrow().clone(), final_offset)
};
let (events1, offset1) = make_events();
let (events2, offset2) = make_events();
assert_eq!(
events1, events2,
"identical input must produce identical events"
);
assert_eq!(
offset1, offset2,
"identical input must produce identical final offset"
);
}
#[test]
fn snap_explicit_list_adjusts_endpoint() {
let mut root = {
let mut root = TestWidget::node(Rect {
x: 0,
y: 0,
width: 400,
height: 600,
});
let mut container = TestWidget::node(Rect {
x: 0,
y: 0,
width: 400,
height: 600,
});
let mut ss = ScrollState::new();
ss.content_h = 1200;
ss.snap = SnapConfig::Explicit(vec![0, 300, 600]);
ss.snap_attraction_radius = 100;
container.set_scroll_state(Box::new(ss));
root.append_child(container);
root
};
let mut ctrl = ScrollController::default_config();
let mut sink = |_: Rect| {};
ctrl.process(
&mut root,
&Event::DragStart {
x: 200,
y: 500,
origin_x: 200,
origin_y: 510,
},
&mut sink,
);
ctrl.process(&mut root, &Event::DragMove { x: 200, y: 220 }, &mut sink);
ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 220 }, &mut sink);
let _offset = node_at_path(&root, &[0]).scroll.as_ref().unwrap().offset_y;
}
#[test]
fn below_threshold_drag_settles_to_snap_point() {
let mut root = {
let mut root = TestWidget::node(Rect {
x: 0,
y: 0,
width: 400,
height: 600,
});
let mut container = TestWidget::node(Rect {
x: 0,
y: 0,
width: 400,
height: 600,
});
let mut ss = ScrollState::new();
ss.content_h = 1200;
ss.snap = SnapConfig::Explicit(vec![0, 300, 600]);
ss.snap_attraction_radius = 100;
container.set_scroll_state(Box::new(ss));
root.append_child(container);
root
};
let log = collect_events(&mut root, &[0]);
let mut ctrl = ScrollController::default_config();
let mut sink = |_: Rect| {};
ctrl.process(
&mut root,
&Event::DragStart {
x: 200,
y: 500,
origin_x: 200,
origin_y: 510,
},
&mut sink,
);
ctrl.process(&mut root, &Event::DragMove { x: 200, y: 220 }, &mut sink);
assert_eq!(
node_at_path(&root, &[0]).scroll.as_ref().unwrap().offset_y,
280,
"drag lands at 280 before release"
);
ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 220 }, &mut sink);
for _ in 0..SNAP_SETTLE_TICKS + 2 {
ctrl.tick(&mut root, &mut sink);
}
assert_eq!(
node_at_path(&root, &[0]).scroll.as_ref().unwrap().offset_y,
300,
"below-threshold release settles to the nearest snap point"
);
let evs = log.borrow();
assert!(
!evs.iter()
.any(|e| matches!(e, ObjectEvent::ScrollThrow { .. })),
"a snap settle is not a fling — no ScrollThrow: {evs:?}"
);
assert!(
evs.iter().any(|e| matches!(e, ObjectEvent::ScrollEnd)),
"settle completes with ScrollEnd: {evs:?}"
);
}
#[test]
fn snap_child_aligned_derives_from_children() {
let mut root = {
let mut root = TestWidget::node(Rect {
x: 0,
y: 0,
width: 400,
height: 600,
});
let mut container = TestWidget::node(Rect {
x: 0,
y: 0,
width: 400,
height: 600,
});
let mut ss = ScrollState::new();
ss.content_h = 1800;
ss.snap = SnapConfig::ChildAligned;
ss.snap_align = SnapAlign::Start;
ss.snap_attraction_radius = 80;
container.set_scroll_state(Box::new(ss));
container.append_child(TestWidget::node(Rect {
x: 0,
y: 0,
width: 400,
height: 600,
}));
container.append_child(TestWidget::node(Rect {
x: 0,
y: 600,
width: 400,
height: 600,
}));
container.append_child(TestWidget::node(Rect {
x: 0,
y: 1200,
width: 400,
height: 600,
}));
root.append_child(container);
root
};
let mut ctrl = ScrollController::default_config();
let mut sink = |_: Rect| {};
ctrl.tick(&mut root, &mut sink);
ctrl.process(
&mut root,
&Event::DragStart {
x: 200,
y: 500,
origin_x: 200,
origin_y: 510,
},
&mut sink,
);
ctrl.tick(&mut root, &mut sink);
ctrl.process(&mut root, &Event::DragMove { x: 200, y: 400 }, &mut sink);
ctrl.tick(&mut root, &mut sink);
ctrl.process(&mut root, &Event::DragMove { x: 200, y: 300 }, &mut sink);
ctrl.tick(&mut root, &mut sink);
ctrl.process(&mut root, &Event::DragMove { x: 200, y: 200 }, &mut sink);
ctrl.tick(&mut root, &mut sink);
ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 200 }, &mut sink);
for _ in 0..600 {
ctrl.tick(&mut root, &mut sink);
}
let offset = node_at_path(&root, &[0]).scroll.as_ref().unwrap().offset_y;
let max_scroll = 1800 - 600; assert!(
offset == 0 || offset == 600 || offset == 1200 || offset == max_scroll,
"offset should snap to a child boundary, got {}",
offset
);
}
#[test]
fn chaining_inner_at_edge_activates_outer() {
let mut root = {
let mut root = TestWidget::node(Rect {
x: 0,
y: 0,
width: 400,
height: 400,
});
let mut outer = TestWidget::node(Rect {
x: 0,
y: 0,
width: 400,
height: 400,
});
let mut outer_ss = ScrollState::new();
outer_ss.content_h = 1200;
outer.set_scroll_state(Box::new(outer_ss));
let mut inner = TestWidget::node(Rect {
x: 0,
y: 0,
width: 400,
height: 200,
});
let mut inner_ss = ScrollState::new();
inner_ss.content_h = 400; inner.set_scroll_state(Box::new(inner_ss));
outer.append_child(inner);
root.append_child(outer);
root
};
let outer_log = collect_events(&mut root, &[0]);
let inner_log = collect_events(&mut root, &[0, 0]);
let mut ctrl = ScrollController::default_config();
let mut sink = |_: Rect| {};
ctrl.process(
&mut root,
&Event::DragStart {
x: 200,
y: 150,
origin_x: 200,
origin_y: 160,
},
&mut sink,
);
ctrl.process(&mut root, &Event::DragMove { x: 200, y: 0 }, &mut sink);
let inner_offset = node_at_path(&root, &[0, 0])
.scroll
.as_ref()
.unwrap()
.offset_y;
assert!(inner_offset <= 200, "inner clamped at max");
ctrl.process(&mut root, &Event::DragMove { x: 200, y: -100 }, &mut sink);
let inner_events = inner_log.borrow().clone();
let outer_events = outer_log.borrow().clone();
let _ = inner_events;
let _ = outer_events;
}
#[test]
fn supersession_no_scroll_end_for_interrupted_throw() {
let mut root = scroll_tree();
let log = collect_events(&mut root, &[0]);
let mut ctrl = ScrollController::default_config();
let mut sink = |_: Rect| {};
ctrl.process(
&mut root,
&Event::DragStart {
x: 200,
y: 500,
origin_x: 200,
origin_y: 520,
},
&mut sink,
);
ctrl.tick(&mut root, &mut sink);
ctrl.process(&mut root, &Event::DragMove { x: 200, y: 480 }, &mut sink);
ctrl.tick(&mut root, &mut sink);
ctrl.process(&mut root, &Event::DragMove { x: 200, y: 460 }, &mut sink);
ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 460 }, &mut sink);
let end_count_before = log
.borrow()
.iter()
.filter(|e| matches!(e, ObjectEvent::ScrollEnd))
.count();
ctrl.process(
&mut root,
&Event::DragStart {
x: 200,
y: 400,
origin_x: 200,
origin_y: 410,
},
&mut sink,
);
let end_count_after = log
.borrow()
.iter()
.filter(|e| matches!(e, ObjectEvent::ScrollEnd))
.count();
assert_eq!(
end_count_before, end_count_after,
"new DragStart during throw must NOT emit ScrollEnd for the old session"
);
}
#[test]
fn bubbling_requires_event_bubble_flag_on_container() {
let mut root = scroll_tree();
let root_saw: Rc<RefCell<Vec<ObjectEvent>>> = Rc::new(RefCell::new(Vec::new()));
let rsaw = root_saw.clone();
root.add_bubble_handler(move |ev, _ctx| {
rsaw.borrow_mut().push(ev.clone());
false
});
let mut ctrl = ScrollController::default_config();
let mut sink = |_: Rect| {};
ctrl.process(
&mut root,
&Event::DragStart {
x: 200,
y: 300,
origin_x: 200,
origin_y: 310,
},
&mut sink,
);
ctrl.process(&mut root, &Event::DragMove { x: 200, y: 280 }, &mut sink);
ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 280 }, &mut sink);
let root_events = root_saw.borrow();
let has_scroll = root_events.iter().any(|e| {
matches!(
e,
ObjectEvent::ScrollBegin | ObjectEvent::Scroll { .. } | ObjectEvent::ScrollEnd
)
});
assert!(
!has_scroll,
"scroll events must not bubble to root without EVENT_BUBBLE on container"
);
let container_flags = node_at_path(&root, &[0]).flags();
assert!(
!container_flags.contains(ObjectFlags::EVENT_BUBBLE),
"controller must not set EVENT_BUBBLE on the container"
);
}
#[test]
fn bubbling_works_when_event_bubble_set() {
let mut root = scroll_tree();
node_at_path_mut(&mut root, &[0]).set_flag(ObjectFlags::EVENT_BUBBLE, true);
let root_saw: Rc<RefCell<Vec<ObjectEvent>>> = Rc::new(RefCell::new(Vec::new()));
let rsaw = root_saw.clone();
root.add_bubble_handler(move |ev, _ctx| {
rsaw.borrow_mut().push(ev.clone());
false
});
let mut ctrl = ScrollController::default_config();
let mut sink = |_: Rect| {};
ctrl.process(
&mut root,
&Event::DragStart {
x: 200,
y: 300,
origin_x: 200,
origin_y: 310,
},
&mut sink,
);
ctrl.process(&mut root, &Event::DragMove { x: 200, y: 280 }, &mut sink);
ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 280 }, &mut sink);
let root_events = root_saw.borrow();
let has_scroll = root_events.iter().any(|e| {
matches!(
e,
ObjectEvent::ScrollBegin | ObjectEvent::Scroll { .. } | ObjectEvent::ScrollEnd
)
});
assert!(
has_scroll,
"scroll events must bubble to root when EVENT_BUBBLE is set"
);
assert!(
node_at_path(&root, &[0])
.flags()
.contains(ObjectFlags::EVENT_BUBBLE),
"controller must not clear EVENT_BUBBLE after session"
);
}
#[test]
fn path_targeted_dispatch_delivers_to_container() {
use crate::object::{DispatchInput, dispatch_object_event};
let mut root = scroll_tree();
let log = collect_events(&mut root, &[0]);
let _ = dispatch_object_event(
&mut root,
DispatchInput::Container {
path: vec![0],
event: ObjectEvent::ScrollBegin,
},
);
let events = log.borrow();
assert!(
events.iter().any(|e| matches!(e, ObjectEvent::ScrollBegin)),
"Container dispatch must deliver ScrollBegin to the target"
);
}
#[test]
fn scroll_state_max_scroll_clamped() {
let mut ss = ScrollState::new();
ss.content_h = 1200;
assert_eq!(ss.max_scroll_y(600), 600);
assert_eq!(ss.max_scroll_y(1200), 0, "no overflow = max is 0");
assert_eq!(ss.max_scroll_y(1400), 0, "viewport > content = max is 0");
assert!(ss.set_offset_y(100, 600));
assert_eq!(ss.offset_y, 100);
assert!(!ss.set_offset_y(100, 600), "no change when same value");
assert!(ss.set_offset_y(700, 600), "clamps to max");
assert_eq!(ss.offset_y, 600);
assert!(ss.set_offset_y(-10, 600), "clamps to 0");
assert_eq!(ss.offset_y, 0);
}
fn vp(x: i32, y: i32, w: i32, h: i32) -> Rect {
Rect {
x,
y,
width: w,
height: h,
}
}
#[test]
fn scrollbar_thumb_returns_none_when_content_fits() {
let mut ss = ScrollState::new();
ss.content_h = 400; let vp = vp(0, 0, 200, 600);
assert!(
ss.scrollbar_thumb(vp).is_none(),
"no thumb when content_h ≤ viewport_h"
);
}
#[test]
fn scrollbar_thumb_returns_none_when_mode_off() {
let mut ss = ScrollState::new();
ss.content_h = 1200;
ss.scrollbar_mode = ScrollbarMode::Off;
let vp = vp(0, 0, 200, 600);
assert!(
ss.scrollbar_thumb(vp).is_none(),
"no thumb when mode is Off"
);
}
#[test]
fn scrollbar_thumb_on_right_edge_with_correct_geometry() {
let mut ss = ScrollState::new();
ss.content_h = 1200;
ss.offset_y = 0;
let vp = vp(0, 0, 200, 600);
let thumb = ss
.scrollbar_thumb(vp)
.expect("thumb must be Some when overflowing");
assert_eq!(thumb.width, SCROLLBAR_WIDTH);
assert_eq!(
thumb.x,
200 - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN,
"thumb must be on the right edge"
);
assert_eq!(thumb.height, 300, "thumb height formula mismatch");
assert_eq!(thumb.y, 0, "thumb at top when offset is 0");
}
#[test]
fn scrollbar_thumb_position_reflects_scroll_offset() {
let mut ss = ScrollState::new();
ss.content_h = 1200;
let vp = vp(0, 0, 200, 600);
ss.offset_y = ss.max_scroll_y(600); let thumb = ss.scrollbar_thumb(vp).expect("thumb must be Some");
assert_eq!(thumb.y, 300, "thumb at bottom when offset is max");
ss.offset_y = 300;
let thumb_mid = ss.scrollbar_thumb(vp).expect("thumb must be Some");
assert_eq!(
thumb_mid.y, 150,
"thumb at center when offset is half of max"
);
}
}