From 6f231909257d8288a10005fae326df564dedf858 Mon Sep 17 00:00:00 2001 From: gniax Date: Thu, 15 Oct 2026 15:45:21 +0200 Subject: [PATCH] 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 |