From 51673cd09818f9c70a7bfb26ab55ee0af8d3c9b8 Mon Sep 17 00:00:00 2001 From: gniax Date: Tue, 13 Oct 2026 14:07:02 +0200 Subject: [PATCH 1/4] feat(web): Apple Pencil input on iPad with palm rejection Read pen and finger Pointer Events in the web shell (capture phase) instead of eframe's touch handlers, which can't tell a pen from a finger and see every touch twice. The pen drives the pointer with its pressure and hovers; fingers are touch contacts for pinch, two-finger pan and undo/redo taps. Once a pen has been seen (remembered in localStorage), a finger or a resting palm never presses a tool on the canvas, and a pen landing cancels fingers already down. A long press drops the tool press and opens the context menu. The page no longer scrolls, zooms or selects under touch, and the canvas stays inside the safe areas of a full-screen app. --- apps/vectorcraft-web/Cargo.toml | 2 +- apps/vectorcraft-web/index.html | 19 ++- apps/vectorcraft-web/src/main.rs | 6 +- apps/vectorcraft-web/src/pencil.rs | 230 +++++++++++++++++++++++++++++ apps/vectorcraft-web/src/web.rs | 10 ++ crates/ui-egui/src/canvas.rs | 35 ++++- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/touch.rs | 61 ++++++++ 8 files changed, 353 insertions(+), 12 deletions(-) create mode 100644 apps/vectorcraft-web/src/pencil.rs diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index 811b937a2..2e9218dc3 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -28,4 +28,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] } diff --git a/apps/vectorcraft-web/index.html b/apps/vectorcraft-web/index.html index 05490806f..42eaaf74e 100644 --- a/apps/vectorcraft-web/index.html +++ b/apps/vectorcraft-web/index.html @@ -2,12 +2,25 @@ - + + + + + + VectorCraft diff --git a/apps/vectorcraft-web/src/main.rs b/apps/vectorcraft-web/src/main.rs index fdf419915..4ad16335c 100644 --- a/apps/vectorcraft-web/src/main.rs +++ b/apps/vectorcraft-web/src/main.rs @@ -9,7 +9,9 @@ //! - File → Open uses the browser file picker; bytes arrive asynchronously through //! `Services::inbox`; //! - Save / Export trigger a browser download; -//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox. +//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox; +//! - pen and finger input is read from Pointer Events by `pencil` (Apple Pencil pressure and +//! hover, palm rejection, fingers navigate once a pen has been seen). //! //! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any //! language code) stands in for the browser's languages when the interface language is Automatic. @@ -19,6 +21,8 @@ mod fonts; #[cfg(target_arch = "wasm32")] mod locks; #[cfg(target_arch = "wasm32")] +mod pencil; +#[cfg(target_arch = "wasm32")] mod web; #[cfg(target_arch = "wasm32")] diff --git a/apps/vectorcraft-web/src/pencil.rs b/apps/vectorcraft-web/src/pencil.rs new file mode 100644 index 000000000..4416196cd --- /dev/null +++ b/apps/vectorcraft-web/src/pencil.rs @@ -0,0 +1,230 @@ +//! Pen and touch input (an iPad with an Apple Pencil): the shell reads Pointer Events itself, in +//! the capture phase, before eframe's own touch and pointer handlers, which can't tell a pen from +//! a finger and see each touch twice (as a pointer and as a touch event). +//! +//! - A pen drives egui's pointer and is a touch contact with the pen's pressure as its force (the +//! canvas reads it), so holding it still opens the context menu (egui's long touch). It hovers +//! where the pen can (Apple Pencil hover). +//! - Fingers are touch contacts: two pinch and pan the canvas, and tap to undo or redo. The first +//! finger also drives the pointer, so it taps the panels and menus; on the canvas it navigates +//! only once a pen has been seen ([`touch::set_source`]), which the page remembers. +//! - Palm rejection: while a pen is down, fingers are ignored; a pen landing cancels the fingers +//! already down (a palm resting first). +//! +//! Mouse and trackpad events (a Magic Keyboard) are left to eframe. + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::{Event, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase}; +use vectorcraft_ui_egui::touch::{self, Source}; +use wasm_bindgen::JsCast as _; + +/// Where the page remembers that a pen has been seen (`localStorage`). +const PEN_SEEN_KEY: &str = "vectorcraft-pen-seen"; +/// The pen's touch device (fingers use another, so egui doesn't pinch a pen with a finger). +const PEN_DEVICE: TouchDeviceId = TouchDeviceId(1); +const FINGER_DEVICE: TouchDeviceId = TouchDeviceId(2); + +/// The pen and finger contacts followed, and the egui events they made since the last frame. +#[derive(Default)] +pub struct Pointers { + events: Vec, + /// The pen pressing, by pointer id. + pen: Option, + /// The fingers down, by pointer id. + fingers: Vec, + /// The contact driving egui's pointer. + primary: Option, +} + +pub type SharedPointers = Rc>; + +impl Pointers { + /// The events since the last frame, for the frame's raw input. + pub fn take(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + fn touch(&mut self, device: TouchDeviceId, id: i32, phase: TouchPhase, pos: Pos2, force: Option) { + self.events.push(Event::Touch { device_id: device, id: TouchId::from(id as u64), phase, pos, force }); + } + + fn press(&mut self, id: i32, pos: Pos2) { + self.primary = Some(id); + self.events.push(Event::PointerMoved(pos)); + self.events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::default() }); + } + + fn release(&mut self, pos: Pos2) { + self.primary = None; + self.events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::default() }); + } + + /// Cancel every finger down (a palm under a pen landing). + fn cancel_fingers(&mut self, pos: Pos2) { + for id in std::mem::take(&mut self.fingers) { + self.touch(FINGER_DEVICE, id, TouchPhase::Cancel, pos, None); + if self.primary == Some(id) { + self.release(pos); + self.events.push(Event::PointerGone); + } + } + } + + /// Follow one pointer event; `true` if it was the pen's or a finger's (taken from eframe). + fn feed(&mut self, kind: &str, e: &web_sys::PointerEvent, pos: Pos2, on_canvas: bool) -> bool { + let id = e.pointer_id(); + match e.pointer_type().as_str() { + "pen" => { + let force = Some(e.pressure().clamp(0.0, 1.0)); + match kind { + "pointerdown" => { + if !on_canvas && self.pen.is_none() { + return false; + } + set_pen_seen(); + touch::set_source(Source::Pen); + self.cancel_fingers(pos); + self.pen = Some(id); + self.touch(PEN_DEVICE, id, TouchPhase::Start, pos, force); + self.press(id, pos); + } + "pointermove" if self.pen == Some(id) => { + self.touch(PEN_DEVICE, id, TouchPhase::Move, pos, force); + self.events.push(Event::PointerMoved(pos)); + } + // Hovering (Apple Pencil hover): the pointer follows without pressing. + "pointermove" => { + if self.primary.is_some() { + return true; + } + if !on_canvas { + return false; + } + self.events.push(Event::PointerMoved(pos)); + } + "pointerup" | "pointercancel" if self.pen == Some(id) => { + let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel }; + self.pen = None; + self.touch(PEN_DEVICE, id, phase, pos, None); + self.release(pos); + } + // A pen leaving the hover range, or an event of a press we didn't take. + _ => { + if kind == "pointerleave" && self.primary.is_none() && on_canvas { + self.events.push(Event::PointerGone); + } + return self.pen.is_some(); + } + } + true + } + "touch" => { + match kind { + "pointerdown" => { + // Palm rejection: nothing presses while the pen does. + if self.pen.is_some() { + return true; + } + if !on_canvas && self.fingers.is_empty() { + return false; + } + self.fingers.push(id); + self.touch(FINGER_DEVICE, id, TouchPhase::Start, pos, None); + if self.primary.is_none() && self.fingers.len() == 1 { + touch::set_source(Source::Finger); + self.press(id, pos); + } + } + "pointermove" if self.fingers.contains(&id) => { + self.touch(FINGER_DEVICE, id, TouchPhase::Move, pos, None); + if self.primary == Some(id) { + self.events.push(Event::PointerMoved(pos)); + } + } + "pointerup" | "pointercancel" if self.fingers.contains(&id) => { + let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel }; + self.fingers.retain(|f| *f != id); + self.touch(FINGER_DEVICE, id, phase, pos, None); + if self.primary == Some(id) { + self.release(pos); + self.events.push(Event::PointerGone); + } + } + // A rejected palm's later events, or one we never took. + _ => return self.pen.is_some() || !self.fingers.is_empty() || on_canvas, + } + true + } + _ => false, + } + } +} + +/// Whether the page remembers a pen, for [`touch::set_pen_seen`] before the first frame. +pub fn restore_pen_seen() { + let seen = local_storage().and_then(|s| s.get_item(PEN_SEEN_KEY).ok().flatten()).is_some_and(|v| v == "1"); + touch::set_pen_seen(seen); +} + +fn set_pen_seen() { + if !touch::pen_seen() + && let Some(s) = local_storage() + { + // Private browsing can refuse it: fingers then draw again on the next visit, no more. + let _ = s.set_item(PEN_SEEN_KEY, "1"); + } +} + +fn local_storage() -> Option { + web_sys::window()?.local_storage().ok().flatten() +} + +/// Take pen and finger Pointer Events on the page from eframe into `pointers` (and the touch +/// events that follow them), for the runner's frames. Mouse events stay eframe's. +pub fn install(runner: &eframe::WebRunner, canvas: &web_sys::HtmlCanvasElement, pointers: &SharedPointers) -> Result<(), wasm_bindgen::JsValue> { + let window: web_sys::EventTarget = web_sys::window().ok_or("no window")?.into(); + let options = web_sys::AddEventListenerOptions::new(); + options.set_capture(true); + options.set_passive(false); + for kind in ["pointerdown", "pointermove", "pointerup", "pointercancel", "pointerleave"] { + let (pointers, canvas) = (pointers.clone(), canvas.clone()); + runner.add_event_listener_ex(&window, kind, &options, move |e: web_sys::PointerEvent, runner, _| { + let on_canvas = e.target().is_some_and(|t| &t == canvas.unchecked_ref::()); + let rect = canvas.get_bounding_client_rect(); + let z = runner.egui_ctx().zoom_factor(); + let pos = egui::pos2((e.client_x() as f32 - rect.left() as f32) / z, (e.client_y() as f32 - rect.top() as f32) / z); + if !pointers.borrow_mut().feed(kind, &e, pos, on_canvas) { + return; + } + e.stop_immediate_propagation(); + // No compatibility mouse events, no text selection or magnifier. + e.prevent_default(); + if kind == "pointerup" { + // Keys typed next (a Magic Keyboard) go to the app. + canvas.focus().ok(); + // Safari lets a page open the share sheet, a download or the on-screen keyboard + // only during the user's gesture: handle the release now. + runner.logic(); + } + runner.egui_ctx().request_repaint(); + })?; + } + // The touch events behind those pointer events: eframe would see each touch a second time, + // and Safari would scroll, zoom or select. + for kind in ["touchstart", "touchmove", "touchend", "touchcancel"] { + let (pointers, canvas) = (pointers.clone(), canvas.clone()); + runner.add_event_listener_ex(&window, kind, &options, move |e: web_sys::TouchEvent, _runner, _| { + let on_canvas = e.target().is_some_and(|t| &t == canvas.unchecked_ref::()); + let p = pointers.borrow(); + if on_canvas || p.pen.is_some() || !p.fingers.is_empty() { + e.stop_immediate_propagation(); + if e.cancelable() { + e.prevent_default(); + } + } + })?; + } + Ok(()) +} diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index 3be50e5fb..e4753c28c 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -17,6 +17,7 @@ use vectorcraft_ui_egui::{Services, VectorcraftApp}; use wasm_bindgen::JsCast as _; use crate::locks::WebLocks; +use crate::pencil::SharedPointers; type Inbox = Arc)>>>; @@ -47,6 +48,8 @@ struct Host { place_inbox: PlaceInbox, drag: DragPos, pasted: Rc>, + /// Pen and finger input read from the page's Pointer Events (`crate::pencil`). + pointers: SharedPointers, /// The page's Web Locks for Data Recovery, started once before the first frame: a graphics /// restart keeps them, so the tab stays the owner of its recovery area. locks: Option, @@ -61,6 +64,7 @@ pub fn start() { return; }; detect_language(); + crate::pencil::restore_pen_seen(); wasm_bindgen_futures::spawn_local(async move { // Before the first frame, which looks for copies a crash left behind. let locks = WebLocks::start(RECOVERY_PREFIX).await; @@ -73,6 +77,7 @@ pub fn start() { place_inbox: Arc::default(), drag: DragPos::default(), pasted: Rc::default(), + pointers: SharedPointers::default(), locks, restarts: 0, }; @@ -96,6 +101,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option { + if let Err(e) = crate::pencil::install(&runner, &pen_canvas, &pointers) { + log::error!("vectorcraft-web: no pen input: {}", js_err(e)); + } if let Some(el) = element(LOADING_ID) { el.remove(); } @@ -381,6 +390,7 @@ impl eframe::App for WebShell { } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + raw.events.extend(self.host.pointers.borrow_mut().take()); if let Some(app) = &mut self.app { app.raw_input_hook(raw); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7aa8ab790..fc1e11be2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -569,6 +569,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: app.hover_doc = hover.map(|p| xf.to_doc(p)); let view = app.view_info(); touch_gestures(app, ui, rect, view); + // Pressed and held still, a pen or a finger opens the context menu (egui's long touch): the + // tool's press is dropped, as a right-click presses nothing. + if resp.long_touched() { + drop_press(app, ui, 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). @@ -610,7 +615,10 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: { ui.ctx().memory_mut(|mem| mem.stop_text_input()); app.ui.flyout = None; - let d = if zoom_mode { + let d = if crate::touch::finger_navigates_only() { + // A finger (or a palm) once a pen has been seen: it navigates, it doesn't draw. + Drag::Gesture + } else if zoom_mode { if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } } } else if pan_mode { Drag::Pan { start: p, center: v.center, middle: false } @@ -890,13 +898,13 @@ fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vec if phase == egui::TouchPhase::Start && !taps.is_down() && !rect.contains(pos) { continue; } + // A cancelled contact (a palm the shell drops when a pen lands) keeps nothing it began. + if phase == egui::TouchPhase::Cancel { + drop_press(app, ui, view); + } 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); - } + drop_press(app, ui, view); ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture)); } Some(crate::touch::Touch::Tap(n)) => tap = crate::touch::tap_command(n), @@ -909,6 +917,21 @@ fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vec } } +/// Drop the tool press in progress, if any: nothing it began is kept, and the rest of the press +/// does nothing ([`Drag::Gesture`]). +fn drop_press(app: &mut VectorcraftApp, ui: &Ui, view: vectorcraft_engine::ViewInfo) { + match ui.data(|d| d.get_temp::(drag_id())) { + Some(Drag::Tool) => { + // 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); + } + Some(_) => {} + None => return, + } + ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture)); +} + /// 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). diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 55c158ca8..57fe1cba0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -55,7 +55,7 @@ pub mod theme; pub mod thumb_worker; pub mod titlebar; pub mod toolbar; -mod touch; +pub 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 index 1f5e9c999..b6223907c 100644 --- a/crates/ui-egui/src/touch.rs +++ b/crates/ui-egui/src/touch.rs @@ -5,9 +5,56 @@ //! 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, +//! 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 = const { Cell::new(Source::Mouse) }; + static PEN_SEEN: Cell = 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)); + } +} + +/// Whether a pen has been seen (the shell restores it across visits), so fingers navigate only. +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. @@ -128,4 +175,18 @@ mod tests { 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); + } } From 9fc7fd70384586dca6901ed7aab4c657c4f8d82e Mon Sep 17 00:00:00 2001 From: gniax Date: Tue, 13 Oct 2026 14:30:35 +0200 Subject: [PATCH 2/4] feat(canvas): pen strokes keep every position reported between frames A pen reports far more often than frames come (an Apple Pencil up to 240 times a second): the canvas now hands the tool each move of the frame with its pressure instead of the last one only, and the web shell feeds Safari's coalesced pen events. Mouse input is unchanged. --- apps/vectorcraft-web/src/pencil.rs | 24 +++++++++++++++++++-- crates/ui-egui/src/canvas.rs | 34 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/apps/vectorcraft-web/src/pencil.rs b/apps/vectorcraft-web/src/pencil.rs index 4416196cd..8651bbf60 100644 --- a/apps/vectorcraft-web/src/pencil.rs +++ b/apps/vectorcraft-web/src/pencil.rs @@ -73,7 +73,7 @@ impl Pointers { } /// Follow one pointer event; `true` if it was the pen's or a finger's (taken from eframe). - fn feed(&mut self, kind: &str, e: &web_sys::PointerEvent, pos: Pos2, on_canvas: bool) -> bool { + fn feed(&mut self, kind: &str, e: &web_sys::PointerEvent, pos: Pos2, coalesced: &[(Pos2, f32)], on_canvas: bool) -> bool { let id = e.pointer_id(); match e.pointer_type().as_str() { "pen" => { @@ -91,6 +91,12 @@ impl Pointers { self.press(id, pos); } "pointermove" if self.pen == Some(id) => { + // The positions the pen reported between two events (up to 240 a second + // on an iPad), each with its pressure, so fast strokes keep their curve. + for c in coalesced.iter().take(coalesced.len().saturating_sub(1)) { + self.touch(PEN_DEVICE, id, TouchPhase::Move, c.0, Some(c.1)); + self.events.push(Event::PointerMoved(c.0)); + } self.touch(PEN_DEVICE, id, TouchPhase::Move, pos, force); self.events.push(Event::PointerMoved(pos)); } @@ -195,7 +201,21 @@ pub fn install(runner: &eframe::WebRunner, canvas: &web_sys::HtmlCanvasElement, let rect = canvas.get_bounding_client_rect(); let z = runner.egui_ctx().zoom_factor(); let pos = egui::pos2((e.client_x() as f32 - rect.left() as f32) / z, (e.client_y() as f32 - rect.top() as f32) / z); - if !pointers.borrow_mut().feed(kind, &e, pos, on_canvas) { + let at = |x: i32, y: i32| egui::pos2((x as f32 - rect.left() as f32) / z, (y as f32 - rect.top() as f32) / z); + let coalesced: Vec<(Pos2, f32)> = if kind == "pointermove" + && e.pointer_type() == "pen" + // Safari before 17 hasn't it, and calling a missing method would abort the app. + && js_sys::Reflect::has(&e, &"getCoalescedEvents".into()).unwrap_or(false) + { + e.get_coalesced_events() + .iter() + .filter_map(|c| c.dyn_into::().ok()) + .map(|c| (at(c.client_x(), c.client_y()), c.pressure().clamp(0.0, 1.0))) + .collect() + } else { + vec![] + }; + if !pointers.borrow_mut().feed(kind, &e, pos, &coalesced, on_canvas) { return; } e.stop_immediate_propagation(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index fc1e11be2..7dcfae666 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -115,6 +115,34 @@ fn pen_pressure(ui: &Ui, pressed: bool) -> f32 { p } +/// The positions this frame's pointer moves passed through before its last one, each with the +/// pen force a touch event gave just before it (if any). The last move is the pointer's position. +/// Only for pens and fingers (frames with touch events): a mouse's moves are left as they were. +fn frame_moves(ui: &Ui) -> Vec<(Pos2, Option)> { + ui.input(|i| { + if !i.events.iter().any(|e| matches!(e, egui::Event::Touch { .. })) { + return vec![]; + } + let mut force = None; + let mut moves = vec![]; + for e in &i.events { + match e { + egui::Event::Touch { force: Some(f), .. } if f.is_finite() && *f > 0.0 => force = Some(f.clamp(0.0, 1.0)), + egui::Event::PointerMoved(p) => moves.push((*p, force)), + _ => {} + } + } + moves.pop(); + // A burst of events (a stalled frame) is thinned to a bounded count. + const MAX: usize = 64; + if moves.len() > MAX { + let step = moves.len().div_ceil(MAX); + moves = moves.into_iter().step_by(step).collect(); + } + moves + }) +} + pub fn mods(m: egui::Modifiers, space: bool) -> Mods { Mods { shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl, space } } @@ -691,6 +719,12 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: } Drag::Tool => { if pointer.delta() != egui::Vec2::ZERO { + // Every position the pen or mouse passed this frame, not just the last: a + // fast stroke keeps its curve (a pen reports far more often than frames come). + for (at, force) in frame_moves(ui) { + let pressure = force.unwrap_or_else(|| pen_pressure(ui, false)); + dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure }, view); + } let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) }; dispatch(app, &ev, view); } From c2d832ebc0d78b4a3a8b5c4643050d4bb98fcba5 Mon Sep 17 00:00:00 2001 From: gniax Date: Tue, 13 Oct 2026 14:51:21 +0200 Subject: [PATCH 3/4] docs: pen and touch input on the web in development and hardware parity --- docs/development.md | 2 ++ docs/hardware-parity.md | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/development.md b/docs/development.md index 0dbbf13b7..e6ebb0054 100644 --- a/docs/development.md +++ b/docs/development.md @@ -29,6 +29,8 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop: - **Open** sets `Services::open_async`, which shows `rfd::AsyncFileDialog`. The bytes arrive in `Services::inbox`, which the app drains every frame. - **Save / Save As / Export** go through `Services::download`: a Blob, an object URL and a temporary ``, all created from Rust. There is no save dialog, so the suggested name becomes the download name. +- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox (files to be placed on a canvas go to `Services::place_inbox` with their `place::DropAt`: the document they were dropped on, by uid, and the point; `VectorcraftApp::drop_target` decides from the position the page's drag events give). Desktop drags carry no position (winit 0.30 reports none, and the pointer position egui last had is stale), so the desktop app decides by kind: documents open, pictures and text are placed in the middle of the view. On the web and the desktop, swatch and graphic style libraries, Libraries panel files, presets and plug-ins open wherever they are dropped, as File › Open opens them (`io::opens_as`). A file that arrives after another document became active still lands in its own document, which becomes active again; if that document closed meanwhile, the file is not placed and the status bar says so. (The app's synchronous drop path is compiled out on wasm32.) +- **Pen and touch input** (`apps/vectorcraft-web/src/pencil.rs`): the shell reads pen and finger Pointer Events itself, in the capture phase on the window, before eframe's touch and pointer handlers (which can't tell a pen from a finger and see each touch twice, as a pointer and as a touch event). Mouse and trackpad events stay eframe's. A pen drives egui's pointer and is a touch contact whose force is the pen's pressure; its coalesced events (an Apple Pencil reports up to 240 a second) reach the tool one by one with their pressure (`canvas::frame_moves`); holding it still opens the context menu (egui's long touch) and drops the tool's press. Fingers are touch contacts (pinch, two-finger pan, the undo and redo taps); the first one also drives the pointer, so it taps panels and menus. Once a pen has been seen (kept in `localStorage` as `vectorcraft-pen-seen`), a finger never presses a tool on the canvas (`touch::finger_navigates_only`), and a pen landing cancels the fingers already down (palm rejection). The page itself doesn't scroll, zoom or select under touch (`touch-action: none`, `overscroll-behavior`), and the canvas stays inside the safe areas. - **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox (files to be placed on a canvas go to `Services::place_inbox` with their `place::DropAt`: the document they were dropped on, by uid, and the point; `VectorcraftApp::drop_target` decides from the position the page's drag events give). Desktop drags carry no position (winit 0.30 reports none, and the pointer position egui last had is stale), so the desktop app decides by kind: documents open, pictures and text are placed in the middle of the view. On the web and the desktop, swatch and graphic style libraries, Libraries panel files, presets, plug-ins and shortcut sets open wherever they are dropped, as File › Open opens them (`io::opens_as`). A file that arrives after another document became active still lands in its own document, which becomes active again; if that document closed meanwhile, the file is not placed and the status bar says so. (The app's synchronous drop path is compiled out on wasm32.) - **Data Recovery** keeps its copies in `localStorage` (`vectorcraft-recovery//`, shared by the site's tabs), so tabs can't lock their areas the way desktop apps lock a folder (`RecoveryStore::lock`). Each tab holds its area with a heartbeat it refreshes every minute and, where the browser has Web Locks (secure pages: `https://` or localhost), with a Web Lock named after the area (`apps/vectorcraft-web/src/locks.rs`, through `js_sys::Reflect` since web-sys has them only as an unstable API). The browser releases the lock only when the tab is gone, not while its timers are paused in the background, which a heartbeat can't tell apart (#367). `navigator.locks.query()` is asynchronous, so the shell keeps a snapshot refreshed every 10 seconds (taken once before the first frame); a snapshot older than a minute counts every area as held. An area is offered as a crash's leftovers only when no tab holds its lock and its heartbeat is older than three intervals. Without Web Locks a paused tab can still be taken for gone; it repairs that when it resumes: its next heartbeat (or recovery save) writes again the copies missing from its area, and a heartbeat newer than the one the other tab wrote taking the area over makes the area running again for that tab. - **Losing the graphics (#369):** the browser can drop the page's WebGPU device or WebGL context (a GPU reset, a driver update, too many tabs on the GPU; or a script calling `device.destroy()` / `WEBGL_lose_context.loseContext()`). The app goes on without it, so the shell watches for it: wgpu's device-lost callback for WebGPU, the canvas's `webglcontextlost` event for WebGL2. Both report to a `graphics::GraphicsLoss`, and the next frame of `WebShell::logic` handles it: diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 2c92e83e8..e93cabf2e 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (tool cursors painted into the window on Wayland, #1652, not run on Linux; borderless title-bar window on Windows, #1758, awaiting confirmation; a window surface larger than 8192 px keeps working, #1459 in part; Linux Display Server preference opens the window through XWayland, #1151) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (web: pen and finger input from Pointer Events, Apple Pencil palm rejection; tool cursors painted into the window on Wayland, #1652, not run on Linux; borderless title-bar window on Windows, #1758, awaiting confirmation; a window surface larger than 8192 px keeps working, #1459 in part; Linux Display Server preference opens the window through XWayland, #1151) · **Target:** Adobe Illustrator 2026 (30.x) What hardware Illustrator uses, per platform, against VectorCraft. Illustrator's side is from its public documentation (GPU Performance, touch workspace, pen pressure pages); ours from the code @@ -19,10 +19,10 @@ on huge documents. Pen input is pressure-only and Windows-first. | Multithreaded rendering off the UI thread | partial | yes | yes | yes | single-threaded | beyond Illustrator | | Hybrid-graphics GPU choice | automatic | power-saving GPU by default, Preferences › Graphics Processor | same; DX12 then OpenGL; no Vulkan unless asked (#545, #806) | the desktop's GPU | browser | done | | Restart on another GPU when the first fails to present | — | yes (#502, #964) | yes | yes | restart on lost device (#369) | done | -| Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | **no** (winit reports none, #852) | yes (pointer force via `WM_POINTER`) | X11 only via XWayland (Preferences › Performance › Linux Display Server, #1151); none on Wayland (#491, #764) | browser pointer events | partial: 4–8 h for macOS and Wayland (winit 0.31) | +| Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | **no** (winit reports none, #852) | yes (pointer force via `WM_POINTER`) | X11 only via XWayland (Preferences › Performance › Linux Display Server, #1151); none on Wayland (#491, #764) | browser pointer events, with coalesced events; Apple Pencil on iPad (hover, palm rejection: fingers navigate once a pen was seen) | partial: 4–8 h for macOS and Wayland (winit 0.31) | | Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | no | no | no | no | missing: 4–8 h after the input layer (#372) | | Pen eraser end | yes | no | no | no | no | missing | -| Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch and scroll | pinch, pan; two-finger tap undo, three-finger redo (#585) | same where winit reports touch | same | partial: touch workspace and rotate gesture missing, 3–5 h | +| Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch and scroll | pinch, pan; two-finger tap undo, three-finger redo (#585) | same where winit reports touch | same, from Pointer Events (pen and fingers told apart); long press opens the context menu | partial: touch workspace and rotate gesture missing, 3–5 h | | Trackpad (smooth scroll, pinch) | yes | yes | precision touchpads | yes | yes | done | | HiDPI / Retina, per-monitor scale | yes | yes | yes | yes | yes | done; a window dragged between monitors of different scale can still grow on Windows (#1459, open); the custom title bar's window on Windows stays truly borderless so the pointer lines up with what is drawn (#1791, #1758; awaiting the reporter's confirmation) | | Multiple monitors: panels and document windows on another screen | yes | no (one OS window) | no | no | — | missing: 6–10 h (with multiple windows, gap G7) | @@ -33,6 +33,7 @@ on huge documents. Pen input is pressure-only and Windows-first. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Web: the shell reads pen and finger Pointer Events itself (pen pressure with coalesced events, Apple Pencil hover, palm rejection: once a pen was seen fingers only navigate; long press opens the context menu); score unchanged (estimated) | | 2026-10-14 | minor | Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed); On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation); neither is confirmed on the hardware that reported it, so nothing is marked done | | 2026-10-13 | minor | A window whose surface is larger than 8192 px (stretched over several monitors, or grown by a drag between monitors of different scale) is configured up to the graphics adapter's limit instead of closing the app (#1495, part of #1459); the window growing during that drag on Windows is still open; not reproduced on a mixed-scale setup | | 2026-10-11 | minor | Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland on a Wayland session, where pens and dropped files reach the app (#1151); score unchanged (estimated) | From 6f231909257d8288a10005fae326df564dedf858 Mon Sep 17 00:00:00 2001 From: gniax Date: Thu, 15 Oct 2026 15:45:21 +0200 Subject: [PATCH 4/4] fix(web): preserve pen buttons and touch-only input --- apps/vectorcraft-web/src/main.rs | 2 +- apps/vectorcraft-web/src/pencil.rs | 227 +++++++++++++++++++++++------ apps/vectorcraft-web/src/web.rs | 1 - crates/ui-egui/src/canvas.rs | 49 +++++++ crates/ui-egui/src/touch.rs | 4 +- docs/development.md | 3 +- docs/hardware-parity.md | 9 +- 7 files changed, 244 insertions(+), 51 deletions(-) diff --git a/apps/vectorcraft-web/src/main.rs b/apps/vectorcraft-web/src/main.rs index 4ad16335c..32cd10784 100644 --- a/apps/vectorcraft-web/src/main.rs +++ b/apps/vectorcraft-web/src/main.rs @@ -20,7 +20,7 @@ mod fonts; #[cfg(target_arch = "wasm32")] mod locks; -#[cfg(target_arch = "wasm32")] +#[cfg(any(target_arch = "wasm32", test))] mod pencil; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/vectorcraft-web/src/pencil.rs b/apps/vectorcraft-web/src/pencil.rs index 8651bbf60..42f6294f2 100644 --- a/apps/vectorcraft-web/src/pencil.rs +++ b/apps/vectorcraft-web/src/pencil.rs @@ -7,21 +7,23 @@ //! where the pen can (Apple Pencil hover). //! - Fingers are touch contacts: two pinch and pan the canvas, and tap to undo or redo. The first //! finger also drives the pointer, so it taps the panels and menus; on the canvas it navigates -//! only once a pen has been seen ([`touch::set_source`]), which the page remembers. +//! only once a pen has been seen ([`touch::set_source`]), for this page session only. //! - Palm rejection: while a pen is down, fingers are ignored; a pen landing cancels the fingers //! already down (a palm resting first). //! -//! Mouse and trackpad events (a Magic Keyboard) are left to eframe. +//! Mouse and trackpad events (a Magic Keyboard) are left to eframe. Until the first pen +//! contact, fingers and their DOM touch events also stay with eframe (including its text agent). +#[cfg(target_arch = "wasm32")] use std::cell::RefCell; +#[cfg(target_arch = "wasm32")] use std::rc::Rc; use egui::{Event, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase}; use vectorcraft_ui_egui::touch::{self, Source}; +#[cfg(target_arch = "wasm32")] use wasm_bindgen::JsCast as _; -/// Where the page remembers that a pen has been seen (`localStorage`). -const PEN_SEEN_KEY: &str = "vectorcraft-pen-seen"; /// The pen's touch device (fingers use another, so egui doesn't pinch a pen with a finger). const PEN_DEVICE: TouchDeviceId = TouchDeviceId(1); const FINGER_DEVICE: TouchDeviceId = TouchDeviceId(2); @@ -36,10 +38,33 @@ pub struct Pointers { fingers: Vec, /// The contact driving egui's pointer. primary: Option, + button: Option, } +#[cfg(target_arch = "wasm32")] pub type SharedPointers = Rc>; +/// Browser-independent snapshot, also used by the routing regression tests. +struct Input<'a> { + pointer_type: &'a str, + id: i32, + button: i16, + pressure: f32, + modifiers: egui::Modifiers, +} + +// Match eframe's button_from_mouse_event, including barrel/right and auxiliary buttons. +fn button(number: i16) -> Option { + match number { + 0 => Some(PointerButton::Primary), + 1 => Some(PointerButton::Middle), + 2 => Some(PointerButton::Secondary), + 3 => Some(PointerButton::Extra1), + 4 => Some(PointerButton::Extra2), + _ => None, + } +} + impl Pointers { /// The events since the last frame, for the frame's raw input. pub fn take(&mut self) -> Vec { @@ -50,45 +75,55 @@ impl Pointers { self.events.push(Event::Touch { device_id: device, id: TouchId::from(id as u64), phase, pos, force }); } - fn press(&mut self, id: i32, pos: Pos2) { + fn press(&mut self, id: i32, pos: Pos2, button: PointerButton, modifiers: egui::Modifiers) { self.primary = Some(id); + self.button = Some(button); + self.events.push(Event::ModifiersChanged(modifiers)); self.events.push(Event::PointerMoved(pos)); - self.events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::default() }); + self.events.push(Event::PointerButton { pos, button, pressed: true, modifiers }); } - fn release(&mut self, pos: Pos2) { + fn release(&mut self, pos: Pos2, modifiers: egui::Modifiers) { self.primary = None; - self.events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::default() }); + if let Some(button) = self.button.take() { + self.events.push(Event::ModifiersChanged(modifiers)); + self.events.push(Event::PointerButton { pos, button, pressed: false, modifiers }); + } } /// Cancel every finger down (a palm under a pen landing). - fn cancel_fingers(&mut self, pos: Pos2) { + fn cancel_fingers(&mut self, pos: Pos2, modifiers: egui::Modifiers) { for id in std::mem::take(&mut self.fingers) { self.touch(FINGER_DEVICE, id, TouchPhase::Cancel, pos, None); if self.primary == Some(id) { - self.release(pos); + self.release(pos, modifiers); self.events.push(Event::PointerGone); } } } + /// The paired DOM touch events must follow the same routing as pointer events. + fn captures_touch(&self, on_canvas: bool) -> bool { + touch::pen_seen() && (on_canvas || self.pen.is_some() || !self.fingers.is_empty()) + } + /// Follow one pointer event; `true` if it was the pen's or a finger's (taken from eframe). - fn feed(&mut self, kind: &str, e: &web_sys::PointerEvent, pos: Pos2, coalesced: &[(Pos2, f32)], on_canvas: bool) -> bool { - let id = e.pointer_id(); - match e.pointer_type().as_str() { + fn feed(&mut self, kind: &str, e: &Input<'_>, pos: Pos2, coalesced: &[(Pos2, f32)], on_canvas: bool) -> bool { + let id = e.id; + match e.pointer_type { "pen" => { - let force = Some(e.pressure().clamp(0.0, 1.0)); + let force = Some(e.pressure.clamp(0.0, 1.0)); match kind { "pointerdown" => { if !on_canvas && self.pen.is_none() { return false; } - set_pen_seen(); + let Some(button) = button(e.button) else { return false }; touch::set_source(Source::Pen); - self.cancel_fingers(pos); + self.cancel_fingers(pos, e.modifiers); self.pen = Some(id); self.touch(PEN_DEVICE, id, TouchPhase::Start, pos, force); - self.press(id, pos); + self.press(id, pos, button, e.modifiers); } "pointermove" if self.pen == Some(id) => { // The positions the pen reported between two events (up to 240 a second @@ -114,7 +149,7 @@ impl Pointers { let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel }; self.pen = None; self.touch(PEN_DEVICE, id, phase, pos, None); - self.release(pos); + self.release(pos, e.modifiers); } // A pen leaving the hover range, or an event of a press we didn't take. _ => { @@ -126,6 +161,9 @@ impl Pointers { } true } + // Keep eframe's touch/IME path unchanged on touch-only devices. Reloading the + // page resets this policy; an old localStorage marker is deliberately ignored. + "touch" if !touch::pen_seen() => false, "touch" => { match kind { "pointerdown" => { @@ -140,7 +178,7 @@ impl Pointers { self.touch(FINGER_DEVICE, id, TouchPhase::Start, pos, None); if self.primary.is_none() && self.fingers.len() == 1 { touch::set_source(Source::Finger); - self.press(id, pos); + self.press(id, pos, PointerButton::Primary, e.modifiers); } } "pointermove" if self.fingers.contains(&id) => { @@ -154,7 +192,7 @@ impl Pointers { self.fingers.retain(|f| *f != id); self.touch(FINGER_DEVICE, id, phase, pos, None); if self.primary == Some(id) { - self.release(pos); + self.release(pos, e.modifiers); self.events.push(Event::PointerGone); } } @@ -163,32 +201,20 @@ impl Pointers { } true } + "mouse" => { + if kind == "pointerdown" { + touch::set_source(Source::Mouse); + } + false + } _ => false, } } } -/// Whether the page remembers a pen, for [`touch::set_pen_seen`] before the first frame. -pub fn restore_pen_seen() { - let seen = local_storage().and_then(|s| s.get_item(PEN_SEEN_KEY).ok().flatten()).is_some_and(|v| v == "1"); - touch::set_pen_seen(seen); -} - -fn set_pen_seen() { - if !touch::pen_seen() - && let Some(s) = local_storage() - { - // Private browsing can refuse it: fingers then draw again on the next visit, no more. - let _ = s.set_item(PEN_SEEN_KEY, "1"); - } -} - -fn local_storage() -> Option { - web_sys::window()?.local_storage().ok().flatten() -} - /// Take pen and finger Pointer Events on the page from eframe into `pointers` (and the touch /// events that follow them), for the runner's frames. Mouse events stay eframe's. +#[cfg(target_arch = "wasm32")] pub fn install(runner: &eframe::WebRunner, canvas: &web_sys::HtmlCanvasElement, pointers: &SharedPointers) -> Result<(), wasm_bindgen::JsValue> { let window: web_sys::EventTarget = web_sys::window().ok_or("no window")?.into(); let options = web_sys::AddEventListenerOptions::new(); @@ -215,7 +241,22 @@ pub fn install(runner: &eframe::WebRunner, canvas: &web_sys::HtmlCanvasElement, } else { vec![] }; - if !pointers.borrow_mut().feed(kind, &e, pos, &coalesced, on_canvas) { + let pointer_type = e.pointer_type(); + let input = Input { + pointer_type: &pointer_type, + id: e.pointer_id(), + button: e.button(), + pressure: e.pressure(), + // Same semantics as eframe's modifiers_from_mouse_event. + modifiers: egui::Modifiers { + alt: e.alt_key(), + ctrl: e.ctrl_key(), + shift: e.shift_key(), + mac_cmd: e.meta_key(), + command: e.ctrl_key() || e.meta_key(), + }, + }; + if !pointers.borrow_mut().feed(kind, &input, pos, &coalesced, on_canvas) { return; } e.stop_immediate_propagation(); @@ -238,7 +279,7 @@ pub fn install(runner: &eframe::WebRunner, canvas: &web_sys::HtmlCanvasElement, runner.add_event_listener_ex(&window, kind, &options, move |e: web_sys::TouchEvent, _runner, _| { let on_canvas = e.target().is_some_and(|t| &t == canvas.unchecked_ref::()); let p = pointers.borrow(); - if on_canvas || p.pen.is_some() || !p.fingers.is_empty() { + if p.captures_touch(on_canvas) { e.stop_immediate_propagation(); if e.cancelable() { e.prevent_default(); @@ -248,3 +289,107 @@ pub fn install(runner: &eframe::WebRunner, canvas: &web_sys::HtmlCanvasElement, } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn input(pointer_type: &str, button: i16) -> Input<'_> { + Input { pointer_type, id: 7, button, pressure: 0.6, modifiers: egui::Modifiers::default() } + } + + fn reset() { + touch::set_pen_seen(false); + touch::set_source(Source::Mouse); + } + + #[test] + fn pen_buttons_and_modifiers_survive_press_release_and_cancel() { + for (number, expected) in [ + (0, PointerButton::Primary), + (1, PointerButton::Middle), + (2, PointerButton::Secondary), + (3, PointerButton::Extra1), + (4, PointerButton::Extra2), + ] { + for end in ["pointerup", "pointercancel"] { + reset(); + let mut p = Pointers::default(); + let mut e = input("pen", number); + e.modifiers = egui::Modifiers { shift: true, ctrl: true, alt: true, mac_cmd: true, command: true }; + let pos = Pos2::new(30.0, 40.0); + assert!(p.feed("pointerdown", &e, pos, &[], true)); + assert!(p.feed(end, &e, pos, &[], false)); + let buttons: Vec<_> = p + .take() + .into_iter() + .filter_map(|e| match e { + Event::PointerButton { button, pressed, modifiers, .. } => Some((button, pressed, modifiers)), + _ => None, + }) + .collect(); + assert_eq!(buttons, [(expected, true, e.modifiers), (expected, false, e.modifiers)]); + assert!(p.pen.is_none() && p.primary.is_none()); + } + } + reset(); + } + + #[test] + fn touch_only_and_mouse_input_stay_with_eframe_and_session_can_reset() { + reset(); + let mut p = Pointers::default(); + let pos = Pos2::new(10.0, 20.0); + for pointer_type in ["touch", "mouse"] { + for kind in ["pointerdown", "pointermove", "pointerup", "pointercancel"] { + assert!(!p.feed(kind, &input(pointer_type, 0), pos, &[], true)); + } + } + assert!(p.take().is_empty()); + assert!(!p.captures_touch(true), "eframe must also receive DOM touch events before a pen"); + assert!(p.feed("pointerdown", &input("pen", 0), pos, &[], true)); + assert!(p.feed("pointerup", &input("pen", 0), pos, &[], true)); + assert!(p.captures_touch(true)); + assert!(p.feed("pointerdown", &input("touch", 0), pos, &[], true)); + assert!(p.feed("pointerup", &input("touch", 0), pos, &[], true)); + // A new page starts from false, without reading any old persistent marker. + reset(); + let mut next_page = Pointers::default(); + assert!(!next_page.feed("pointerdown", &input("touch", 0), pos, &[], true)); + assert!(next_page.take().is_empty()); + assert!(!next_page.captures_touch(true)); + } + + #[test] + fn finger_modifiers_and_palm_cancellation_are_preserved() { + reset(); + touch::set_source(Source::Pen); + let mut p = Pointers::default(); + let pos = Pos2::new(10.0, 20.0); + let mut finger = input("touch", 0); + finger.id = 10; + finger.modifiers.shift = true; + assert!(p.feed("pointerdown", &finger, pos, &[], true)); + assert!(p.take().iter().any(|e| matches!(e, Event::PointerButton { pressed: true, modifiers, .. } if modifiers.shift))); + let pen = input("pen", 0); + assert!(p.feed("pointerdown", &pen, pos, &[], true)); + assert!(p.take().iter().any(|e| matches!(e, Event::Touch { phase: TouchPhase::Cancel, .. }))); + assert!(p.fingers.is_empty()); + assert!(p.feed("pointermove", &finger, pos, &[], true)); + assert!(p.take().is_empty(), "a resting palm cannot move the pen"); + assert!(p.feed("pointerup", &pen, pos, &[], true)); + assert!(p.feed("pointerdown", &finger, pos, &[], true)); + assert!(p.feed("pointerup", &finger, pos, &[], true)); + assert!(p.take().iter().any(|e| matches!(e, Event::PointerButton { pressed: false, modifiers, .. } if modifiers.shift))); + reset(); + } + + #[test] + fn unknown_pen_buttons_are_not_reinterpreted_as_primary() { + reset(); + let mut p = Pointers::default(); + assert!(!p.feed("pointerdown", &input("pen", 5), Pos2::ZERO, &[], true)); + assert!(p.take().is_empty()); + assert!(!touch::pen_seen()); + } +} diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index e4753c28c..028c5bc71 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -64,7 +64,6 @@ pub fn start() { return; }; detect_language(); - crate::pencil::restore_pen_seen(); wasm_bindgen_futures::spawn_local(async move { // Before the first frame, which looks for copies a crash left behind. let locks = WebLocks::start(RECOVERY_PREFIX).await; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7dcfae666..c400c6bc5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4554,6 +4554,55 @@ mod tests { assert_eq!(plain(&app, id), "雅がくらくか"); } + #[test] + fn frame_moves_keeps_touch_samples_and_pressure_but_leaves_mouse_unchanged() { + let ctx = egui::Context::default(); + let a = pos2(10.0, 20.0); + let b = pos2(20.0, 30.0); + let c = pos2(30.0, 40.0); + let events = vec![egui::Event::PointerMoved(a), egui::Event::PointerMoved(b), egui::Event::PointerMoved(c)]; + let mut mouse = vec![]; + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| mouse = frame_moves(ui)); + out.textures_delta.clear(); + assert!(mouse.is_empty()); + let events = [pen(egui::TouchPhase::Move, a, 0.2), pen(egui::TouchPhase::Move, b, 0.8), pen(egui::TouchPhase::Move, c, 0.6)].concat(); + let mut samples = vec![]; + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui)); + out.textures_delta.clear(); + assert_eq!(samples, [(a, Some(0.2)), (b, Some(0.8))], "the last sample follows the normal pointer path"); + let events = (0..200).flat_map(|n| pen(egui::TouchPhase::Move, pos2(n as f32, 0.0), 0.5)).collect(); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui)); + out.textures_delta.clear(); + assert!(samples.len() <= 64, "a stalled frame stays bounded"); + } + + #[test] + fn long_touch_drops_a_tool_press_without_committing_art() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width":800,"height":600})).unwrap(); + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + let tick = |app: &mut VectorcraftApp, time, events| { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + time: Some(time), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + tick(&mut app, 0.0, vec![]); + let pos = app.canvas_rect.unwrap().center(); + tick(&mut app, 0.1, pen(egui::TouchPhase::Start, pos, 0.5)); + assert!(matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Tool))); + tick(&mut app, 1.5, vec![]); + assert!(matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Gesture)), "long touch drops the tool drag"); + assert!(app.session.active().unwrap().interaction.is_none()); + tick(&mut app, 1.6, pen(egui::TouchPhase::End, pos, 0.5)); + assert!(app.session.active().unwrap().doc.art_bounds().is_none(), "releasing cannot commit the cancelled press"); + } + /// The events eframe's winit integration sends for a pen (Windows Ink) or a finger: a touch /// with its pressure, and the mouse it stands in for (#491). fn pen(phase: egui::TouchPhase, pos: Pos2, force: f32) -> Vec { diff --git a/crates/ui-egui/src/touch.rs b/crates/ui-egui/src/touch.rs index b6223907c..5d2cd6c8d 100644 --- a/crates/ui-egui/src/touch.rs +++ b/crates/ui-egui/src/touch.rs @@ -7,7 +7,7 @@ //! ([`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, +//! 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. @@ -40,7 +40,7 @@ pub fn set_source(source: Source) { } } -/// Whether a pen has been seen (the shell restores it across visits), so fingers navigate only. +/// 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)); } diff --git a/docs/development.md b/docs/development.md index e6ebb0054..6ee48053d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -29,9 +29,8 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop: - **Open** sets `Services::open_async`, which shows `rfd::AsyncFileDialog`. The bytes arrive in `Services::inbox`, which the app drains every frame. - **Save / Save As / Export** go through `Services::download`: a Blob, an object URL and a temporary ``, all created from Rust. There is no save dialog, so the suggested name becomes the download name. -- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox (files to be placed on a canvas go to `Services::place_inbox` with their `place::DropAt`: the document they were dropped on, by uid, and the point; `VectorcraftApp::drop_target` decides from the position the page's drag events give). Desktop drags carry no position (winit 0.30 reports none, and the pointer position egui last had is stale), so the desktop app decides by kind: documents open, pictures and text are placed in the middle of the view. On the web and the desktop, swatch and graphic style libraries, Libraries panel files, presets and plug-ins open wherever they are dropped, as File › Open opens them (`io::opens_as`). A file that arrives after another document became active still lands in its own document, which becomes active again; if that document closed meanwhile, the file is not placed and the status bar says so. (The app's synchronous drop path is compiled out on wasm32.) -- **Pen and touch input** (`apps/vectorcraft-web/src/pencil.rs`): the shell reads pen and finger Pointer Events itself, in the capture phase on the window, before eframe's touch and pointer handlers (which can't tell a pen from a finger and see each touch twice, as a pointer and as a touch event). Mouse and trackpad events stay eframe's. A pen drives egui's pointer and is a touch contact whose force is the pen's pressure; its coalesced events (an Apple Pencil reports up to 240 a second) reach the tool one by one with their pressure (`canvas::frame_moves`); holding it still opens the context menu (egui's long touch) and drops the tool's press. Fingers are touch contacts (pinch, two-finger pan, the undo and redo taps); the first one also drives the pointer, so it taps panels and menus. Once a pen has been seen (kept in `localStorage` as `vectorcraft-pen-seen`), a finger never presses a tool on the canvas (`touch::finger_navigates_only`), and a pen landing cancels the fingers already down (palm rejection). The page itself doesn't scroll, zoom or select under touch (`touch-action: none`, `overscroll-behavior`), and the canvas stays inside the safe areas. - **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox (files to be placed on a canvas go to `Services::place_inbox` with their `place::DropAt`: the document they were dropped on, by uid, and the point; `VectorcraftApp::drop_target` decides from the position the page's drag events give). Desktop drags carry no position (winit 0.30 reports none, and the pointer position egui last had is stale), so the desktop app decides by kind: documents open, pictures and text are placed in the middle of the view. On the web and the desktop, swatch and graphic style libraries, Libraries panel files, presets, plug-ins and shortcut sets open wherever they are dropped, as File › Open opens them (`io::opens_as`). A file that arrives after another document became active still lands in its own document, which becomes active again; if that document closed meanwhile, the file is not placed and the status bar says so. (The app's synchronous drop path is compiled out on wasm32.) +- **Pen and touch input** (`apps/vectorcraft-web/src/pencil.rs`): the shell reads pen and finger Pointer Events itself, in the capture phase on the window, before eframe's touch and pointer handlers (which can't tell a pen from a finger and see each touch twice, as a pointer and as a touch event). Mouse and trackpad events stay eframe's, as do finger events until the first pen contact, preserving eframe's touch-only keyboard path. Pen buttons and event modifiers follow eframe's mappings. A pen drives egui's pointer and is a touch contact whose force is the pen's pressure; its coalesced events (an Apple Pencil reports up to 240 a second) reach the tool one by one with their pressure (`canvas::frame_moves`); holding it still opens the context menu (egui's long touch) and drops the tool's press. Fingers are touch contacts (pinch, two-finger pan, the undo and redo taps); the first one also drives the pointer, so it taps panels and menus. Once a pen has been seen in this page session (reset by reloading; no persistent storage), a finger never presses a tool on the canvas (`touch::finger_navigates_only`), and a pen landing cancels the fingers already down (palm rejection). The page itself doesn't scroll, zoom or select under touch (`touch-action: none`, `overscroll-behavior`), and the canvas stays inside the safe areas. - **Data Recovery** keeps its copies in `localStorage` (`vectorcraft-recovery//`, shared by the site's tabs), so tabs can't lock their areas the way desktop apps lock a folder (`RecoveryStore::lock`). Each tab holds its area with a heartbeat it refreshes every minute and, where the browser has Web Locks (secure pages: `https://` or localhost), with a Web Lock named after the area (`apps/vectorcraft-web/src/locks.rs`, through `js_sys::Reflect` since web-sys has them only as an unstable API). The browser releases the lock only when the tab is gone, not while its timers are paused in the background, which a heartbeat can't tell apart (#367). `navigator.locks.query()` is asynchronous, so the shell keeps a snapshot refreshed every 10 seconds (taken once before the first frame); a snapshot older than a minute counts every area as held. An area is offered as a crash's leftovers only when no tab holds its lock and its heartbeat is older than three intervals. Without Web Locks a paused tab can still be taken for gone; it repairs that when it resumes: its next heartbeat (or recovery save) writes again the copies missing from its area, and a heartbeat newer than the one the other tab wrote taking the area over makes the area running again for that tab. - **Losing the graphics (#369):** the browser can drop the page's WebGPU device or WebGL context (a GPU reset, a driver update, too many tabs on the GPU; or a script calling `device.destroy()` / `WEBGL_lose_context.loseContext()`). The app goes on without it, so the shell watches for it: wgpu's device-lost callback for WebGPU, the canvas's `webglcontextlost` event for WebGL2. Both report to a `graphics::GraphicsLoss`, and the next frame of `WebShell::logic` handles it: 1. `VectorcraftApp::graphics_lost` writes Data Recovery copies of the modified documents at once (to browser storage, also when Data Recovery is turned off). diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index e93cabf2e..f7ada46b3 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (web: pen and finger input from Pointer Events, Apple Pencil palm rejection; tool cursors painted into the window on Wayland, #1652, not run on Linux; borderless title-bar window on Windows, #1758, awaiting confirmation; a window surface larger than 8192 px keeps working, #1459 in part; Linux Display Server preference opens the window through XWayland, #1151) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** patch (web pen buttons/modifiers preserved, touch-only input left to eframe, session-only palm rejection; tool cursors painted into the window on Wayland, #1652, not run on Linux; borderless title-bar window on Windows, #1758, awaiting confirmation; a window surface larger than 8192 px keeps working, #1459 in part; Linux Display Server preference opens the window through XWayland, #1151) · **Target:** Adobe Illustrator 2026 (30.x) What hardware Illustrator uses, per platform, against VectorCraft. Illustrator's side is from its public documentation (GPU Performance, touch workspace, pen pressure pages); ours from the code @@ -19,10 +19,10 @@ on huge documents. Pen input is pressure-only and Windows-first. | Multithreaded rendering off the UI thread | partial | yes | yes | yes | single-threaded | beyond Illustrator | | Hybrid-graphics GPU choice | automatic | power-saving GPU by default, Preferences › Graphics Processor | same; DX12 then OpenGL; no Vulkan unless asked (#545, #806) | the desktop's GPU | browser | done | | Restart on another GPU when the first fails to present | — | yes (#502, #964) | yes | yes | restart on lost device (#369) | done | -| Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | **no** (winit reports none, #852) | yes (pointer force via `WM_POINTER`) | X11 only via XWayland (Preferences › Performance › Linux Display Server, #1151); none on Wayland (#491, #764) | browser pointer events, with coalesced events; Apple Pencil on iPad (hover, palm rejection: fingers navigate once a pen was seen) | partial: 4–8 h for macOS and Wayland (winit 0.31) | +| Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | **no** (winit reports none, #852) | yes (pointer force via `WM_POINTER`) | X11 only via XWayland (Preferences › Performance › Linux Display Server, #1151); none on Wayland (#491, #764) | browser pointer events, with coalesced events; Apple Pencil on iPad (hover, palm rejection: fingers navigate after a pen contact until reload) | partial: 4–8 h for macOS and Wayland (winit 0.31) | | Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | no | no | no | no | missing: 4–8 h after the input layer (#372) | | Pen eraser end | yes | no | no | no | no | missing | -| Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch and scroll | pinch, pan; two-finger tap undo, three-finger redo (#585) | same where winit reports touch | same, from Pointer Events (pen and fingers told apart); long press opens the context menu | partial: touch workspace and rotate gesture missing, 3–5 h | +| Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch and scroll | pinch, pan; two-finger tap undo, three-finger redo (#585) | same where winit reports touch | eframe until the first pen contact, then Pointer Events (pen and fingers told apart); long press opens the context menu | partial: touch workspace and rotate gesture missing, 3–5 h | | Trackpad (smooth scroll, pinch) | yes | yes | precision touchpads | yes | yes | done | | HiDPI / Retina, per-monitor scale | yes | yes | yes | yes | yes | done; a window dragged between monitors of different scale can still grow on Windows (#1459, open); the custom title bar's window on Windows stays truly borderless so the pointer lines up with what is drawn (#1791, #1758; awaiting the reporter's confirmation) | | Multiple monitors: panels and document windows on another screen | yes | no (one OS window) | no | no | — | missing: 6–10 h (with multiple windows, gap G7) | @@ -33,8 +33,9 @@ on huge documents. Pen input is pressure-only and Windows-first. | Date | Change | Summary | |---|---|---| -| 2026-10-13 | minor | Web: the shell reads pen and finger Pointer Events itself (pen pressure with coalesced events, Apple Pencil hover, palm rejection: once a pen was seen fingers only navigate; long press opens the context menu); score unchanged (estimated) | +| 2026-10-15 | patch | Web review: preserve pen buttons and modifiers; leave fingers to eframe before the first pen contact; remove persistent pen policy. Canvas regression tests cover intermediate moves and long-touch cancellation on native and web input. Physical-device QA pending. | | 2026-10-14 | minor | Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed); On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation); neither is confirmed on the hardware that reported it, so nothing is marked done | +| 2026-10-13 | minor | Web: the shell reads pen and finger Pointer Events itself (pen pressure with coalesced events, Apple Pencil hover, palm rejection: after a pen contact fingers only navigate until reload; long press opens the context menu); score unchanged (estimated) | | 2026-10-13 | minor | A window whose surface is larger than 8192 px (stretched over several monitors, or grown by a drag between monitors of different scale) is configured up to the graphics adapter's limit instead of closing the app (#1495, part of #1459); the window growing during that drag on Windows is still open; not reproduced on a mixed-scale setup | | 2026-10-11 | minor | Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland on a Wayland session, where pens and dropped files reach the app (#1151); score unchanged (estimated) | | 2026-10-10 | major | First checklist from the canvas, touch and graphics code and the open hardware issues |