use emath::{Rect, Vec2, vec2};
use crate::{InputOptions, Modifiers, MouseWheelSource, MouseWheelUnit, TouchPhase};
const SCROLL_ACTION_TIMEOUT: f64 = 0.150;
const RESTING_FINGER_TIMEOUT: f64 = 1.0;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Status {
Static,
Smoothing,
InTouch,
}
#[derive(Clone, Debug)]
pub struct WheelState {
pub status: Status,
pub source: MouseWheelSource,
pub modifiers: Modifiers,
pub last_wheel_event: f64,
pub unprocessed_wheel_delta: Vec2,
pub smooth_wheel_delta: Vec2,
}
impl Default for WheelState {
fn default() -> Self {
Self {
status: Status::Static,
source: MouseWheelSource::Unknown,
modifiers: Default::default(),
last_wheel_event: f64::NEG_INFINITY,
unprocessed_wheel_delta: Vec2::ZERO,
smooth_wheel_delta: Vec2::ZERO,
}
}
}
impl WheelState {
#[expect(clippy::too_many_arguments)]
pub fn on_wheel_event(
&mut self,
viewport_rect: Rect,
options: &InputOptions,
time: f64,
unit: MouseWheelUnit,
delta: Vec2,
phase: TouchPhase,
source: MouseWheelSource,
latest_modifiers: Modifiers,
) {
if self.is_scroll_action_over(time) {
self.end_scroll_action();
}
self.last_wheel_event = time;
self.source = source;
match phase {
crate::TouchPhase::Start => {
self.status = Status::InTouch;
self.modifiers = latest_modifiers;
}
crate::TouchPhase::Move => {
match self.status {
Status::Static | Status::Smoothing => {
self.modifiers = latest_modifiers;
self.status = Status::Smoothing;
}
Status::InTouch => {
self.modifiers |= latest_modifiers;
}
}
let mut delta = match unit {
MouseWheelUnit::Point => delta,
MouseWheelUnit::Line => options.line_scroll_speed * delta,
MouseWheelUnit::Page => viewport_rect.height() * delta,
};
let is_horizontal = self
.modifiers
.matches_any(options.horizontal_scroll_modifier);
let is_vertical = self.modifiers.matches_any(options.vertical_scroll_modifier);
if is_horizontal && !is_vertical {
delta = vec2(delta.x + delta.y, 0.0);
}
if !is_horizontal && is_vertical {
delta = vec2(0.0, delta.x + delta.y);
}
let is_smooth = self.status == Status::InTouch
|| match unit {
MouseWheelUnit::Point => delta.length() < 8.0, MouseWheelUnit::Line | MouseWheelUnit::Page => false,
};
if is_smooth {
self.smooth_wheel_delta += delta;
} else {
self.unprocessed_wheel_delta += delta;
}
}
crate::TouchPhase::End | crate::TouchPhase::Cancel => {
self.status = Status::Static;
self.modifiers = Default::default();
self.unprocessed_wheel_delta = Default::default();
self.smooth_wheel_delta = Default::default();
}
}
}
pub fn after_events(&mut self, time: f64, dt: f32) {
let t = crate::emath::exponential_smooth_factor(0.90, 0.1, dt);
if self.unprocessed_wheel_delta != Vec2::ZERO {
for d in 0..2 {
if self.unprocessed_wheel_delta[d].abs() < 1.0 {
self.smooth_wheel_delta[d] += self.unprocessed_wheel_delta[d];
self.unprocessed_wheel_delta[d] = 0.0;
} else {
let applied = t * self.unprocessed_wheel_delta[d];
self.smooth_wheel_delta[d] += applied;
self.unprocessed_wheel_delta[d] -= applied;
}
}
}
if self.smooth_wheel_delta == Vec2::ZERO && self.is_scroll_action_over(time) {
self.end_scroll_action();
}
}
fn is_scroll_action_over(&self, time: f64) -> bool {
let timeout = if self.status == Status::InTouch && self.source == MouseWheelSource::Trackpad
{
RESTING_FINGER_TIMEOUT
} else {
SCROLL_ACTION_TIMEOUT
};
self.status != Status::Static && timeout < time - self.last_wheel_event
}
fn end_scroll_action(&mut self) {
self.status = Status::Static;
self.modifiers = Default::default();
}
pub fn is_scrolling(&self) -> bool {
self.status != Status::Static
}
pub fn ui(&self, ui: &mut crate::Ui) {
let Self {
status,
source,
modifiers,
last_wheel_event,
unprocessed_wheel_delta,
smooth_wheel_delta,
} = self;
let time = ui.input(|i| i.time);
crate::Grid::new("ScrollState")
.num_columns(2)
.show(ui, |ui| {
ui.label("status");
ui.monospace(format!("{status:?}"));
ui.end_row();
ui.label("source");
ui.monospace(format!("{source:?}"));
ui.end_row();
ui.label("modifiers");
ui.monospace(format!("{modifiers:?}"));
ui.end_row();
ui.label("last_wheel_event");
ui.monospace(format!("{:.1}s ago", time - *last_wheel_event));
ui.end_row();
ui.label("unprocessed_wheel_delta");
ui.monospace(unprocessed_wheel_delta.to_string());
ui.end_row();
ui.label("smooth_wheel_delta");
ui.monospace(smooth_wheel_delta.to_string());
ui.end_row();
});
}
}
#[cfg(test)]
mod tests {
use emath::{Vec2, vec2};
use crate::{
Context, Event, Modifiers, MouseWheelSource, MouseWheelUnit, RawInput, TouchPhase,
};
fn wheel(phase: TouchPhase, modifiers: Modifiers) -> Event {
wheel_from(phase, MouseWheelSource::Unknown, modifiers)
}
fn wheel_from(phase: TouchPhase, source: MouseWheelSource, modifiers: Modifiers) -> Event {
Event::MouseWheel {
unit: MouseWheelUnit::Point,
delta: vec2(0.0, -5.0),
phase,
source,
modifiers,
}
}
fn run_frame(ctx: &Context, time: f64, events: Vec<Event>) -> (Vec2, f32, bool) {
let input = RawInput {
time: Some(time),
events,
..Default::default()
};
let mut result = None;
let output = ctx.run_ui(input, |ui| {
if result.is_none() {
result =
Some(ui.input(|i| (i.smooth_scroll_delta(), i.zoom_delta(), i.is_scrolling())));
}
});
output.drop_without_applying_deltas();
result.unwrap_or_default()
}
#[test]
fn modifiers_should_not_stick_when_touch_phase_end_never_arrives() {
let ctx = Context::default();
let (scroll, zoom, _) = run_frame(
&ctx,
0.0,
vec![
wheel(TouchPhase::Start, Modifiers::COMMAND),
wheel(TouchPhase::Move, Modifiers::COMMAND),
],
);
assert_eq!(scroll, Vec2::ZERO, "ctrl+scroll should not scroll");
assert!(zoom != 1.0, "ctrl+scroll should zoom");
let (scroll, zoom, _) =
run_frame(&ctx, 1.0, vec![wheel(TouchPhase::Move, Modifiers::NONE)]);
assert_eq!(zoom, 1.0, "scrolling without ctrl should not zoom");
assert_eq!(
scroll,
vec2(0.0, -5.0),
"scrolling without ctrl should scroll"
);
}
#[test]
fn scroll_action_should_end_when_touch_phase_end_never_arrives() {
let ctx = Context::default();
let (_, _, is_scrolling) = run_frame(
&ctx,
0.0,
vec![
wheel(TouchPhase::Start, Modifiers::NONE),
wheel(TouchPhase::Move, Modifiers::NONE),
],
);
assert!(is_scrolling, "we just scrolled");
let (_, _, is_scrolling) = run_frame(&ctx, 1.0, vec![]);
assert!(
!is_scrolling,
"no scroll events for a second: the scroll action is over"
);
}
#[test]
fn modifiers_should_stick_until_touch_phase_end() {
let ctx = Context::default();
let (scroll, _, _) = run_frame(
&ctx,
0.0,
vec![
wheel(TouchPhase::Start, Modifiers::SHIFT),
wheel(TouchPhase::Move, Modifiers::SHIFT),
],
);
assert_eq!(
scroll,
vec2(-5.0, 0.0),
"shift+scroll should scroll horizontally"
);
let (scroll, _, is_scrolling) =
run_frame(&ctx, 0.05, vec![wheel(TouchPhase::Move, Modifiers::NONE)]);
assert_eq!(scroll, vec2(-5.0, 0.0), "should still scroll horizontally");
assert!(is_scrolling, "the scroll action is still going");
let (_, _, is_scrolling) =
run_frame(&ctx, 0.1, vec![wheel(TouchPhase::End, Modifiers::NONE)]);
assert!(!is_scrolling, "the scroll action ended");
let (scroll, _, _) = run_frame(
&ctx,
0.2,
vec![
wheel(TouchPhase::Start, Modifiers::NONE),
wheel(TouchPhase::Move, Modifiers::NONE),
],
);
assert_eq!(
scroll,
vec2(0.0, -5.0),
"a new scroll action without shift is vertical"
);
}
#[test]
fn resting_fingers_keep_the_scroll_action_alive() {
let ctx = Context::default();
let finger = MouseWheelSource::Trackpad;
run_frame(
&ctx,
0.0,
vec![
wheel_from(TouchPhase::Start, finger, Modifiers::NONE),
wheel_from(TouchPhase::Move, finger, Modifiers::NONE),
],
);
let (_, _, is_scrolling) = run_frame(&ctx, 0.5, vec![]);
assert!(is_scrolling, "fingers are resting on the trackpad");
let (_, _, is_scrolling) = run_frame(&ctx, 1.5, vec![]);
assert!(
!is_scrolling,
"but we give up eventually, in case the end got lost"
);
run_frame(&ctx, 2.0, vec![wheel(TouchPhase::Start, Modifiers::NONE)]);
let (_, _, is_scrolling) = run_frame(&ctx, 2.5, vec![]);
assert!(!is_scrolling);
}
}