Merge pull request #1740 from gniax/touch-pen-input

Web: Apple Pencil and touch input (palm rejection, pen pressure with coalesced events)
This commit is contained in:
BFlat
2026-10-16 01:57:27 +07:00
committed by GitHub
10 changed files with 606 additions and 15 deletions
+1 -1
View File
@@ -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"] }
+16 -3
View File
@@ -2,12 +2,25 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="VectorCraft">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="theme-color" content="#262626">
<title>VectorCraft</title>
<link data-trunk rel="rust" data-bin="vectorcraft-web" data-wasm-opt="z" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#vectorcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* iPad: no page scroll, bounce, pinch zoom, text selection or callout; the app takes the gestures. */
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626;
overscroll-behavior: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent; -webkit-text-size-adjust: 100%; }
/* Inside the safe areas (notch, home indicator) of a full-screen app. */
#vectorcraft_canvas { position: absolute; top: env(safe-area-inset-top, 0); right: env(safe-area-inset-right, 0);
bottom: env(safe-area-inset-bottom, 0); left: env(safe-area-inset-left, 0);
width: calc(100% - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
height: calc(100% - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
display: block; touch-action: none; }
#vectorcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
</style>
+5 -1
View File
@@ -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.
@@ -18,6 +20,8 @@
mod fonts;
#[cfg(target_arch = "wasm32")]
mod locks;
#[cfg(any(target_arch = "wasm32", test))]
mod pencil;
#[cfg(target_arch = "wasm32")]
mod web;
+395
View File
@@ -0,0 +1,395 @@
//! 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`]), 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. 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 _;
/// 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<Event>,
/// The pen pressing, by pointer id.
pen: Option<i32>,
/// The fingers down, by pointer id.
fingers: Vec<i32>,
/// The contact driving egui's pointer.
primary: Option<i32>,
button: Option<PointerButton>,
}
#[cfg(target_arch = "wasm32")]
pub type SharedPointers = Rc<RefCell<Pointers>>;
/// 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<PointerButton> {
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<Event> {
std::mem::take(&mut self.events)
}
fn touch(&mut self, device: TouchDeviceId, id: i32, phase: TouchPhase, pos: Pos2, force: Option<f32>) {
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, 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, pressed: true, modifiers });
}
fn release(&mut self, pos: Pos2, modifiers: egui::Modifiers) {
self.primary = None;
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, 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, 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: &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));
match kind {
"pointerdown" => {
if !on_canvas && self.pen.is_none() {
return false;
}
let Some(button) = button(e.button) else { return false };
touch::set_source(Source::Pen);
self.cancel_fingers(pos, e.modifiers);
self.pen = Some(id);
self.touch(PEN_DEVICE, id, TouchPhase::Start, pos, force);
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
// 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));
}
// 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, e.modifiers);
}
// 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
}
// 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" => {
// 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, PointerButton::Primary, e.modifiers);
}
}
"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, e.modifiers);
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
}
"mouse" => {
if kind == "pointerdown" {
touch::set_source(Source::Mouse);
}
false
}
_ => false,
}
}
}
/// 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();
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::<web_sys::EventTarget>());
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);
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::<web_sys::PointerEvent>().ok())
.map(|c| (at(c.client_x(), c.client_y()), c.pressure().clamp(0.0, 1.0)))
.collect()
} else {
vec![]
};
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();
// 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::<web_sys::EventTarget>());
let p = pointers.borrow();
if p.captures_touch(on_canvas) {
e.stop_immediate_propagation();
if e.cancelable() {
e.prevent_default();
}
}
})?;
}
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());
}
}
+9
View File
@@ -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<Mutex<Vec<(String, Vec<u8>)>>>;
@@ -47,6 +48,8 @@ struct Host {
place_inbox: PlaceInbox,
drag: DragPos,
pasted: Rc<RefCell<Pasted>>,
/// 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<WebLocks>,
@@ -73,6 +76,7 @@ pub fn start() {
place_inbox: Arc::default(),
drag: DragPos::default(),
pasted: Rc::default(),
pointers: SharedPointers::default(),
locks,
restarts: 0,
};
@@ -96,6 +100,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
// The app until the new runner takes it (still here if starting fails before).
let slot = Rc::new(RefCell::new(moving.map(|m| m.app)));
let (runner, taken, page) = (host.runner.clone(), slot.clone(), canvas.clone());
let (pointers, pen_canvas) = (host.pointers.clone(), canvas.clone());
let result = runner
.start(
canvas,
@@ -127,6 +132,9 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
.await;
match (result, kept) {
(Ok(()), _) => {
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 +389,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);
}
+112 -6
View File
@@ -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<f32>)> {
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 }
}
@@ -569,6 +597,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<Drag> = ui.data(|d| d.get_temp(drag_id()));
// A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows
// without moving the mouse (Alt switches the Shape Builder to erase mode).
@@ -610,7 +643,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 }
@@ -683,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);
}
@@ -890,13 +932,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>(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 +951,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>(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).
@@ -4497,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>(drag_id())), Some(Drag::Tool)));
tick(&mut app, 1.5, vec![]);
assert!(matches!(ctx.data(|d| d.get_temp::<Drag>(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<egui::Event> {
+1 -1
View File
@@ -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;
+61
View File
@@ -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 in this session,
//! fingers only navigate: pinch, two-finger pan and the taps; a finger or a resting palm never
//! presses a tool on the canvas.
use std::cell::Cell;
use egui::{Pos2, TouchId, TouchPhase};
/// What drives the pointer.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Source {
/// A mouse or trackpad, or a platform that can't tell (the desktop apps).
#[default]
Mouse,
/// A pen (Apple Pencil, a stylus).
Pen,
/// A finger on a touch screen.
Finger,
}
thread_local! {
static SOURCE: Cell<Source> = const { Cell::new(Source::Mouse) };
static PEN_SEEN: Cell<bool> = const { Cell::new(false) };
}
/// The pointer's presses now come from `source`; a pen makes fingers navigate only from then on.
pub fn set_source(source: Source) {
SOURCE.with(|s| s.set(source));
if source == Source::Pen {
PEN_SEEN.with(|p| p.set(true));
}
}
/// Set the session-only pen policy (reload starts with finger drawing enabled).
pub fn set_pen_seen(seen: bool) {
PEN_SEEN.with(|p| p.set(seen));
}
/// A pen has been seen.
pub fn pen_seen() -> bool {
PEN_SEEN.with(Cell::get)
}
/// The press in progress is a finger's while a pen has been seen: it navigates, it doesn't draw.
pub(crate) fn finger_navigates_only() -> bool {
SOURCE.with(Cell::get) == Source::Finger && pen_seen()
}
/// How long the fingers of a tap may stay down, in seconds.
const TAP_TIME: f64 = 0.5;
/// How far (points) a finger of a tap may move: further, it is a pinch or a pan.
@@ -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);
}
}
+1
View File
@@ -30,6 +30,7 @@ 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 `<a download>`, 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, 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/<area>/<name>`, 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).
+5 -3
View File
@@ -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-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 | 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 | 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,7 +33,9 @@ on huge documents. Pen input is pressure-only and Windows-first.
| Date | Change | Summary |
|---|---|---|
| 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 |