use std::cell::Cell;
use std::time::{Duration, Instant};
use crate::base::Point;
use super::event::{MouseButton, MouseEvent, MouseKind};
pub const DEFAULT_CLICK_WINDOW: Duration = Duration::from_millis(400);
pub const DEFAULT_CLICK_TOLERANCE: i32 = 1;
#[derive(Copy, Clone, Debug)]
struct ChainState {
at: Instant,
pos: Point,
button: MouseButton,
count: u8,
}
#[derive(Debug)]
pub struct ClickChain {
window: Duration,
tolerance: i32,
state: Option<ChainState>,
}
impl Default for ClickChain {
fn default() -> Self {
ClickChain::new()
}
}
impl ClickChain {
pub fn new() -> ClickChain {
ClickChain {
window: DEFAULT_CLICK_WINDOW,
tolerance: DEFAULT_CLICK_TOLERANCE,
state: None,
}
}
pub fn window(mut self, window: Duration) -> ClickChain {
self.window = window;
self
}
pub fn tolerance(mut self, cells: i32) -> ClickChain {
self.tolerance = cells.max(0);
self
}
pub fn observe(&mut self, now: Instant, ev: &MouseEvent) -> u8 {
match ev.kind {
MouseKind::Down(button) => {
let chained = self.state.as_ref().is_some_and(|s| {
s.button == button
&& now.saturating_duration_since(s.at) <= self.window
&& chebyshev(ev.pos, s.pos) <= self.tolerance
});
let count = match (chained, &self.state) {
(true, Some(s)) => s.count.saturating_add(1),
_ => 1,
};
self.state = Some(ChainState {
at: now,
pos: ev.pos,
button,
count,
});
count
}
MouseKind::Drag(_)
| MouseKind::ScrollUp
| MouseKind::ScrollDown
| MouseKind::ScrollLeft
| MouseKind::ScrollRight => {
self.state = None;
0
}
MouseKind::Up(_) | MouseKind::Move => 0,
}
}
pub fn reset(&mut self) {
self.state = None;
}
}
fn chebyshev(a: Point, b: Point) -> i32 {
(a.x - b.x).abs().max((a.y - b.y).abs())
}
thread_local! {
static EVENT_TIME: Cell<Option<Instant>> = const { Cell::new(None) };
}
pub fn set_event_time(now: Option<Instant>) {
EVENT_TIME.with(|t| t.set(now));
}
pub fn event_time() -> Option<Instant> {
EVENT_TIME.with(|t| t.get())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::Mods;
fn press_at(x: i32, y: i32) -> MouseEvent {
MouseEvent {
pos: Point::new(x, y),
kind: MouseKind::Down(MouseButton::Left),
mods: Mods::NONE,
}
}
fn ev(kind: MouseKind, x: i32, y: i32) -> MouseEvent {
MouseEvent {
pos: Point::new(x, y),
kind,
mods: Mods::NONE,
}
}
#[test]
fn presses_chain_and_triple_counts_three() {
let mut chain = ClickChain::new();
let t0 = Instant::now();
assert_eq!(chain.observe(t0, &press_at(5, 5)), 1);
assert_eq!(
chain.observe(t0 + Duration::from_millis(100), &press_at(5, 5)),
2
);
assert_eq!(
chain.observe(t0 + Duration::from_millis(200), &press_at(5, 5)),
3,
"triple-click reads 3 — no artificial cap below saturation"
);
}
#[test]
fn window_boundary_is_inclusive_and_one_past_resets() {
let mut chain = ClickChain::new().window(Duration::from_millis(400));
let t0 = Instant::now();
assert_eq!(chain.observe(t0, &press_at(5, 5)), 1);
assert_eq!(
chain.observe(t0 + Duration::from_millis(400), &press_at(5, 5)),
2
);
assert_eq!(
chain.observe(t0 + Duration::from_millis(801), &press_at(5, 5)),
1
);
}
#[test]
fn tolerance_boundary_is_inclusive_and_one_past_resets() {
let mut chain = ClickChain::new().tolerance(1);
let t0 = Instant::now();
let t1 = t0 + Duration::from_millis(50);
let t2 = t0 + Duration::from_millis(100);
assert_eq!(chain.observe(t0, &press_at(5, 5)), 1);
assert_eq!(chain.observe(t1, &press_at(6, 6)), 2);
assert_eq!(chain.observe(t2, &press_at(8, 6)), 1);
let mut strict = ClickChain::new().tolerance(0);
assert_eq!(strict.observe(t0, &press_at(5, 5)), 1);
assert_eq!(strict.observe(t1, &press_at(6, 5)), 1);
assert_eq!(strict.observe(t2, &press_at(6, 5)), 2);
}
#[test]
fn different_button_starts_its_own_chain() {
let mut chain = ClickChain::new();
let t0 = Instant::now();
assert_eq!(chain.observe(t0, &press_at(5, 5)), 1);
let right = MouseEvent {
pos: Point::new(5, 5),
kind: MouseKind::Down(MouseButton::Right),
mods: Mods::NONE,
};
assert_eq!(
chain.observe(t0 + Duration::from_millis(50), &right),
1,
"a right press never continues a left chain"
);
assert_eq!(
chain.observe(t0 + Duration::from_millis(100), &press_at(5, 5)),
1
);
}
#[test]
fn wheel_and_drag_reset_up_and_move_do_not() {
let t0 = Instant::now();
let step = Duration::from_millis(30);
let mut chain = ClickChain::new();
assert_eq!(chain.observe(t0, &press_at(5, 5)), 1);
assert_eq!(
chain.observe(t0 + step, &ev(MouseKind::ScrollDown, 5, 5)),
0
);
assert_eq!(chain.observe(t0 + step * 2, &press_at(5, 5)), 1);
let mut chain = ClickChain::new();
assert_eq!(chain.observe(t0, &press_at(5, 5)), 1);
assert_eq!(
chain.observe(t0 + step, &ev(MouseKind::Drag(MouseButton::Left), 9, 5)),
0
);
assert_eq!(chain.observe(t0 + step * 2, &press_at(5, 5)), 1);
let mut chain = ClickChain::new();
assert_eq!(chain.observe(t0, &press_at(5, 5)), 1);
chain.observe(t0 + step, &ev(MouseKind::Up(MouseButton::Left), 5, 5));
chain.observe(t0 + step, &ev(MouseKind::Move, 5, 5));
assert_eq!(chain.observe(t0 + step * 2, &press_at(5, 5)), 2);
}
#[test]
fn mods_do_not_break_the_chain() {
let mut chain = ClickChain::new();
let t0 = Instant::now();
let shifted = MouseEvent {
pos: Point::new(5, 5),
kind: MouseKind::Down(MouseButton::Left),
mods: Mods::SHIFT,
};
assert_eq!(chain.observe(t0, &shifted), 1);
assert_eq!(
chain.observe(t0 + Duration::from_millis(50), &press_at(5, 5)),
2,
"chain identity is button+time+place, never modifiers"
);
}
#[test]
fn count_saturates_at_u8_max() {
let mut chain = ClickChain::new();
let t0 = Instant::now();
let mut last = 0;
for _ in 0..300 {
last = chain.observe(t0, &press_at(5, 5));
}
assert_eq!(last, u8::MAX, "saturating, never wrapping to 0/1");
}
#[test]
fn reset_isolates_the_next_press() {
let mut chain = ClickChain::new();
let t0 = Instant::now();
assert_eq!(chain.observe(t0, &press_at(5, 5)), 1);
chain.reset();
assert_eq!(
chain.observe(t0 + Duration::from_millis(10), &press_at(5, 5)),
1
);
}
#[test]
fn ambient_event_time_roundtrips_and_clears() {
let t = Instant::now();
set_event_time(Some(t));
assert_eq!(event_time(), Some(t));
set_event_time(None);
assert_eq!(event_time(), None);
}
}