mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user