diff --git a/ROADMAP.md b/ROADMAP.md index b1829c2e..723f26b1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ade4c38d..cac25c34 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 = 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_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] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4e301b85..7d5d9da2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/touch.rs b/crates/ui-egui/src/touch.rs new file mode 100644 index 00000000..1f5e9c99 --- /dev/null +++ b/crates/ui-egui/src/touch.rs @@ -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 { + 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"); + } +}