use crate::event::{Key, NamedKey, Rect, ScrollDelta};
use crate::primitives::scrollbar::{fit_thumb, ScrollAxis, Scrollbar};
use crate::types::{Modifiers, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ScrollView {
pub id: WidgetId,
#[serde(default = "ScrollAxis::vertical_default")]
pub axis: ScrollAxis,
pub viewport: Rect,
pub content_len: f32,
#[serde(default)]
pub scroll_offset: f32,
#[serde(default)]
pub focused: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum ScrollViewEvent {
Scrolled { offset: f32 },
}
impl ScrollAxis {
fn vertical_default() -> Self {
ScrollAxis::Vertical
}
}
impl ScrollView {
pub fn new(id: impl Into<WidgetId>, viewport: Rect, content_len: f32) -> Self {
Self {
id: id.into(),
axis: ScrollAxis::Vertical,
viewport,
content_len,
scroll_offset: 0.0,
focused: false,
}
}
pub fn vertical(id: impl Into<WidgetId>, viewport: Rect, content_len: f32) -> Self {
Self::new(id, viewport, content_len)
}
pub fn horizontal(id: impl Into<WidgetId>, viewport: Rect, content_len: f32) -> Self {
Self {
axis: ScrollAxis::Horizontal,
..Self::new(id, viewport, content_len)
}
}
pub fn visible_len(&self) -> f32 {
match self.axis {
ScrollAxis::Vertical => self.viewport.height,
ScrollAxis::Horizontal => self.viewport.width,
}
}
pub fn max_offset(&self) -> f32 {
(self.content_len - self.visible_len()).max(0.0)
}
pub fn clamped_offset(&self) -> f32 {
self.scroll_offset.clamp(0.0, self.max_offset())
}
pub fn clamp_offset(&mut self) {
self.scroll_offset = self.clamped_offset();
}
pub fn visible_range(&self) -> (f32, f32) {
if self.content_len <= 0.0 {
return (0.0, 0.0);
}
let start = self.clamped_offset();
let end = (start + self.visible_len()).min(self.content_len);
(start, end)
}
pub fn is_visible(&self, child_start: f32, child_len: f32) -> bool {
if child_len <= 0.0 {
return false;
}
let (start, end) = self.visible_range();
child_start < end && (child_start + child_len) > start
}
pub fn scroll_by(&self, delta: f32) -> Option<ScrollViewEvent> {
let offset = (self.scroll_offset + delta).clamp(0.0, self.max_offset());
if offset == self.scroll_offset {
None
} else {
Some(ScrollViewEvent::Scrolled { offset })
}
}
pub fn handle_wheel(&self, delta: ScrollDelta, step: f32) -> Option<ScrollViewEvent> {
let notches = match self.axis {
ScrollAxis::Vertical => delta.y,
ScrollAxis::Horizontal => delta.x,
};
self.scroll_by(notches * step)
}
pub fn handle_key(
&self,
key: &Key,
_modifiers: &Modifiers,
step: f32,
) -> Option<ScrollViewEvent> {
if !self.focused {
return None;
}
let (forward, backward) = match self.axis {
ScrollAxis::Vertical => (NamedKey::Down, NamedKey::Up),
ScrollAxis::Horizontal => (NamedKey::Right, NamedKey::Left),
};
match key {
Key::Named(k) if *k == forward => self.scroll_by(step),
Key::Named(k) if *k == backward => self.scroll_by(-step),
Key::Named(NamedKey::PageDown) => self.scroll_by(self.visible_len()),
Key::Named(NamedKey::PageUp) => self.scroll_by(-self.visible_len()),
Key::Named(NamedKey::Home) => {
if self.scroll_offset == 0.0 {
None
} else {
Some(ScrollViewEvent::Scrolled { offset: 0.0 })
}
}
Key::Named(NamedKey::End) => {
let offset = self.max_offset();
if offset == self.scroll_offset {
None
} else {
Some(ScrollViewEvent::Scrolled { offset })
}
}
_ => None,
}
}
pub fn scrollbar(&self, track: Rect, min_thumb_len: f32) -> Option<Scrollbar> {
let track_len = match self.axis {
ScrollAxis::Vertical => track.height,
ScrollAxis::Horizontal => track.width,
};
let (thumb_start, thumb_len) = fit_thumb(
self.clamped_offset(),
self.content_len,
self.visible_len(),
track_len,
min_thumb_len,
);
if thumb_len <= 0.0 {
return None;
}
Some(Scrollbar {
id: self.id,
axis: self.axis,
track,
thumb_start,
thumb_len,
hovered: false,
dragging: false,
})
}
}
impl Default for ScrollView {
fn default() -> Self {
Self::new(WidgetId::new(""), Rect::default(), 0.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn view() -> ScrollView {
ScrollView::new(
WidgetId::new("test:scroll"),
Rect::new(0.0, 0.0, 20.0, 10.0),
50.0,
)
}
#[test]
fn max_offset_is_content_minus_visible() {
assert_eq!(view().max_offset(), 40.0);
}
#[test]
fn max_offset_floors_at_zero_when_content_fits() {
let v = ScrollView::new(WidgetId::new("x"), Rect::new(0.0, 0.0, 20.0, 10.0), 5.0);
assert_eq!(v.max_offset(), 0.0);
}
#[test]
fn scroll_by_clamps_to_zero_and_max() {
let mut v = view();
assert_eq!(v.scroll_by(-10.0), None);
v.scroll_offset = 5.0;
assert_eq!(
v.scroll_by(-10.0).unwrap(),
ScrollViewEvent::Scrolled { offset: 0.0 }
);
assert_eq!(
v.scroll_by(1000.0).unwrap(),
ScrollViewEvent::Scrolled { offset: 40.0 }
);
}
#[test]
fn scroll_by_zero_effective_delta_returns_none() {
let v = view();
assert_eq!(v.scroll_by(-5.0), None);
}
#[test]
fn visible_range_tracks_offset() {
let mut v = view();
assert_eq!(v.visible_range(), (0.0, 10.0));
v.scroll_offset = 20.0;
assert_eq!(v.visible_range(), (20.0, 30.0));
v.scroll_offset = 45.0; assert_eq!(v.visible_range(), (40.0, 50.0));
}
#[test]
fn visible_range_empty_content_is_zero_zero() {
let v = ScrollView::new(WidgetId::new("x"), Rect::new(0.0, 0.0, 20.0, 10.0), 0.0);
assert_eq!(v.visible_range(), (0.0, 0.0));
}
#[test]
fn is_visible_detects_overlap_with_the_visible_window() {
let mut v = view();
v.scroll_offset = 10.0; assert!(v.is_visible(5.0, 10.0)); assert!(v.is_visible(15.0, 20.0)); assert!(!v.is_visible(0.0, 5.0)); assert!(!v.is_visible(20.0, 5.0)); assert!(!v.is_visible(10.0, 0.0)); }
#[test]
fn handle_wheel_scrolls_vertical_by_delta_y_times_step() {
let v = view();
let event = v.handle_wheel(ScrollDelta::new(0.0, 2.0), 3.0);
assert_eq!(event, Some(ScrollViewEvent::Scrolled { offset: 6.0 }));
}
#[test]
fn handle_wheel_ignores_focus() {
let v = view(); assert!(v.handle_wheel(ScrollDelta::new(0.0, 1.0), 1.0).is_some());
}
#[test]
fn handle_wheel_on_horizontal_view_uses_delta_x() {
let v = ScrollView::horizontal(WidgetId::new("x"), Rect::new(0.0, 0.0, 20.0, 10.0), 50.0);
let event = v.handle_wheel(ScrollDelta::new(1.0, 5.0), 2.0);
assert_eq!(event, Some(ScrollViewEvent::Scrolled { offset: 2.0 }));
}
#[test]
fn handle_key_requires_focus() {
let v = view();
assert_eq!(
v.handle_key(&Key::Named(NamedKey::Down), &Modifiers::default(), 1.0),
None
);
}
#[test]
fn handle_key_up_down_move_by_step_when_focused() {
let mut v = view();
v.focused = true;
assert_eq!(
v.handle_key(&Key::Named(NamedKey::Down), &Modifiers::default(), 2.0),
Some(ScrollViewEvent::Scrolled { offset: 2.0 })
);
v.scroll_offset = 10.0;
assert_eq!(
v.handle_key(&Key::Named(NamedKey::Up), &Modifiers::default(), 2.0),
Some(ScrollViewEvent::Scrolled { offset: 8.0 })
);
}
#[test]
fn handle_key_page_up_down_move_by_visible_len() {
let mut v = view();
v.focused = true;
assert_eq!(
v.handle_key(&Key::Named(NamedKey::PageDown), &Modifiers::default(), 1.0),
Some(ScrollViewEvent::Scrolled { offset: 10.0 })
);
v.scroll_offset = 20.0;
assert_eq!(
v.handle_key(&Key::Named(NamedKey::PageUp), &Modifiers::default(), 1.0),
Some(ScrollViewEvent::Scrolled { offset: 10.0 })
);
}
#[test]
fn handle_key_home_end_jump_to_bounds() {
let mut v = view();
v.focused = true;
v.scroll_offset = 20.0;
assert_eq!(
v.handle_key(&Key::Named(NamedKey::Home), &Modifiers::default(), 1.0),
Some(ScrollViewEvent::Scrolled { offset: 0.0 })
);
assert_eq!(
v.handle_key(&Key::Named(NamedKey::End), &Modifiers::default(), 1.0),
Some(ScrollViewEvent::Scrolled { offset: 40.0 })
);
}
#[test]
fn handle_key_home_already_at_start_returns_none() {
let mut v = view();
v.focused = true;
assert_eq!(
v.handle_key(&Key::Named(NamedKey::Home), &Modifiers::default(), 1.0),
None
);
}
#[test]
fn handle_key_horizontal_view_uses_left_right() {
let mut v =
ScrollView::horizontal(WidgetId::new("x"), Rect::new(0.0, 0.0, 20.0, 10.0), 50.0);
v.focused = true;
assert_eq!(
v.handle_key(&Key::Named(NamedKey::Right), &Modifiers::default(), 3.0),
Some(ScrollViewEvent::Scrolled { offset: 3.0 })
);
assert_eq!(
v.handle_key(&Key::Named(NamedKey::Down), &Modifiers::default(), 3.0),
None
);
}
#[test]
fn handle_key_ignores_unrelated_keys() {
let mut v = view();
v.focused = true;
assert_eq!(
v.handle_key(&Key::Named(NamedKey::Tab), &Modifiers::default(), 1.0),
None
);
}
#[test]
fn scrollbar_none_when_content_fits_viewport() {
let v = ScrollView::new(WidgetId::new("x"), Rect::new(0.0, 0.0, 20.0, 10.0), 5.0);
assert_eq!(v.scrollbar(Rect::new(19.0, 0.0, 1.0, 10.0), 1.0), None);
}
#[test]
fn scrollbar_thumb_shrinks_as_offset_advances() {
let mut v = view();
let track = Rect::new(19.0, 0.0, 1.0, 10.0);
let at_top = v.scrollbar(track, 1.0).unwrap();
assert_eq!(at_top.thumb_start, 0.0);
v.scroll_offset = v.max_offset();
let at_bottom = v.scrollbar(track, 1.0).unwrap();
assert!(at_bottom.thumb_start > at_top.thumb_start);
}
#[test]
fn clamp_offset_pins_a_directly_written_offset() {
let mut v = view();
v.scroll_offset = 999.0;
v.clamp_offset();
assert_eq!(v.scroll_offset, 40.0);
}
}