Merge pull request: land #621 (fix #585)

Land #621 (fix #585: two-finger tap undoes, three-finger tap redoes)
This commit is contained in:
BFlat 2026-10-09 05:46:59 +07:00 committed by GitHub
commit 921e60f4fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 216 additions and 3 deletions

File diff suppressed because one or more lines are too long

View File

@ -79,6 +79,8 @@ enum Drag {
/// A Selection tool move dragged off the canvas: the panels get the art
/// ([`widgets::PanelDrag::Art`]).
Art,
/// Fingers making a touch gesture ([`crate::touch`]): the first one's press does nothing.
Gesture,
}
fn drag_id() -> egui::Id {
@ -477,6 +479,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
let at = hover.or_else(|| pointer.interact_pos().filter(|p| rect.contains(*p)));
app.hover_doc = hover.map(|p| xf.to_doc(p));
let view = app.view_info();
touch_gestures(app, ui, rect, view);
let drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id()));
// A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows
// without moving the mouse (Alt switches the Shape Builder to erase mode).
@ -568,7 +571,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
vm.rotation = vectorcraft_geom::normalize_deg(deg);
}
}
Drag::ZoomBox { .. } | Drag::Art => {}
Drag::ZoomBox { .. } | Drag::Art | Drag::Gesture => {}
Drag::Tool if drag_art_out(app, ui, resp, p, view) => {
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art));
}
@ -612,7 +615,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
dispatch(app, &ev, view);
}
Drag::Art => {}
Drag::Art | Drag::Gesture => {}
Drag::Pan { .. } | Drag::RotateView { .. } => {}
}
}
@ -691,6 +694,50 @@ fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn
(zoom, scroll)
}
/// Enable Touch Gestures: this frame's finger contacts on the canvas ([`crate::touch`]). A second
/// finger drops the press the first one began (its tool interaction rolled back; the rest of the
/// gesture presses nothing), and a tap of two fingers undoes, one of three redoes.
fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vectorcraft_engine::ViewInfo) {
if !app.session.prefs.touch_gestures {
return;
}
let (touches, now): (Vec<_>, f64) = ui.input(|i| {
let t = i.events.iter().filter_map(|e| match e {
egui::Event::Touch { id, phase, pos, .. } => Some((*id, *phase, *pos)),
_ => None,
});
(t.collect(), i.time)
});
if touches.is_empty() {
return;
}
let key = egui::Id::new("canvas-touch");
let mut taps: crate::touch::Taps = ui.data(|d| d.get_temp(key)).unwrap_or_default();
let mut tap = None;
for (id, phase, pos) in touches {
// A gesture begins on the canvas.
if phase == egui::TouchPhase::Start && !taps.is_down() && !rect.contains(pos) {
continue;
}
match taps.feed(id, phase, pos, now) {
Some(crate::touch::Touch::Fingers) => {
if ui.data(|d| d.get_temp::<Drag>(drag_id())) == Some(Drag::Tool) {
// Nothing it began is kept; the tool's mouse-up returns it to rest.
let _ = app.session.cancel_interaction();
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);
}
ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture));
}
Some(crate::touch::Touch::Tap(n)) => tap = crate::touch::tap_command(n),
None => {}
}
}
ui.data_mut(|d| d.insert_temp(key, taps));
if let Some(cmd) = tap {
crate::menus::invoke(app, cmd, serde_json::json!({}));
}
}
/// A Selection tool move dragged off the canvas (to `p`, over a panel) turns into a panel drag of
/// the selected art ([`widgets::PanelDrag::Art`]): the move is dropped, so the art stays where it
/// was, and the panel it is released on takes it (the Graphic Styles panel makes a style of it).
@ -1868,6 +1915,40 @@ mod tests {
out.textures_delta.clear();
}
/// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the
/// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing.
#[test]
fn two_finger_tap_undoes_and_three_finger_tap_redoes() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap();
app.select_tool("rectangle");
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap().len();
let c = app.canvas_rect.unwrap().center();
let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None };
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
// `fingers` fingers tap at the centre, the first also pressing as the pointer and moving a
// little (as a rectangle drag would begin).
let tap = |app: &mut VectorcraftApp, fingers: u64| {
let at = |n: u64| c + vec2(n as f32 * 50.0, 0.0);
frame(app, &ctx, vec![touch(0, egui::TouchPhase::Start, at(0)), egui::Event::PointerMoved(at(0)), button(at(0), true)]);
frame(app, &ctx, vec![touch(0, egui::TouchPhase::Move, at(0) + vec2(6.0, 6.0)), egui::Event::PointerMoved(at(0) + vec2(6.0, 6.0))]);
frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::Start, at(n))).collect());
frame(app, &ctx, vec![touch(0, egui::TouchPhase::End, at(0)), button(at(0) + vec2(6.0, 6.0), false), egui::Event::PointerGone]);
frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::End, at(n))).collect());
frame(app, &ctx, vec![]);
};
tap(&mut app, 2);
assert_eq!(count(&app), 0, "the rectangle undone, none drawn");
tap(&mut app, 3);
assert_eq!(count(&app), 1, "redone");
app.session.prefs.touch_gestures = false;
tap(&mut app, 2);
assert_eq!(count(&app), 2, "off: the first finger draws as the pointer, and nothing is undone");
}
/// The Artboard tool's label holds the artboard's name (never translated); the tools' own
/// labels are interface text.
#[test]

View File

@ -51,6 +51,7 @@ pub mod sysclip;
pub mod theme;
pub mod titlebar;
pub mod toolbar;
mod touch;
mod ui_fonts;
pub mod unsaved;
pub mod widgets;

131
crates/ui-egui/src/touch.rs Normal file
View File

@ -0,0 +1,131 @@
//! 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");
}
}