Merge pull request #4104 from dotCooCoo/feat/web-pen-eraser-end

Pen tablets: a pen's eraser end selects the Eraser in the web build
This commit is contained in:
BFlat
2026-10-26 09:22:19 +07:00
committed by GitHub
7 changed files with 149 additions and 7 deletions
+2 -1
View File
@@ -11,7 +11,8 @@
//! - Save / Export trigger a browser download;
//! - 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).
//! hover, palm rejection, fingers navigate once a pen has been seen, the pen's eraser end
//! selects the Eraser tool).
//!
//! 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.
+129 -3
View File
@@ -5,6 +5,9 @@
//! - 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).
//! - A press of the pen's eraser end (the eraser button, `button` 5 in Pointer Events) presses
//! like the tip, and the pen's samples go into a [`StylusFeed`] with that end, so the canvas
//! selects the Eraser tool for the press. A press of the tip turns the pen back.
//! - 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.
@@ -20,6 +23,7 @@ use std::cell::RefCell;
use std::rc::Rc;
use egui::{Event, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase};
use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
use vectorcraft_ui_egui::touch::{self, Source};
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::JsCast as _;
@@ -28,8 +32,11 @@ use wasm_bindgen::JsCast as _;
const PEN_DEVICE: TouchDeviceId = TouchDeviceId(1);
const FINGER_DEVICE: TouchDeviceId = TouchDeviceId(2);
/// The Pointer Events `button` of a pen's eraser button, which is how a pen's eraser end presses
/// (<https://www.w3.org/TR/pointerevents3/#the-button-property>).
const ERASER_BUTTON: i16 = 5;
/// 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.
@@ -39,6 +46,16 @@ pub struct Pointers {
/// The contact driving egui's pointer.
primary: Option<i32>,
button: Option<PointerButton>,
/// The pen last pressed with its eraser end.
eraser: bool,
/// The pen's pressure and end for the canvas (`Services::stylus`).
stylus: StylusFeed,
}
impl Default for Pointers {
fn default() -> Self {
Self { events: vec![], pen: None, fingers: vec![], primary: None, button: None, eraser: false, stylus: StylusFeed::on_ui_thread() }
}
}
#[cfg(target_arch = "wasm32")]
@@ -71,6 +88,16 @@ impl Pointers {
std::mem::take(&mut self.events)
}
/// The feed the pen's samples go into, for `Services::stylus`.
pub fn stylus(&self) -> StylusFeed {
self.stylus.clone()
}
/// A pen moved or pressed with `pressure`, with the end it last pressed with.
fn pen_sample(&self, pressure: f32) {
self.stylus.set_sample(Some(PenSample { pressure, eraser: self.eraser }));
}
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 });
}
@@ -110,6 +137,11 @@ 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: &Input<'_>, pos: Pos2, coalesced: &[(Pos2, f32)], on_canvas: bool) -> bool {
let id = e.id;
if e.pointer_type != "pen" && self.pen.is_none() && matches!(kind, "pointerdown" | "pointermove") {
// Another pointer moved while no pen presses (a palm under a pen pressing is
// rejected): the canvas takes no pen pressure or pen end for it.
self.stylus.set(None);
}
match e.pointer_type {
"pen" => {
let force = Some(e.pressure.clamp(0.0, 1.0));
@@ -118,14 +150,27 @@ impl Pointers {
if !on_canvas && self.pen.is_none() {
return false;
}
let Some(button) = button(e.button) else { return false };
// The eraser end presses like the tip; the tip and the eraser end each
// set the end the pen is turned to.
let (button, end) = match e.button {
ERASER_BUTTON => (PointerButton::Primary, Some(true)),
number => match button(number) {
Some(button) => (button, (number == 0).then_some(false)),
None => return false,
},
};
touch::set_source(Source::Pen);
self.cancel_fingers(pos, e.modifiers);
if let Some(end) = end {
self.eraser = end;
}
self.pen_sample(e.pressure);
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) => {
self.pen_sample(e.pressure);
// 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)) {
@@ -143,10 +188,14 @@ impl Pointers {
if !on_canvas {
return false;
}
// Pointer Events report no end while a pen hovers: it keeps the end it
// last pressed with.
self.pen_sample(e.pressure);
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_sample(0.0);
self.pen = None;
self.touch(PEN_DEVICE, id, phase, pos, None);
self.release(pos, e.modifiers);
@@ -388,8 +437,85 @@ mod tests {
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.feed("pointerdown", &input("pen", 6), Pos2::ZERO, &[], true));
assert!(p.take().is_empty());
assert!(!touch::pen_seen());
assert_eq!(p.stylus().eraser(), None);
}
fn presses(p: &mut Pointers) -> Vec<(PointerButton, bool)> {
p.take()
.into_iter()
.filter_map(|e| match e {
Event::PointerButton { button, pressed, .. } => Some((button, pressed)),
_ => None,
})
.collect()
}
/// The eraser end presses as Pointer Events' eraser button (`button` 5): a primary press whose
/// samples carry the eraser end, kept while the pen hovers, until the tip presses.
#[test]
fn the_eraser_button_presses_with_the_eraser_end() {
reset();
let mut p = Pointers::default();
let feed = p.stylus();
let pos = Pos2::new(30.0, 40.0);
let eraser = input("pen", ERASER_BUTTON);
assert!(p.feed("pointerdown", &eraser, pos, &[], true));
assert_eq!(presses(&mut p), [(PointerButton::Primary, true)]);
assert!(touch::pen_seen());
assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(0.6)));
assert!(p.feed("pointermove", &input("pen", -1), pos, &[], true));
assert_eq!(feed.eraser(), Some(true));
assert!(p.feed("pointerup", &eraser, pos, &[], true));
assert_eq!(presses(&mut p), [(PointerButton::Primary, false)]);
let mut hover = input("pen", -1);
hover.pressure = 0.0;
assert!(p.feed("pointermove", &hover, pos, &[], true));
assert_eq!(feed.eraser(), Some(true), "a hovering pen keeps the end it pressed with");
// The barrel button leaves the end as it was.
assert!(p.feed("pointerdown", &input("pen", 2), pos, &[], true));
assert!(p.feed("pointerup", &input("pen", 2), pos, &[], true));
assert_eq!(feed.eraser(), Some(true));
assert!(p.feed("pointerdown", &input("pen", 0), pos, &[], true));
assert_eq!(feed.eraser(), Some(false), "the tip");
reset();
}
/// A mouse or a finger that moves takes the pen out of the feed, so the canvas gives it no
/// pen pressure and does not switch tools for it.
#[test]
fn other_pointers_clear_the_pen_feed() {
for (pointer_type, kind) in [("mouse", "pointermove"), ("mouse", "pointerdown"), ("touch", "pointerdown"), ("touch", "pointermove")] {
reset();
let mut p = Pointers::default();
let feed = p.stylus();
assert!(p.feed("pointerdown", &input("pen", ERASER_BUTTON), Pos2::ZERO, &[], true));
assert!(p.feed("pointerup", &input("pen", ERASER_BUTTON), Pos2::ZERO, &[], true));
let mut other = input(pointer_type, 0);
other.id = 10;
p.feed(kind, &other, Pos2::ZERO, &[], true);
assert_eq!((feed.eraser(), feed.held()), (None, None), "{pointer_type} {kind}");
}
reset();
}
/// A palm that lands or moves while the eraser end presses is rejected, and the feed keeps the
/// eraser end for the canvas's next frame.
#[test]
fn a_rejected_palm_keeps_the_pen_end() {
reset();
let mut p = Pointers::default();
let feed = p.stylus();
assert!(p.feed("pointerdown", &input("pen", ERASER_BUTTON), Pos2::ZERO, &[], true));
let mut palm = input("touch", 0);
palm.id = 10;
for (pointer_type, kind) in [("touch", "pointerdown"), ("touch", "pointermove"), ("mouse", "pointermove")] {
palm.pointer_type = pointer_type;
p.feed(kind, &palm, Pos2::ZERO, &[], true);
assert_eq!(feed.eraser(), Some(true), "{pointer_type} {kind}");
}
reset();
}
}
+6 -2
View File
@@ -13,6 +13,7 @@ use vectorcraft_ui_egui::graphics::GraphicsLoss;
use vectorcraft_ui_egui::i18n;
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
use vectorcraft_ui_egui::stylus::StylusFeed;
use vectorcraft_ui_egui::{Services, VectorcraftApp};
use wasm_bindgen::JsCast as _;
@@ -117,7 +118,8 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
}
host.pasted.borrow_mut().ctx = Some(ctx.clone());
let services = services(host.inbox.clone(), host.place_inbox.clone(), ctx.clone(), host.locks.clone());
let stylus = host.pointers.borrow().stylus();
let services = services(host.inbox.clone(), host.place_inbox.clone(), ctx.clone(), host.locks.clone(), stylus);
let app = match taken.borrow_mut().take() {
Some(mut app) => {
// The old services asked the old context for frames.
@@ -418,7 +420,7 @@ impl eframe::App for WebShell {
}
}
fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Option<WebLocks>) -> Services {
fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Option<WebLocks>, stylus: StylusFeed) -> Services {
let open_inbox = inbox.clone();
let picked = place_inbox.clone();
let place_ctx = ctx.clone();
@@ -468,6 +470,8 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
recovery_store: Some(Arc::new(BrowserStore { locks })),
// File → Print: the browser's print dialog.
print: Some(Box::new(BrowserPrint)),
// The pen's end, from the page's Pointer Events (`crate::pencil`).
stylus: Some(stylus),
..Default::default()
}
}
+6 -1
View File
@@ -16,6 +16,11 @@
//! ([`sync_eraser_tool`]), as the reference app's help describes under "Erase parts of a path
//! using a stylus pen":
//! <https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-parts-of-a-path.html>
//!
//! The web build writes the pen's end into a feed as well. Pointer Events report the eraser end
//! only as a press of the pen's eraser button (`button` 5), so in a browser the end changes when
//! the pen presses: a press of the eraser end selects the Eraser before that press, and a press of
//! the tip selects the tool used before.
use std::collections::VecDeque;
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
@@ -192,7 +197,7 @@ impl StylusFeed {
}
}
/// The browser's main thread can't wait, and the web build sets no feed.
/// The browser's main thread can't wait, and the web build writes its feed on that thread.
#[cfg(target_arch = "wasm32")]
fn wait_for_touch<'a>(&'a self, pen: MutexGuard<'a, Pen>, _wait: Duration) -> MutexGuard<'a, Pen> {
pen
+3
View File
@@ -443,9 +443,12 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
- **Done:** pen pressure on macOS (#852), read from AppKit's tablet events before the window gets them, each
move of a stroke with its own pressure, and the pen's eraser end there selecting the Eraser tool, as on Linux
under X11 and XWayland; not yet tried on a Mac with a tablet. On the web, a press of the pen's eraser end
selects the Eraser tool and a press of the tip brings back the tool before; not yet tried with a pen.
under X11 and XWayland; not yet tried on a Mac with a tablet. The pen's eraser end on Windows with Windows Ink,
read from the pen's pointer messages; not yet tried with a pen.
- **Missing:** pen pressure on native Wayland (#491, #764) — winit 0.30 reports none there; tilt, bearing and
barrel rotation for brushes (6D Art Pen, #372); the eraser end on Windows and native Wayland.
barrel rotation for brushes (6D Art Pen, #372); the eraser end on native Wayland and the web.
- **Estimate:** 4–10 h, partly waiting on winit 0.31; needs pen hardware to verify.
+2
View File
@@ -12,6 +12,7 @@ documentation (GPU Performance, touch workspace, pen pressure pages); ours from
**~35% (estimated), 25–45 h.** VectorCraft rasterizes the canvas on the CPU (multithreaded `vello_cpu`) and uses
the GPU only to composite, so any GPU that can show a window works, at the cost of GPU-accelerated preview
on huge documents. VectorCraft reads pen pressure on Windows, on the web, on macOS, and on Linux under X11 and XWayland, and it
reads the pen's eraser end on macOS, on Linux under X11 and XWayland, and on the web when it presses.
reads the pen's eraser end on Windows, on macOS and on Linux under X11 and XWayland.
| Feature | Illustrator | VectorCraft macOS | Windows | Linux | Web | Status / hours |
@@ -22,6 +23,7 @@ reads the pen's eraser end on Windows, on macOS and on Linux under X11 and XWayl
| 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) | yes, read from AppKit's tablet events before the window gets them (winit reports none), each move of a stroke with its own pressure, mouse event coalescing off (#852; not yet tried on a Mac) | yes (pointer force via `WM_POINTER`) | under X11 and XWayland (Preferences › Performance › Linux Display Server, #1151, #2028), read from XInput2 raw events (#764); **no** on native Wayland, where winit reports none and the pen may not reach the window (#491) | browser pointer events, with coalesced events; Apple Pencil on iPad (hover, palm rejection: fingers navigate after a pen contact until reload) | partial: 2–4 h for Wayland (winit 0.31), plus a check on a Mac |
| 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 | the eraser end (AppKit's tablet proximity events) selects the Eraser tool and the tip brings back the tool used before, as on Linux (#852; not yet tried on a Mac) | no | under X11 and XWayland, the eraser end selects the Eraser tool and the tip brings back the tool used before (#764); **no** on native Wayland | a press of the eraser end (Pointer Events eraser button, `button` 5) selects the Eraser tool, and that press erases; a press of the tip brings back the tool used before; Pointer Events report no end while the pen hovers | partial |
| Pen eraser end | yes | the eraser end (AppKit's tablet proximity events) selects the Eraser tool and the tip brings back the tool used before, as on Linux (#852; not yet tried on a Mac) | with Windows Ink, an inverted pen or its eraser button (`PEN_FLAG_INVERTED`, `PEN_FLAG_ERASER` in the pen's pointer messages) selects the Eraser tool and the tip brings back the tool used before, as on Linux (not yet tried with a pen) | under X11 and XWayland, the eraser end selects the Eraser tool and the tip brings back the tool used before (#764); **no** on native Wayland | no | partial |
| Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch, scroll and rotate | 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 touch-screen rotation missing, 3–5 h |
| Trackpad (smooth scroll, pinch) | yes | yes | precision touchpads | yes | yes | done |
+1
View File
@@ -995,6 +995,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Linux display server for pens (part of #2028):** Preferences › Performance › Linux Display Server has X11 (XWayland) When a Pen Is Attached (`x11WithPen`, off by default), which opens the window through XWayland on a Wayland session only when a pen is attached as the app starts. Under XWayland a drawing tablet's pen draws as a mouse. Below the choice, the Performance page tells users whose tablet pen doesn't draw to choose an X11 (XWayland) option.
- **Linux pen pressure (part of #764):** when the window is on X11, XWayland included, the desktop app reads a drawing tablet's pen pressure from XInput2 raw events on a second X connection, and the Paintbrush's pressure-sensitive brushes and the Liquify tools' Use Pressure Pen get it.
- **Linux pen eraser end (part of #764):** when the window is on X11, XWayland included, turning a drawing tablet's pen to its eraser end selects the Eraser tool, and turning it back to the tip selects the tool used before, unless another tool was chosen meanwhile. A turn during a press takes effect after the release, and a turn made while the pointer is over another window takes effect when it comes back over VectorCraft's window.
- **Pen eraser end on the web:** a press of a pen's eraser end, which Pointer Events report as the eraser button (`button` 5), selects the Eraser tool, and that press erases; the next press of the tip selects the tool used before, unless another tool was chosen meanwhile. Before, the browser build ignored that press.
- **Pen barrel button on Windows:** with Windows Ink, a pen that touches down with its barrel button held acts as the right mouse button until it lifts, so a tap opens the canvas context menu; on Linux the barrel button is the right button (#3659). A touch with the tip alone is the left button, with pressure, as before.
- **Pen eraser end on Windows:** with Windows Ink, turning a pen over, or holding its eraser button, selects the Eraser tool, and the tip selects the tool used before, as on Linux and macOS; the switch comes while the pen hovers, so the eraser end erases from its first touch.
- **Control transport:** idle sockets expire after 5 minutes, partial lines after an absolute 30 seconds from their first byte, and reply writes after 60 seconds (#1726). Real TCP regressions cover stalled peers and slot recovery; command execution has its separate existing timeout. MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727). MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739).