Files
vectorcraft/crates/ui-egui/src/touch.rs
T
dotCooCoo cc19dc1db2 Touch: a two-finger tap switches between selection and drawing modes
In the Touch workspace, a two-finger tap on the canvas now switches
between the Selection tool and the drawing tool chosen last, as the
help page describes. It no longer undoes there; outside Touch a
two-finger tap still undoes, and a three-finger tap still redoes in
every workspace.

- New UI command tool.toggleTouchMode (Switch Selection and Drawing
  Modes, {} -> {tool}). From the Selection, Direct Selection, Group
  Selection, Magic Wand, Lasso or Perspective Selection tool it chooses
  the drawing tool chosen last in the window, or the Pencil tool when
  none has been chosen; from any other tool it chooses the Selection
  tool. The Hand, Rotate View, Zoom and Print Tiling tools never become
  the drawing tool. It adds no undo step.
- These tool lists and Pencil as the first drawing tool are
  VectorCraft's own rules.
- The label is in every catalog. Docs: docs/mcp.md, the touch row of
  docs/hardware-parity.md and docs/target-app-parity.md.
2026-11-12 21:21:52 -08:00

244 lines
9.8 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. In the Touch workspace a tap with two fingers
//! switches between selection and drawing modes instead ([`TOGGLE_MODE`]).
//!
//! 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.
//!
//! Where the platform tells a pen from a finger (the web shell reads Pointer Events: an iPad with
//! an Apple Pencil), it says which drives the pointer ([`set_source`]). Once a pen has been seen in this session,
//! fingers only navigate: pinch, two-finger pan and the taps; a finger or a resting palm never
//! presses a tool on the canvas.
use std::cell::Cell;
use egui::{Pos2, TouchId, TouchPhase};
/// What drives the pointer.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Source {
/// A mouse or trackpad, or a platform that can't tell (the desktop apps).
#[default]
Mouse,
/// A pen (Apple Pencil, a stylus).
Pen,
/// A finger on a touch screen.
Finger,
}
thread_local! {
static SOURCE: Cell<Source> = const { Cell::new(Source::Mouse) };
static PEN_SEEN: Cell<bool> = const { Cell::new(false) };
}
/// The pointer's presses now come from `source`; a pen makes fingers navigate only from then on.
pub fn set_source(source: Source) {
SOURCE.with(|s| s.set(source));
if source == Source::Pen {
PEN_SEEN.with(|p| p.set(true));
}
}
/// Set the session-only pen policy (reload starts with finger drawing enabled).
pub fn set_pen_seen(seen: bool) {
PEN_SEEN.with(|p| p.set(seen));
}
/// A pen has been seen.
pub fn pen_seen() -> bool {
PEN_SEEN.with(Cell::get)
}
/// The press in progress is a finger's while a pen has been seen: it navigates, it doesn't draw.
pub(crate) fn finger_navigates_only() -> bool {
SOURCE.with(Cell::get) == Source::Finger && pen_seen()
}
/// 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 finger landed while another is down: the gesture isn't the first finger's press, nor a
/// lifted finger's press as it lands again.
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));
}
self.most = self.most.max(self.down.len());
(self.down.len() >= 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. In the Touch workspace (`touch_workspace`)
/// two switch between the Selection tool and the last drawing tool ([`TOGGLE_MODE`]).
pub(crate) fn tap_command(fingers: usize, touch_workspace: bool) -> Option<&'static str> {
match fingers {
2 if touch_workspace => Some(TOGGLE_MODE),
2 => Some("edit.undo"),
3 => Some("edit.redo"),
_ => None,
}
}
/// Switch between selection and drawing modes: the Selection tool, and back to the drawing tool
/// chosen last.
pub const TOGGLE_MODE: &str = "tool.toggleTouchMode";
/// The drawing tool a switch out of selection mode chooses when none has been chosen in this window.
pub const FIRST_DRAWING_TOOL: &str = "pencil";
/// A tool of selection mode: one that selects objects or parts of them.
fn selects(id: &str) -> bool {
vectorcraft_tools::catalog::is_selection_tool(id) || matches!(id, "magicWand" | "lasso" | "perspectiveSelection")
}
/// A tool of drawing mode: any tool but those of selection mode and those that only move, turn or
/// zoom the view.
pub(crate) fn is_drawing_tool(id: &str) -> bool {
!selects(id) && !matches!(id, "hand" | "rotateView" | "zoom" | "printTiling")
}
/// [`TOGGLE_MODE`]: from a tool of selection mode, choose the drawing tool chosen last (none yet:
/// [`FIRST_DRAWING_TOOL`]); from any other tool, keep it as the drawing tool and choose the
/// Selection tool. → `{tool}`, the tool now active.
pub(crate) fn toggle_mode(app: &mut crate::VectorcraftApp) -> serde_json::Value {
let now = app.session.tool_id().to_string();
let next = if selects(&now) {
app.ui.touch_drawing_tool.clone().unwrap_or_else(|| FIRST_DRAWING_TOOL.to_string())
} else {
if is_drawing_tool(&now) {
app.ui.touch_drawing_tool = Some(now);
}
"selection".to_string()
};
app.select_tool(&next);
serde_json::json!({"tool": app.session.tool_id()})
}
#[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, false), tap_command(3, false), tap_command(1, false)), (Some("edit.undo"), Some("edit.redo"), None));
assert_eq!((tap_command(2, true), tap_command(3, true), tap_command(1, true)), (Some(TOGGLE_MODE), Some("edit.redo"), None));
}
#[test]
fn drawing_tools_are_neither_selection_nor_view_tools() {
for id in ["pencil", "pen", "rectangle", "paintbrush", "type", "eraser", "shapeBuilder"] {
assert!(is_drawing_tool(id), "{id}");
}
let selecting = ["selection", "directSelection", "groupSelection", "magicWand", "lasso", "perspectiveSelection"];
for id in selecting.into_iter().chain(["hand", "rotateView", "zoom", "printTiling"]) {
assert!(!is_drawing_tool(id), "{id}");
}
assert!(vectorcraft_tools::tool_info(FIRST_DRAWING_TOOL).is_some_and(|t| is_drawing_tool(t.id)));
}
#[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");
}
#[test]
fn fingers_navigate_only_once_a_pen_was_seen() {
set_pen_seen(false);
set_source(Source::Finger);
assert!(!finger_navigates_only(), "no pen yet: a finger draws");
set_source(Source::Pen);
assert!(pen_seen() && !finger_navigates_only());
set_source(Source::Finger);
assert!(finger_navigates_only());
set_source(Source::Mouse);
assert!(!finger_navigates_only(), "a mouse still draws");
set_pen_seen(false);
}
}