Files
vectorcraft/crates/ui-egui/src/touch.rs
T
bflatasticandClaude Opus 5.5 aa125bfff1 Fix #585: a two-finger tap undoes, a three-finger tap redoes
Preferences › Devices › Enable Touch Gestures (on by default) was stored
but nothing read it: no touch gesture was implemented.

- `touch::Taps` follows the fingers egui reports (winit reads them from
  the system: WM_POINTER on Windows): fingers down together and lifted
  within half a second, none moved more than 16 points, is a tap. Two
  fingers undo, three redo, with the preference on.
- egui makes the first finger the pointer too, so it has pressed on the
  canvas by the time a second lands: that press is dropped then (its tool
  interaction rolled back), and the rest of the gesture presses nothing.
  The tap so undoes what came before it, not the first finger's mark.
- A pen is one contact, so pen strokes and taps are unaffected; pinch zoom
  is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:39:47 +07:00

132 lines
5.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Touch gestures on the canvas (Preferences › Devices › Enable Touch Gestures, #585): a tap with
//! two fingers undoes, one with three redoes.
//!
//! egui hands every finger's contact over as [`egui::Event::Touch`] (winit reads them from the
//! system: `WM_POINTER` on Windows), and makes the first finger the mouse pointer too. So the first
//! finger has pressed on the canvas by the time a second lands: that press is dropped then
//! ([`Taps::feed`] says [`Touch::Fingers`]), and the tap undoes what came before it.
use egui::{Pos2, TouchId, TouchPhase};
/// How long the fingers of a tap may stay down, in seconds.
const TAP_TIME: f64 = 0.5;
/// How far (points) a finger of a tap may move: further, it is a pinch or a pan.
const TAP_SLOP: f32 = 16.0;
/// What a touch event made of the gesture.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Touch {
/// A second finger landed: the gesture isn't the first finger's press.
Fingers,
/// All fingers lifted from a tap of this many fingers (two or more).
Tap(usize),
}
/// The fingers down and how the gesture they make has gone so far.
#[derive(Clone, Debug, Default)]
pub(crate) struct Taps {
/// Each finger down and where it landed.
down: Vec<(TouchId, Pos2)>,
/// The most fingers down at once in this gesture.
most: usize,
/// When its first finger landed.
start: f64,
/// A finger moved too far or stayed too long: not a tap.
spoilt: bool,
}
impl Taps {
/// Follow a finger's `phase` at `pos` (touch `id`) at time `now` (seconds).
pub(crate) fn feed(&mut self, id: TouchId, phase: TouchPhase, pos: Pos2, now: f64) -> Option<Touch> {
match phase {
TouchPhase::Start => {
if self.down.is_empty() {
*self = Self { start: now, ..Self::default() };
}
if !self.down.iter().any(|(d, _)| *d == id) {
self.down.push((id, pos));
}
let before = self.most;
self.most = self.most.max(self.down.len());
(before < 2 && self.most >= 2).then_some(Touch::Fingers)
}
TouchPhase::Move => {
if self.down.iter().any(|(d, at)| *d == id && at.distance(pos) > TAP_SLOP) {
self.spoilt = true;
}
None
}
TouchPhase::End | TouchPhase::Cancel => {
let known = self.down.len();
self.down.retain(|(d, _)| *d != id);
if self.down.len() == known {
return None;
}
self.spoilt |= phase == TouchPhase::Cancel || now - self.start > TAP_TIME;
(self.down.is_empty() && !self.spoilt && self.most >= 2).then_some(Touch::Tap(self.most))
}
}
}
/// A finger is down.
pub(crate) fn is_down(&self) -> bool {
!self.down.is_empty()
}
}
/// The command a tap of `fingers` runs: two undo, three redo.
pub(crate) fn tap_command(fingers: usize) -> Option<&'static str> {
match fingers {
2 => Some("edit.undo"),
3 => Some("edit.redo"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn id(n: u64) -> TouchId {
TouchId(n)
}
#[test]
fn two_and_three_finger_taps() {
let mut t = Taps::default();
let p = Pos2::new(100.0, 100.0);
assert_eq!(t.feed(id(1), TouchPhase::Start, p, 0.0), None);
assert_eq!(t.feed(id(2), TouchPhase::Start, p + egui::vec2(60.0, 0.0), 0.05), Some(Touch::Fingers));
assert!(t.is_down());
assert_eq!(t.feed(id(1), TouchPhase::Move, p + egui::vec2(3.0, 2.0), 0.1), None);
assert_eq!(t.feed(id(1), TouchPhase::End, p, 0.15), None, "one finger still down");
assert_eq!(t.feed(id(2), TouchPhase::End, p, 0.2), Some(Touch::Tap(2)));
assert!(!t.is_down());
// Three fingers.
for (n, at) in [(4, 0.0), (5, 0.02), (6, 0.04)] {
t.feed(id(n), TouchPhase::Start, p, 1.0 + at);
}
let ends: Vec<_> = [4, 5, 6].into_iter().map(|n| t.feed(id(n), TouchPhase::End, p, 1.2)).collect();
assert_eq!(ends, [None, None, Some(Touch::Tap(3))]);
assert_eq!((tap_command(2), tap_command(3), tap_command(1)), (Some("edit.undo"), Some("edit.redo"), None));
}
#[test]
fn a_pinch_a_long_press_a_cancel_or_one_finger_is_no_tap() {
let p = Pos2::new(100.0, 100.0);
let gesture = |moved: f32, held: f64, last: TouchPhase, fingers: u64| {
let mut t = Taps::default();
for n in 0..fingers {
t.feed(id(n), TouchPhase::Start, p, 0.0);
}
t.feed(id(0), TouchPhase::Move, p + egui::vec2(moved, 0.0), held / 2.0);
(0..fingers).filter_map(|n| t.feed(id(n), if n + 1 == fingers { last } else { TouchPhase::End }, p, held)).last()
};
assert_eq!(gesture(0.0, 0.2, TouchPhase::End, 2), Some(Touch::Tap(2)));
assert_eq!(gesture(40.0, 0.2, TouchPhase::End, 2), None, "a pinch");
assert_eq!(gesture(0.0, 0.9, TouchPhase::End, 2), None, "held too long");
assert_eq!(gesture(0.0, 0.2, TouchPhase::Cancel, 2), None, "cancelled");
assert_eq!(gesture(0.0, 0.2, TouchPhase::End, 1), None, "a pen or one finger");
}
}