use crate::layout::LayoutNode;
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct ScrollState {
pub offset: f32,
grab: Option<f32>,
}
fn xy(rest: &str) -> Option<(f32, f32)> {
let mut it = rest.split(':');
Some((it.next()?.parse().ok()?, it.next()?.parse().ok()?))
}
impl ScrollState {
pub const fn new() -> Self {
Self {
offset: 0.0,
grab: None,
}
}
pub fn is_dragging(&self) -> bool {
self.grab.is_some()
}
pub fn reset(&mut self) {
*self = Self::new();
}
pub fn handle(&mut self, event: &str, view: &LayoutNode, wheel_step: f32) -> bool {
let max = view.max_scroll();
if let Some(dy) = event.strip_prefix("scroll:") {
let Ok(dy) = dy.parse::<f32>() else {
return false;
};
self.offset = (self.offset.min(max) - dy * wheel_step).clamp(0.0, max);
return true;
}
if let Some((mx, my)) = event.strip_prefix("click:").and_then(xy) {
let Some((track, knob)) = view.scrollbar_rects() else {
return false;
};
if !track.contains(mx, my) {
return false;
}
let grab = if knob.contains(mx, my) {
my - knob.y
} else {
knob.h / 2.0
};
self.grab = Some(grab);
self.drag_to(my, view);
return true;
}
if let Some((_, my)) = event.strip_prefix("drag:").and_then(xy) {
if self.grab.is_some() {
self.drag_to(my, view);
return true;
}
return false;
}
if event.starts_with("release:") {
return self.grab.take().is_some();
}
false
}
fn drag_to(&mut self, my: f32, view: &LayoutNode) {
let (Some(grab), Some((track, knob))) = (self.grab, view.scrollbar_rects()) else {
return;
};
let travel = (track.h - knob.h).max(1.0);
let t = ((my - grab - track.y) / travel).clamp(0.0, 1.0);
self.offset = t * view.max_scroll();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::{self, FlexDir};
use crate::widget;
fn view(offset: f32) -> LayoutNode {
let list = widget::scroll_view()
.id("list")
.w(100.0)
.h(100.0)
.gap(0.0)
.scroll_y(offset)
.children((0..10).map(|i| {
widget::panel(FlexDir::Column)
.w(widget::Units::Percent(100.0))
.h(30.0)
.on_click(format!("row:{i}"))
}));
let root = widget::panel(FlexDir::Column).child(list);
let node = layout::compute(&root, 400.0, 400.0);
layout::find_node(&node, "list").unwrap().clone()
}
#[test]
fn layout_clamps_and_applies_the_offset() {
let v = view(50.0);
assert_eq!(v.content_h, 300.0);
assert_eq!(v.max_scroll(), 200.0);
assert_eq!(v.scroll_y, 50.0);
assert_eq!(v.children[0].rect.y, -50.0);
assert_eq!(v.children[0].rect.w, 100.0 - layout::SCROLLBAR_W);
assert_eq!(view(1000.0).scroll_y, 200.0);
let root_like = LayoutNode {
children: vec![v.clone()],
rect: v.rect,
..Default::default()
};
assert!(layout::hit_test(&root_like, 10.0, 5.0)
.and_then(|n| n.on_click.as_deref())
.is_some_and(|e| e == "row:1"));
}
#[test]
fn wheel_and_knob_drag() {
let v = view(0.0);
let mut s = ScrollState::new();
assert!(s.handle("scroll:-2.00", &v, 30.0));
assert_eq!(s.offset, 60.0);
assert!(s.handle("scroll:10", &v, 30.0));
assert_eq!(s.offset, 0.0);
assert!(
!s.handle("click:50:10", &v, 30.0),
"content click isn't ours"
);
assert!(s.handle("click:97:10", &v, 30.0));
assert!(s.is_dragging());
assert!(s.handle("drag:97:200", &v, 30.0));
assert_eq!(s.offset, 200.0);
assert!(s.handle("release:97:200", &v, 30.0));
assert!(!s.is_dragging());
assert!(!s.handle("drag:97:0", &v, 30.0));
}
#[test]
fn no_scrollbar_without_overflow() {
let list = widget::scroll_view().id("l").w(100.0).h(100.0);
let node = layout::compute(&list.child(widget::label("x")), 400.0, 400.0);
assert!(node.scrollbar_rects().is_none());
assert!(!ScrollState::new().handle("click:97:10", &node, 10.0));
}
}