macOS: a tablet pen's pressure reaches the tools (#852)

An AppKit local event monitor (apps/vectorcraft/src/mac_tablet.rs) reads each mouse and tablet event's pressure and the pen's end before winit's view handles it and writes them into the canvas's StylusFeed. The feed keeps the pressure of each pointer move, and the canvas pairs them with the frame's moves, so a fast stroke keeps its curve and each point its own pressure; mouse event coalescing is off. The eraser end selects the Eraser tool, as on Linux. Ported from PhotoCraft's crates/tablet (pressure and eraser end only).
This commit is contained in:
bflatastic
2026-10-24 09:26:27 +07:00
parent e219543e58
commit b1e36d69f3
6 changed files with 385 additions and 33 deletions
+274
View File
@@ -0,0 +1,274 @@
//! macOS: a drawing tablet's pen pressure (#852). winit 0.30 hands a pen to the window there as a
//! mouse, without its pressure, so the app reads it from AppKit itself and writes it into the UI's
//! [`StylusFeed`] (`Services::stylus`), which the canvas reads, as on X11 ([`crate::tablet`]).
//!
//! A local event monitor (`addLocalMonitorForEventsMatchingMask:handler:`, which AppKit calls "with
//! each event it is about to dispatch" in this app:
//! <https://developer.apple.com/documentation/appkit/nsevent/addlocalmonitorforevents(matching:handler:)>)
//! reads each mouse and tablet event on the main thread before winit's view handles it, and passes
//! it on unchanged:
//! - A mouse event whose `subtype` is `NSEventSubtypeTabletPoint` comes from a pen: its `pressure`
//! (0..1) goes into the feed, kept for the pointer move winit makes of the event
//! ([`StylusFeed::set_move`]), so each move of a fast stroke has its own pressure. A mouse event
//! of any other subtype is a mouse's or a trackpad's: the feed is set to `None`.
//! - An `NSEventTypeTabletPoint` event carries the same pressure without a move.
//! - A tablet proximity event (`NSEventTypeTabletProximity`, or a mouse event of that subtype) says
//! which end of the pen came near (`pointingDeviceType`): the eraser end's samples are marked so,
//! and the canvas switches to the Eraser tool ([`vectorcraft_ui_egui::stylus`]). A puck
//! (`NSPointingDeviceTypeCursor`) is a mouse.
//!
//! The monitor also turns off mouse event coalescing (`NSEvent.mouseCoalescingEnabled`), which
//! merges the drags that queue up between two reads into one: every report of the pen arrives.
//!
//! The mapping comes from PhotoCraft's `crates/tablet` (`appkit.rs` and `macos.rs`), with the
//! pressure and the pen's end only. It is plain values, tested on every platform; the monitor
//! reads them.
#![cfg_attr(not(target_os = "macos"), allow(dead_code))]
use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
/// `NSEventType` values (AppKit headers).
mod event_type {
pub const TABLET_POINT: usize = 23;
pub const TABLET_PROXIMITY: usize = 24;
/// Left, right and other mouse down, up, moved and dragged: winit makes a pointer move of each.
pub const MOUSE: [usize; 10] = [1, 2, 3, 4, 5, 6, 7, 25, 26, 27];
}
/// `NSEventSubtype` values of mouse events.
mod subtype {
pub const TABLET_POINT: i16 = 1;
pub const TABLET_PROXIMITY: i16 = 2;
}
/// `NSPointingDeviceType` values.
mod device {
pub const CURSOR: usize = 2;
pub const ERASER: usize = 3;
}
/// The tablet fields of one `NSEvent`, as plain values.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct RawEvent {
/// `type`.
kind: usize,
/// `subtype` (mouse events only).
subtype: i16,
/// `pressure` (tablet point events and mouse events of that subtype only).
pressure: f32,
/// `pointingDeviceType` (proximity events only).
device: usize,
/// `isEnteringProximity` (proximity events only).
entering: bool,
}
impl RawEvent {
fn mouse(&self) -> bool {
event_type::MOUSE.contains(&self.kind)
}
fn proximity(&self) -> bool {
self.kind == event_type::TABLET_PROXIMITY || (self.mouse() && self.subtype == subtype::TABLET_PROXIMITY)
}
fn point(&self) -> bool {
self.kind == event_type::TABLET_POINT || (self.mouse() && self.subtype == subtype::TABLET_POINT)
}
}
/// The events the monitor reads (`NSEventMask` bits are `1 << type`).
fn event_mask() -> u64 {
event_type::MOUSE.iter().chain(&[event_type::TABLET_POINT, event_type::TABLET_PROXIMITY]).fold(0, |m, t| m | 1u64 << t)
}
/// What an event means for the feed.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Update {
/// Nothing: an event the monitor doesn't map.
Keep,
/// The pressure and end of a pen, or `None` for a mouse.
Set(Option<PenSample>),
/// A pen's sample that comes with a pointer move.
Move(PenSample),
}
impl Update {
fn apply(self, feed: &StylusFeed) {
match self {
Update::Keep => {}
Update::Set(sample) => feed.set_sample(sample),
Update::Move(sample) => feed.set_move(sample),
}
}
}
/// Which end of the pen is near the tablet.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Pen {
eraser: bool,
}
impl Pen {
fn handle(&mut self, e: &RawEvent) -> Update {
if e.proximity() {
// Entering with the tip or the eraser end: it hovers. A puck, or leaving, is a mouse.
self.eraser = e.entering && e.device == device::ERASER;
let pen = e.entering && e.device != device::CURSOR;
return Update::Set(pen.then_some(PenSample { pressure: 0.0, eraser: self.eraser }));
}
let sample = PenSample { pressure: e.pressure, eraser: self.eraser };
match (e.point(), e.mouse()) {
(true, true) => Update::Move(sample),
(true, false) => Update::Set(Some(sample)),
(false, true) => Update::Set(None),
(false, false) => Update::Keep,
}
}
}
/// Start reading pen pressure: the feed the monitor writes into, or `None` (logged) when AppKit
/// refuses the monitor or this isn't the main thread.
#[cfg(target_os = "macos")]
pub fn start() -> Option<StylusFeed> {
let feed = StylusFeed::default();
match monitor::install(feed.clone()) {
Ok(()) => Some(feed),
Err(e) => {
log::warn!("tablet pressure: {e}");
None
}
}
}
#[cfg(target_os = "macos")]
mod monitor {
use std::cell::Cell;
use std::ptr::NonNull;
use block2::RcBlock;
use objc2::MainThreadMarker;
use objc2_app_kit::{NSEvent, NSEventMask};
use vectorcraft_ui_egui::stylus::StylusFeed;
use super::{Pen, RawEvent, event_mask};
/// Install the monitor for the life of the app. Main thread only.
pub(super) fn install(feed: StylusFeed) -> Result<(), &'static str> {
MainThreadMarker::new().ok_or("the event monitor can only be installed from the main thread")?;
let pen = Cell::new(Pen::default());
let handler = RcBlock::new(move |event: NonNull<NSEvent>| -> *mut NSEvent {
// SAFETY: AppKit calls the handler with the event it is about to dispatch, which it
// keeps alive while the handler runs.
#[allow(unsafe_code)]
let e = unsafe { event.as_ref() };
let mut p = pen.get();
p.handle(&read(e)).apply(&feed);
pen.set(p);
event.as_ptr()
});
// SAFETY: the handler returns the event it was given, a valid pointer ("the block's return
// must be a valid pointer or null"). AppKit copies the block and keeps it while the monitor
// is installed.
#[allow(unsafe_code)]
let token = unsafe { NSEvent::addLocalMonitorForEventsMatchingMask_handler(NSEventMask(event_mask()), &handler) };
// The monitor stays until the app quits: its token is never handed to `removeMonitor:`.
std::mem::forget(token.ok_or("AppKit refused the event monitor")?);
NSEvent::setMouseCoalescingEnabled(false);
Ok(())
}
/// The tablet fields of `e`. `subtype` raises an exception for events without one, and the
/// pressure and proximity fields are read only for the events that define them.
fn read(e: &NSEvent) -> RawEvent {
let mut raw = RawEvent { kind: e.r#type().0, ..Default::default() };
if raw.mouse() {
raw.subtype = e.subtype().0;
}
if raw.point() {
raw.pressure = e.pressure();
}
if raw.proximity() {
raw.device = e.pointingDeviceType().0;
raw.entering = e.isEnteringProximity();
}
raw
}
}
#[cfg(test)]
mod tests {
use super::*;
const DRAG: usize = 6;
const KEY_DOWN: usize = 10;
fn pen(kind: usize, pressure: f32) -> RawEvent {
RawEvent { kind, subtype: subtype::TABLET_POINT, pressure, ..Default::default() }
}
fn proximity(device: usize, entering: bool) -> RawEvent {
RawEvent { kind: event_type::TABLET_PROXIMITY, device, entering, ..Default::default() }
}
fn tip(pressure: f32) -> PenSample {
PenSample { pressure, eraser: false }
}
#[test]
fn a_pens_mouse_events_are_moves_with_its_pressure() {
let mut p = Pen::default();
assert_eq!(p.handle(&pen(DRAG, 0.5)), Update::Move(tip(0.5)));
assert_eq!(p.handle(&pen(1, 0.25)), Update::Move(tip(0.25)), "a press");
assert_eq!(p.handle(&pen(event_type::TABLET_POINT, 0.8)), Update::Set(Some(tip(0.8))), "no move");
}
#[test]
fn other_mouse_events_are_a_mouse_and_other_events_are_kept() {
let mut p = Pen::default();
for subtype in [0, 3, 99, -1] {
let e = RawEvent { kind: DRAG, subtype, pressure: 1.0, ..Default::default() };
assert_eq!(p.handle(&e), Update::Set(None), "subtype {subtype}");
}
for kind in [0, KEY_DOWN, 22, 29, 34, usize::MAX] {
// Other events' subtypes don't make them a pen's.
let e = RawEvent { kind, subtype: subtype::TABLET_POINT, ..Default::default() };
assert_eq!(p.handle(&e), Update::Keep, "type {kind}");
}
}
#[test]
fn proximity_says_which_end_is_near() {
let mut p = Pen::default();
let eraser = |pressure| PenSample { pressure, eraser: true };
assert_eq!(p.handle(&proximity(device::ERASER, true)), Update::Set(Some(eraser(0.0))));
assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(eraser(0.4)));
assert_eq!(p.handle(&proximity(device::ERASER, false)), Update::Set(None), "it left");
assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(tip(0.4)));
assert_eq!(p.handle(&proximity(1, true)), Update::Set(Some(tip(0.0))), "the tip");
assert_eq!(p.handle(&proximity(0, true)), Update::Set(Some(tip(0.0))), "an unknown tool draws as the tip");
assert_eq!(p.handle(&proximity(device::CURSOR, true)), Update::Set(None), "a puck is a mouse");
let e = RawEvent { kind: 5, subtype: subtype::TABLET_PROXIMITY, device: device::ERASER, entering: true, ..Default::default() };
assert_eq!(p.handle(&e), Update::Set(Some(eraser(0.0))), "as a mouse event's subtype");
}
#[test]
fn the_feed_gets_each_moves_pressure_within_0_to_1() {
let feed = StylusFeed::default();
let mut p = Pen::default();
for e in [pen(DRAG, f32::NAN), pen(DRAG, 4.0), pen(DRAG, -1.0), pen(event_type::TABLET_POINT, 0.3)] {
p.handle(&e).apply(&feed);
}
assert_eq!((feed.take_moves(), feed.held()), (vec![1.0, 1.0, 0.0], Some(0.3)));
p.handle(&RawEvent { kind: DRAG, ..Default::default() }).apply(&feed);
assert_eq!(feed.held(), None, "a mouse moved");
}
#[test]
fn the_mask_covers_the_mapped_events() {
let m = event_mask();
for t in [1, 2, 3, 4, 5, 6, 7, 23, 24, 25, 26, 27] {
assert!(m & (1 << t) != 0, "{t}");
}
assert_eq!(m & (1 << KEY_DOWN), 0, "keys are not read");
}
}
+4
View File
@@ -41,6 +41,7 @@ mod mac_menu;
mod mac_save_format;
#[cfg(target_os = "macos")]
mod mac_screen_color;
mod mac_tablet;
#[cfg(target_os = "macos")]
mod mac_window;
#[cfg(target_os = "macos")]
@@ -580,6 +581,9 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
no_cursor_theme: wayland && cursor_theme::theme_missing(),
#[cfg(target_os = "linux")]
stylus: if x11 { tablet::start() } else { None },
// macOS reads it from AppKit's events (#852).
#[cfg(target_os = "macos")]
stylus: mac_tablet::start(),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
+42 -20
View File
@@ -194,21 +194,30 @@ fn pen_pressure(ui: &Ui, app: &VectorcraftApp, pressed: bool) -> f32 {
}
/// 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>)> {
/// pen force a touch event gave just before it, or the pressure the host's feed kept for that move
/// ([`StylusFeed::take_moves`], macOS), if any. The last move is the pointer's position. Only for
/// pens and fingers (frames with touch events or with the feed's moves): a mouse's moves are left
/// as they were.
fn frame_moves(ui: &Ui, stylus: Option<&StylusFeed>) -> Vec<(Pos2, Option<f32>)> {
let pressures = stylus.map(StylusFeed::take_moves).unwrap_or_default();
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)),
_ => {}
if i.events.iter().any(|e| matches!(e, egui::Event::Touch { .. })) {
let mut force = None;
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)),
_ => {}
}
}
} else if !pressures.is_empty() {
// The feed's last pressure is the last move's: pair them from the end. A lift's 0 is
// no pressure (the press keeps its last touch).
let points: Vec<Pos2> = i.events.iter().filter_map(|e| if let egui::Event::PointerMoved(p) = e { Some(*p) } else { None }).collect();
let forces = pressures.iter().rev().map(|f| (*f > 0.0).then_some(*f)).chain(std::iter::repeat(None));
moves = points.into_iter().rev().zip(forces).collect();
moves.reverse();
}
moves.pop();
// A burst of events (a stalled frame) is thinned to a bounded count.
@@ -1157,11 +1166,12 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
}
Drag::Tool => {
if tool_drag_moved(ui, &xf, p, &pointer) {
// In a frame with touch events (a pen on Windows or the web, a finger), every
// position the pen passed, not just the last: a fast stroke keeps its curve (a
// pen reports far more often than frames come). Mouse events, a pen's on X11
// among them, give the last position only, with the host's latest pressure.
for (at, force) in frame_moves(ui) {
// In a frame with touch events (a pen on Windows or the web, a finger) or a
// macOS pen's moves, every position the pen passed, not just the last: a fast
// stroke keeps its curve (a pen reports far more often than frames come).
// Mouse events, a pen's on X11 among them, give the last position only, with
// the host's latest pressure.
for (at, force) in frame_moves(ui, app.services.stylus.as_ref()) {
let pressure = force.unwrap_or_else(|| pen_pressure(ui, app, false));
dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure }, view);
}
@@ -8690,16 +8700,28 @@ mod tests {
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));
let mut out = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| mouse = frame_moves(ui, None));
out.textures_delta.clear();
assert!(mouse.is_empty());
// A macOS pen: the feed kept a pressure for each move, the last for the last.
let feed = StylusFeed::default();
for p in [0.9, 0.3, 0.0, 0.5] {
feed.set_move(crate::stylus::PenSample { pressure: p, eraser: false });
}
let mut samples = vec![];
let mut out = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| samples = frame_moves(ui, Some(&feed)));
out.textures_delta.clear();
assert_eq!(samples, [(a, Some(0.3)), (b, None)], "paired from the end; a lift's 0 is no pressure");
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, Some(&feed)));
out.textures_delta.clear();
assert!(samples.is_empty(), "the moves were taken: a mouse's moves again");
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));
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, None));
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));
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, None));
out.textures_delta.clear();
assert!(samples.len() <= 64, "a stalled frame stays bounded");
}
+50 -2
View File
@@ -4,7 +4,10 @@
//! web build makes the same touch events from the browser's Pointer Events. On Linux under X11
//! (XWayland included), winit reports a pen as a mouse, so the desktop app reads the pressure
//! from XInput2 on a connection of its own and writes it into a [`StylusFeed`]
//! ([`crate::Services::stylus`]). The canvas uses it when a frame's events carry no touch force.
//! ([`crate::Services::stylus`]). On macOS, where winit reports none either, the app reads each
//! event's pressure from AppKit before the window handles it, and also keeps it for that event's
//! pointer move ([`StylusFeed::set_move`]), so the moves of a fast stroke each get their own. The
//! canvas uses the feed when a frame's events carry no touch force.
//!
//! The feed also says which end of the pen moved. Turning the pen to its eraser end selects the
//! Eraser tool, and turning it back to the tip selects the tool used before
@@ -12,6 +15,7 @@
//! using a stylus pen":
//! <https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-parts-of-a-path.html>
use std::collections::VecDeque;
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
use std::time::Duration;
@@ -23,6 +27,10 @@ const ERASER: &str = "eraser";
/// How long a press waits for the touch of a pen that hovers ([`StylusFeed::press`]).
const PRESS_WAIT: Duration = Duration::from_millis(5);
/// The pressures of pointer moves kept for the UI ([`StylusFeed::set_move`]); a frame replays 64
/// moves at most.
const MAX_MOVES: usize = 256;
/// What a pen reports as it moves.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PenSample {
@@ -33,7 +41,7 @@ pub struct PenSample {
}
/// What the feed holds.
#[derive(Clone, Copy, Debug, Default)]
#[derive(Clone, Debug, Default)]
struct Pen {
/// The pressure of the pen when a pen was the last pointer to move.
latest: Option<f32>,
@@ -45,6 +53,8 @@ struct Pen {
/// The last pressure above 0 set since a pen was the last pointer to move, with the same end
/// (0 when that end has not touched).
touched: f32,
/// The pressure of each pointer move set since the UI last took them, oldest first.
moves: VecDeque<f32>,
}
impl Pen {
@@ -99,6 +109,7 @@ impl StylusFeed {
None => {
pen.peak = None;
pen.touched = 0.0;
pen.moves.clear();
}
}
pen.latest = pressure;
@@ -106,6 +117,25 @@ impl StylusFeed {
self.0.set.notify_all();
}
/// Like [`Self::set_sample`], for a pen sample that comes with one pointer move of the window,
/// read before the window handles the move (macOS): its pressure is also kept for that move
/// ([`Self::take_moves`]).
pub fn set_move(&self, sample: PenSample) {
self.set_sample(Some(sample));
let mut pen = self.lock();
if pen.moves.len() >= MAX_MOVES {
pen.moves.pop_front();
}
let pressure = pen.latest.unwrap_or(1.0);
pen.moves.push_back(pressure);
}
/// The pressures of the pointer moves set with [`Self::set_move`] since the last call, oldest
/// first (256 at most): the last one is the latest move's.
pub fn take_moves(&self) -> Vec<f32> {
self.lock().moves.drain(..).collect()
}
/// The pressure of a press the UI holds (a drag and its release) when a pen was the last
/// pointer to move, otherwise `None`: the latest pressure, or, when the pen has lifted (0), the
/// last pressure it touched with. The lift can reach the feed before the UI handles the last
@@ -254,6 +284,24 @@ mod tests {
assert_eq!(feed.eraser(), None, "a mouse moved");
}
#[test]
fn the_feed_keeps_the_pressure_of_each_move() {
let feed = StylusFeed::default();
for p in [0.2, 1.5, 0.4] {
feed.set_move(PenSample { pressure: p, eraser: false });
}
feed.set(Some(0.9));
assert_eq!(feed.take_moves(), [0.2, 1.0, 0.4], "clamped; a sample without a move keeps none");
assert_eq!((feed.take_moves(), feed.held()), (vec![], Some(0.9)), "taken once");
for _ in 0..MAX_MOVES + 10 {
feed.set_move(PenSample { pressure: 0.5, eraser: false });
}
assert_eq!(feed.take_moves().len(), MAX_MOVES, "bounded");
feed.set_move(PenSample { pressure: 0.5, eraser: false });
feed.set(None);
assert!(feed.take_moves().is_empty(), "a mouse moved");
}
#[test]
fn a_held_press_keeps_the_last_touch_when_the_lift_comes_first() {
let feed = StylusFeed::default();
+9 -6
View File
File diff suppressed because one or more lines are too long
+6 -5
View File
@@ -1,6 +1,6 @@
# Hardware parity
> **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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (pen pressure and the pen's eraser end on macOS, read from AppKit's tablet events, #852, not yet tried on a Mac; previously: 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
@@ -11,8 +11,8 @@ 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, and on Linux under X11 and XWayland, and it
reads the pen's eraser end on Linux under X11 and XWayland only.
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 and on Linux under X11 and XWayland.
| Feature | Illustrator | VectorCraft macOS | Windows | Linux | Web | Status / hours |
|---|---|---|---|---|---|---|
@@ -20,9 +20,9 @@ reads the pen's eraser end on Linux under X11 and XWayland only.
| 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`) | 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: 4–8 h for macOS and Wayland (winit 0.31) |
| 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 | no | 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 | no | 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) | 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 | 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 |
| 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) |
@@ -34,6 +34,7 @@ reads the pen's eraser end on Linux under X11 and XWayland only.
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | macOS: an AppKit local event monitor reads each mouse and tablet event's pen pressure and the pen's end before the window gets it and writes them into the canvas's pen feed (#852): each pointer move of a stroke keeps its own pressure, mouse event coalescing is off, and the eraser end selects the Eraser tool. The mapping is unit-tested on every platform and the macOS code type-checks for `aarch64-apple-darwin`; not yet tried on a Mac with a tablet, so the score is 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) |