diff --git a/widget/src/scrollable.rs b/widget/src/scrollable.rs index 2cc1d9e4..913e5f3b 100644 --- a/widget/src/scrollable.rs +++ b/widget/src/scrollable.rs @@ -567,6 +567,9 @@ where shell: &mut Shell<'_, Message>, _viewport: &Rectangle, ) { + const AUTOSCROLL_DEADZONE: f32 = 20.0; + const AUTOSCROLL_SPEED: f32 = 10.0; + let state = tree.state.downcast_mut::(); let bounds = layout.bounds(); let cursor_over_scrollable = cursor.position_over(bounds); @@ -601,7 +604,7 @@ where } let mut update = || { - if let Some(scroller_grabbed_at) = state.y_scroller_grabbed_at { + if let Some(scroller_grabbed_at) = state.y_scroller_grabbed_at() { match event { Event::Mouse(mouse::Event::CursorMoved { .. }) | Event::Touch(touch::Event::FingerMoved { .. }) => { @@ -657,8 +660,9 @@ where content_bounds, ); - state.y_scroller_grabbed_at = - Some(scroller_grabbed_at); + state.interaction = Interaction::YScrollerGrabbed( + scroller_grabbed_at, + ); let _ = notify_scroll( state, @@ -675,7 +679,7 @@ where } } - if let Some(scroller_grabbed_at) = state.x_scroller_grabbed_at { + if let Some(scroller_grabbed_at) = state.x_scroller_grabbed_at() { match event { Event::Mouse(mouse::Event::CursorMoved { .. }) | Event::Touch(touch::Event::FingerMoved { .. }) => { @@ -730,8 +734,9 @@ where content_bounds, ); - state.x_scroller_grabbed_at = - Some(scroller_grabbed_at); + state.interaction = Interaction::XScrollerGrabbed( + scroller_grabbed_at, + ); let _ = notify_scroll( state, @@ -800,10 +805,19 @@ where | touch::Event::FingerLost { .. } ) ) { - state.scroll_area_touched_at = None; - state.x_scroller_grabbed_at = None; - state.y_scroller_grabbed_at = None; + state.interaction = Interaction::None; + return; + } + if matches!(state.interaction, Interaction::AutoScrolling { .. }) + && matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(_)) + | Event::Touch(_) + | Event::Keyboard(_) + ) + { + state.interaction = Interaction::None; return; } @@ -811,15 +825,6 @@ where return; } - if let Event::Keyboard(keyboard::Event::ModifiersChanged( - modifiers, - )) = event - { - state.keyboard_modifiers = *modifiers; - - return; - } - match event { Event::Mouse(mouse::Event::WheelScrolled { delta }) => { if cursor_over_scrollable.is_none() { @@ -874,58 +879,172 @@ where shell.capture_event(); } } + Event::Mouse(mouse::Event::ButtonPressed( + mouse::Button::Middle, + )) if matches!(state.interaction, Interaction::None) => { + let Some(origin) = cursor_over_scrollable else { + return; + }; + + state.interaction = Interaction::AutoScrolling { + origin, + current: origin, + last_frame: None, + }; + + shell.capture_event(); + } Event::Touch(event) - if state.scroll_area_touched_at.is_some() - || (!mouse_over_y_scrollbar - && !mouse_over_x_scrollbar) => + if matches!( + state.interaction, + Interaction::TouchScrolling(_) + ) || (!mouse_over_y_scrollbar + && !mouse_over_x_scrollbar) => { match event { touch::Event::FingerPressed { .. } => { - if cursor_over_scrollable.is_none() { + let Some(position) = cursor_over_scrollable else { return; - } + }; - state.scroll_area_touched_at = cursor.position(); + state.interaction = + Interaction::TouchScrolling(position); } touch::Event::FingerMoved { .. } => { - if let Some(scroll_box_touched_at) = - state.scroll_area_touched_at - { - let Some(cursor_position) = cursor.position() - else { - return; - }; + let Interaction::TouchScrolling( + scroll_box_touched_at, + ) = state.interaction + else { + return; + }; - let delta = Vector::new( - scroll_box_touched_at.x - cursor_position.x, - scroll_box_touched_at.y - cursor_position.y, - ); + let Some(cursor_position) = cursor.position() + else { + return; + }; - state.scroll( - self.direction.align(delta), - bounds, - content_bounds, - ); + let delta = Vector::new( + scroll_box_touched_at.x - cursor_position.x, + scroll_box_touched_at.y - cursor_position.y, + ); - state.scroll_area_touched_at = - Some(cursor_position); + state.scroll( + self.direction.align(delta), + bounds, + content_bounds, + ); - // TODO: bubble up touch movements if not consumed. - let _ = notify_scroll( - state, - &self.on_scroll, - bounds, - content_bounds, - shell, - ); - } + state.interaction = + Interaction::TouchScrolling(cursor_position); + + // TODO: bubble up touch movements if not consumed. + let _ = notify_scroll( + state, + &self.on_scroll, + bounds, + content_bounds, + shell, + ); } _ => {} } shell.capture_event(); } - Event::Window(window::Event::RedrawRequested(_)) => { + Event::Mouse(mouse::Event::CursorMoved { position }) => { + if let Interaction::AutoScrolling { + origin, + last_frame, + .. + } = state.interaction + { + let delta = *position - origin; + + if delta.x.abs() >= AUTOSCROLL_DEADZONE + || delta.y.abs() >= AUTOSCROLL_DEADZONE + { + state.interaction = Interaction::AutoScrolling { + origin, + current: *position, + last_frame, + }; + + if last_frame.is_none() { + shell.request_redraw(); + } + } + } + } + Event::Keyboard(keyboard::Event::ModifiersChanged( + modifiers, + )) => { + state.keyboard_modifiers = *modifiers; + } + Event::Window(window::Event::RedrawRequested(now)) => { + if let Interaction::AutoScrolling { + origin, + current, + last_frame, + } = state.interaction + { + if last_frame == Some(*now) { + shell.request_redraw(); + return; + } + + state.interaction = Interaction::AutoScrolling { + origin, + current, + last_frame: None, + }; + + let delta = current - origin; + + if delta.x.abs() >= AUTOSCROLL_DEADZONE + || delta.y.abs() >= AUTOSCROLL_DEADZONE + { + let time_delta = + if let Some(last_frame) = last_frame { + *now - last_frame + } else { + Duration::ZERO + }; + + let scroll_factor = + time_delta.as_secs_f32() * AUTOSCROLL_SPEED; + + state.scroll( + self.direction.align(Vector::new( + delta.x * scroll_factor, + delta.y * scroll_factor, + )), + bounds, + content_bounds, + ); + + let has_scrolled = notify_scroll( + state, + &self.on_scroll, + bounds, + content_bounds, + shell, + ); + + if has_scrolled || time_delta.is_zero() { + state.interaction = + Interaction::AutoScrolling { + origin, + current, + last_frame: Some(*now), + }; + + shell.request_redraw(); + } + + return; + } + } + let _ = notify_viewport( state, &self.on_scroll, @@ -940,15 +1059,13 @@ where update(); - let status = if state.y_scroller_grabbed_at.is_some() - || state.x_scroller_grabbed_at.is_some() - { + let status = if state.scrollers_grabbed() { Status::Dragged { is_horizontal_scrollbar_dragged: state - .x_scroller_grabbed_at + .x_scroller_grabbed_at() .is_some(), is_vertical_scrollbar_dragged: state - .y_scroller_grabbed_at + .y_scroller_grabbed_at() .is_some(), is_horizontal_scrollbar_disabled: scrollbars.is_x_disabled(), is_vertical_scrollbar_disabled: scrollbars.is_y_disabled(), @@ -1318,25 +1435,34 @@ fn notify_viewport( #[derive(Debug, Clone, Copy)] struct State { - scroll_area_touched_at: Option, offset_y: Offset, - y_scroller_grabbed_at: Option, offset_x: Offset, - x_scroller_grabbed_at: Option, + interaction: Interaction, keyboard_modifiers: keyboard::Modifiers, last_notified: Option, last_scrolled: Option, is_scrollbar_visible: bool, } +#[derive(Debug, Clone, Copy)] +enum Interaction { + None, + YScrollerGrabbed(f32), + XScrollerGrabbed(f32), + TouchScrolling(Point), + AutoScrolling { + origin: Point, + current: Point, + last_frame: Option, + }, +} + impl Default for State { fn default() -> Self { Self { - scroll_area_touched_at: None, offset_y: Offset::Absolute(0.0), - y_scroller_grabbed_at: None, offset_x: Offset::Absolute(0.0), - x_scroller_grabbed_at: None, + interaction: Interaction::None, keyboard_modifiers: keyboard::Modifiers::default(), last_notified: None, last_scrolled: None, @@ -1455,14 +1581,11 @@ impl Viewport { } impl State { - /// Creates a new [`State`] with the scrollbar(s) at the beginning. - pub fn new() -> Self { + fn new() -> Self { State::default() } - /// Apply a scrolling offset to the current [`State`], given the bounds of - /// the [`Scrollable`] and its contents. - pub fn scroll( + fn scroll( &mut self, delta: Vector, bounds: Rectangle, @@ -1485,11 +1608,7 @@ impl State { } } - /// Scrolls the [`Scrollable`] to a relative amount along the y axis. - /// - /// `0` represents scrollbar at the beginning, while `1` represents scrollbar at - /// the end. - pub fn scroll_y_to( + fn scroll_y_to( &mut self, percentage: f32, bounds: Rectangle, @@ -1499,11 +1618,7 @@ impl State { self.unsnap(bounds, content_bounds); } - /// Scrolls the [`Scrollable`] to a relative amount along the x axis. - /// - /// `0` represents scrollbar at the beginning, while `1` represents scrollbar at - /// the end. - pub fn scroll_x_to( + fn scroll_x_to( &mut self, percentage: f32, bounds: Rectangle, @@ -1513,14 +1628,12 @@ impl State { self.unsnap(bounds, content_bounds); } - /// Snaps the scroll position to a [`RelativeOffset`]. - pub fn snap_to(&mut self, offset: RelativeOffset) { + fn snap_to(&mut self, offset: RelativeOffset) { self.offset_x = Offset::Relative(offset.x.clamp(0.0, 1.0)); self.offset_y = Offset::Relative(offset.y.clamp(0.0, 1.0)); } - /// Scroll to the provided [`AbsoluteOffset`]. - pub fn scroll_to(&mut self, offset: AbsoluteOffset) { + fn scroll_to(&mut self, offset: AbsoluteOffset) { self.offset_x = Offset::Absolute(offset.x.max(0.0)); self.offset_y = Offset::Absolute(offset.y.max(0.0)); } @@ -1580,10 +1693,27 @@ impl State { ) } - /// Returns whether any scroller is currently grabbed or not. - pub fn scrollers_grabbed(&self) -> bool { - self.x_scroller_grabbed_at.is_some() - || self.y_scroller_grabbed_at.is_some() + fn scrollers_grabbed(&self) -> bool { + matches!( + self.interaction, + Interaction::YScrollerGrabbed(_) | Interaction::XScrollerGrabbed(_), + ) + } + + pub fn y_scroller_grabbed_at(&self) -> Option { + let Interaction::YScrollerGrabbed(at) = self.interaction else { + return None; + }; + + Some(at) + } + + pub fn x_scroller_grabbed_at(&self) -> Option { + let Interaction::XScrollerGrabbed(at) = self.interaction else { + return None; + }; + + Some(at) } }