From 9fc7fd70384586dca6901ed7aab4c657c4f8d82e Mon Sep 17 00:00:00 2001 From: gniax Date: Tue, 13 Oct 2026 14:30:35 +0200 Subject: [PATCH] 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); }