mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #3932 from storytold/feat/852-macos-pen-pressure
macOS: a tablet pen's pressure reaches the tools (#852)
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
//! 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> {
|
||||
// The monitor writes on the main thread before winit hands the event on: a press never waits.
|
||||
let feed = StylusFeed::on_ui_thread();
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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())),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -59,6 +69,8 @@ struct Shared {
|
||||
pen: Mutex<Pen>,
|
||||
/// Signaled on every [`StylusFeed::set_sample`] ([`StylusFeed::set`] included).
|
||||
set: Condvar,
|
||||
/// Written on the UI thread itself ([`StylusFeed::on_ui_thread`]): a press never waits.
|
||||
ui_thread: bool,
|
||||
}
|
||||
|
||||
/// The pen pressure and pen end the host last read, written from any thread and read on the UI
|
||||
@@ -67,6 +79,12 @@ struct Shared {
|
||||
pub struct StylusFeed(Arc<Shared>);
|
||||
|
||||
impl StylusFeed {
|
||||
/// A feed the host writes on the UI thread, before the UI handles the event it read (macOS):
|
||||
/// a press has its touch already, and waiting for one would only stall the thread.
|
||||
pub fn on_ui_thread() -> Self {
|
||||
Self(Arc::new(Shared { ui_thread: true, ..Shared::default() }))
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, Pen> {
|
||||
self.0.pen.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
@@ -99,6 +117,7 @@ impl StylusFeed {
|
||||
None => {
|
||||
pen.peak = None;
|
||||
pen.touched = 0.0;
|
||||
pen.moves.clear();
|
||||
}
|
||||
}
|
||||
pen.latest = pressure;
|
||||
@@ -106,6 +125,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
|
||||
@@ -130,7 +168,7 @@ impl StylusFeed {
|
||||
|
||||
fn press_within(&self, wait: Duration) -> Option<f32> {
|
||||
let mut pen = self.lock();
|
||||
if pen.hovering() {
|
||||
if pen.hovering() && !self.0.ui_thread {
|
||||
pen = self.wait_for_touch(pen, wait);
|
||||
}
|
||||
let peak = pen.peak.take();
|
||||
@@ -254,6 +292,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();
|
||||
@@ -337,4 +393,15 @@ mod tests {
|
||||
assert_eq!(feed.press_within(Duration::from_secs(10)), None);
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
|
||||
/// A feed written on the UI thread (macOS) has nothing to wait for: a hovering pen's press
|
||||
/// gets its 0 at once rather than stalling the thread.
|
||||
#[test]
|
||||
fn a_feed_written_on_the_ui_thread_never_waits() {
|
||||
let feed = StylusFeed::on_ui_thread();
|
||||
feed.set(Some(0.0));
|
||||
let start = std::time::Instant::now();
|
||||
assert_eq!(feed.press_within(Duration::from_secs(10)), Some(0.0));
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
|
||||
+9
-6
File diff suppressed because one or more lines are too long
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user