Stylus pressure, tilt, rotation and eraser on macOS and X11 (#255) (#255)

This commit is contained in:
Brandon Thomas 2026-10-05 17:47:59 -04:00 committed by GitHub
parent 02c07eb021
commit 47f4abfed4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1411 additions and 32 deletions

View File

@ -23,6 +23,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
crates/
geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles)
psd codecs L0 standalone format crates (no workspace deps; publishable)
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate
doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data)
ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes
compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format
@ -46,7 +47,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | t
People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`.
- **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`.
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`.
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only its `src/macos.rs` allows `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
- **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input.
- **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic).
- **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s.

13
Cargo.lock generated
View File

@ -3404,6 +3404,7 @@ dependencies = [
"photocraft-geom",
"photocraft-io",
"photocraft-raster",
"photocraft-tablet",
"photocraft-ui-egui",
"rfd",
"serde_json",
@ -3673,6 +3674,18 @@ dependencies = [
"rayon",
]
[[package]]
name = "photocraft-tablet"
version = "0.2.0"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"x11rb",
]
[[package]]
name = "photocraft-testkit"
version = "0.2.0"

View File

@ -33,6 +33,7 @@ photocraft-plugins = { path = "crates/plugins" }
photocraft-engine = { path = "crates/engine" }
photocraft-ui-egui = { path = "crates/ui-egui" }
photocraft-testkit = { path = "crates/testkit" }
photocraft-tablet = { path = "crates/tablet" }
# external
thiserror = "2"

View File

@ -69,7 +69,7 @@ pub fn start() {
});
}
/// Pen pressure, tilt and twist from Pointer Events (eframe forwards none of them for pens) into
/// Pen pressure, tilt, twist and the eraser button from Pointer Events (eframe forwards none of them for pens) into
/// the app's stylus feed. The sample is kept through `pointerup` so the stroke's last points keep
/// their pressure; hovering, a mouse, or leaving the canvas clears it.
fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::stylus::StylusFeed) {
@ -83,7 +83,15 @@ fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::sty
return;
}
let pen = e.pointer_type() == "pen" && e.buttons() != 0 && ty != "pointercancel" && ty != "pointerleave";
feed.set(pen.then(|| PenSample { pressure: e.pressure(), tilt_x: e.tilt_x() as f32, tilt_y: e.tilt_y() as f32, rotation: e.twist() as f32 }));
// W3C Pointer Events: `buttons` bit 5 (32) is the pen's eraser.
let eraser = e.buttons() & 32 != 0;
feed.set(pen.then(|| PenSample {
pressure: e.pressure(),
tilt_x: e.tilt_x() as f32,
tilt_y: e.tilt_y() as f32,
rotation: e.twist() as f32,
eraser,
}));
});
if target.add_event_listener_with_callback(kind, cb.as_ref().unchecked_ref()).is_ok() {
cb.forget();

View File

@ -22,6 +22,8 @@ photocraft-io = { workspace = true }
photocraft-format = { workspace = true }
photocraft-automation = { workspace = true }
photocraft-ui-egui = { workspace = true }
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
photocraft-tablet = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }

View File

@ -27,6 +27,9 @@ mod gpu_startup;
mod linux_libs;
mod monitor_profile;
mod services;
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
#[cfg(any(target_os = "macos", target_os = "linux", test))]
mod tablet;
use photocraft_engine::Session;
use photocraft_ui_egui::PhotocraftApp;
@ -114,6 +117,13 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
// Pen tablet samples on macOS (AppKit event monitor, before winit sees each event) and X11
// (started once eframe says which display server it is on). The monitor lives until the event
// loop returns.
let stylus_feed = photocraft_ui_egui::stylus::StylusFeed::default();
#[cfg(target_os = "macos")]
let _tablet = tablet::install_macos(&stylus_feed);
// Read the main display's ICC profile while the window opens (colour-managed canvas).
let monitor = monitor_profile::detect_async();
// Brush presets load in the background; the app attaches them when they arrive.
@ -215,6 +225,11 @@ fn main() -> eframe::Result {
{
app.services.os_events = Some(apple_events.connect(&cc.egui_ctx));
}
// Tablet pressure/tilt/eraser (winit drops them): the macOS monitor installed above
// and the X11 reader write into this feed.
app.stylus.feed = stylus_feed;
#[cfg(target_os = "linux")]
tablet::spawn_x11(&app.stylus.feed, tablet::DisplayKind::of(cc));
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
app.open_paths(&files);
Ok(Box::new(app))

View File

@ -0,0 +1,201 @@
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79).
//!
//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit
//! local event monitor; XInput2 raw events on a second X connection) and this module writes each
//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards
//! `WM_POINTER` pressure as touch force. Wayland has no tablet input yet (see the crate docs).
use photocraft_tablet::Sample;
use photocraft_ui_egui::stylus::{PenSample, StylusFeed};
/// A tablet sample as the UI's pen sample.
pub fn pen_sample(s: Sample) -> PenSample {
PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized()
}
/// Writes tablet samples into `feed` (`None` = a mouse).
pub fn sink(feed: StylusFeed) -> impl Fn(Option<Sample>) + Send + 'static {
move |s| feed.set(s.map(pen_sample))
}
/// Install the AppKit tablet monitor (main thread, before the event loop). Keep the result until
/// the event loop returns. On failure this logs why: pens then paint like a mouse.
#[cfg(target_os = "macos")]
pub fn install_macos(feed: &StylusFeed) -> Option<photocraft_tablet::macos::Monitor> {
photocraft_tablet::macos::Monitor::install(sink(feed.clone())).map_err(|e| log::warn!("{e}")).ok()
}
/// Start the XInput2 reader when eframe runs on X11. On Wayland, `$DISPLAY` is Xwayland, which
/// sees none of this window's input (and a pen sample from another X app would outlive a mouse
/// stroke here), so nothing starts.
#[cfg(target_os = "linux")]
pub fn spawn_x11(feed: &StylusFeed, display: Option<DisplayKind>) {
if display != Some(DisplayKind::X11) {
log::info!("tablet pressure: not available on this display server ({display:?}); Wayland is a follow-up to #79");
return;
}
if let Err(e) = photocraft_tablet::x11::spawn(None, sink(feed.clone())) {
log::warn!("{e}");
}
}
/// The windowing system eframe ended up on.
#[cfg(target_os = "linux")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DisplayKind {
X11,
Wayland,
Other,
}
#[cfg(target_os = "linux")]
impl DisplayKind {
/// From the creation context's display handle.
pub fn of(cc: &eframe::CreationContext<'_>) -> Option<Self> {
use eframe::wgpu::rwh::{HasDisplayHandle, RawDisplayHandle};
Some(match cc.display_handle().ok()?.as_raw() {
RawDisplayHandle::Xlib(_) | RawDisplayHandle::Xcb(_) => DisplayKind::X11,
RawDisplayHandle::Wayland(_) => DisplayKind::Wayland,
_ => DisplayKind::Other,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_tablet::{Update, appkit, xi};
use photocraft_ui_egui::PhotocraftApp;
use photocraft_ui_egui::canvas::{ToolEvent, tool_event};
use photocraft_ui_egui::state::Tool;
use serde_json::{Value, json};
#[test]
fn samples_map_and_sanitize() {
let p = pen_sample(Sample { pressure: 0.25, tilt_x: 30.0, tilt_y: -10.0, rotation: 90.0, eraser: true });
assert_eq!(p, PenSample { pressure: 0.25, tilt_x: 30.0, tilt_y: -10.0, rotation: 90.0, eraser: true });
let p = pen_sample(Sample { pressure: f32::NAN, tilt_x: 500.0, tilt_y: f32::NEG_INFINITY, rotation: -45.0, eraser: false });
assert_eq!(p, PenSample { pressure: 1.0, tilt_x: 90.0, tilt_y: 0.0, rotation: 315.0, eraser: false });
let feed = StylusFeed::default();
let f = sink(feed.clone());
f(Some(Sample { pressure: 0.5, ..Default::default() }));
assert_eq!(feed.get().map(|p| p.pressure), Some(0.5));
f(None);
assert_eq!(feed.get(), None);
}
/// The platform path end to end, minus the OS: raw AppKit / XInput2 values → the crate's
/// mapping → this module's sink → the stylus feed → the canvas's tool events → `paint.stroke`.
/// A light-to-heavy pen stroke must paint thin and faint at the light end and wide and opaque
/// at the heavy end (Size and Opacity on Pen Pressure); a mouse paints full strength.
fn pressure_stroke(mut next: impl FnMut(f32) -> Option<Sample>) -> (f64, f64, f64, f64) {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
let feed = app.stylus.feed.clone();
let sink = sink(feed);
app.session.execute("file.new", json!({"width": 240, "height": 80, "background": "white"})).unwrap();
app.session.execute("tools.setColors", json!({"foreground": [0.0, 0.0, 0.0, 1.0]})).unwrap();
let brush = json!({"size": 30, "hardness": 1.0, "spacing": 0.05, "pressureSize": true, "pressureOpacity": true, "smoothing": {"amount": 0}});
app.session.execute("tools.setBrush", json!({ "brush": brush })).unwrap();
app.ui.tool = Tool::Brush;
let m = egui::Modifiers::NONE;
let n = 20;
for i in 0..=n {
let t = i as f32 / n as f32;
sink(next(0.1 + 0.9 * t));
let (x, y) = (20.0 + 200.0 * f64::from(t), 40.0);
let pressure = app.stylus.pressure();
let ev = if i == 0 { ToolEvent::Down { x, y, pressure } } else { ToolEvent::Move { x, y, pressure } };
tool_event(&mut app, ev, m);
}
tool_event(&mut app, ToolEvent::Up { x: 220.0, y: 40.0 }, m);
sink(None);
// Darkness (1 - luma) along x = 50 (light end) and x = 200 (heavy end), and the painted
// half-width there.
let mut dark = |x: i32, y: i32| -> f64 {
let px: Value = app.session.execute("document.pixel", json!({"x": x, "y": y})).unwrap();
1.0 - px[0].as_f64().unwrap()
};
let (light, heavy) = (dark(50, 40), dark(200, 40));
let light_w = (0..40).take_while(|d| dark(50, 40 + d) > 0.02).count() as f64;
let heavy_w = (0..40).take_while(|d| dark(200, 40 + d) > 0.02).count() as f64;
(light, light_w, heavy, heavy_w)
}
#[test]
fn appkit_tablet_pressure_drives_stroke_width_and_opacity() {
let mut st = appkit::State::default();
let (light_dark, light_w, heavy_dark, heavy_w) = pressure_stroke(|p| {
let e =
appkit::RawEvent { kind: appkit::event_type::LEFT_MOUSE_DRAGGED, subtype: appkit::subtype::TABLET_POINT, pressure: p, ..Default::default() };
match st.handle(&e) {
Update::Set(s) => s,
Update::Keep => None,
}
});
assert!(heavy_w > light_w * 2.0, "width responds: {light_w} → {heavy_w}");
assert!(heavy_dark > light_dark + 0.3, "opacity responds: {light_dark} → {heavy_dark}");
assert!(heavy_dark > 0.9, "full pressure paints (nearly) opaque: {heavy_dark}");
// A mouse (non-tablet subtype) through the same path: full size and opacity throughout.
let mut st = appkit::State::default();
let (m_dark, m_w, _, _) = pressure_stroke(|p| {
let e = appkit::RawEvent { kind: appkit::event_type::LEFT_MOUSE_DRAGGED, subtype: appkit::subtype::MOUSE, pressure: p, ..Default::default() };
match st.handle(&e) {
Update::Set(s) => s,
Update::Keep => None,
}
});
assert!(m_dark > 0.9 && m_w >= heavy_w - 1.0, "mouse = pressure 1: {m_dark}, {m_w}");
}
#[test]
fn xinput_tablet_pressure_drives_stroke_width_and_opacity() {
let mut st = xi::State::default();
st.set_devices([xi::Device::new(9, "Wacom Intuos Pen stylus", [(2, "Abs Pressure".to_string(), 0.0, 65535.0)])]);
let (light_dark, light_w, heavy_dark, heavy_w) = pressure_stroke(|p| match st.handle(9, [(2, f64::from(p) * 65535.0)]) {
Update::Set(s) => s,
Update::Keep => None,
});
assert!(heavy_w > light_w * 2.0, "width responds: {light_w} → {heavy_w}");
assert!(heavy_dark > light_dark + 0.3, "opacity responds: {light_dark} → {heavy_dark}");
}
#[test]
fn use_tablet_pressure_off_paints_like_a_mouse() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.session.execute("prefs.set", json!({"path": "tools.useTabletPressure", "value": false})).unwrap();
// The canvas copies the preference each frame; do what it does.
app.stylus.use_pressure = app.session.prefs().tools.use_tablet_pressure;
sink(app.stylus.feed.clone())(Some(Sample { pressure: 0.2, tilt_x: 40.0, ..Default::default() }));
assert_eq!(app.stylus.pressure(), 1.0);
assert_eq!(app.stylus.sample(), None);
}
#[test]
fn eraser_end_switches_to_the_eraser_and_back() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
let sink = sink(app.stylus.feed.clone());
let mut st = appkit::State::default();
let mut send = |e: appkit::RawEvent| {
if let Update::Set(s) = st.handle(&e) {
sink(s);
}
};
let prox = |device, entering| appkit::RawEvent { kind: appkit::event_type::TABLET_PROXIMITY, device, entering, ..Default::default() };
app.ui.tool = Tool::Brush;
send(prox(appkit::device::PEN, true));
assert!(!photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
assert_eq!(app.ui.tool, Tool::Brush, "the tip changes nothing");
send(prox(appkit::device::PEN, false));
send(prox(appkit::device::ERASER, true));
assert!(photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
assert_eq!(app.ui.tool, Tool::Eraser);
// Mouse moves in between change nothing; the tip coming back restores the Brush.
send(appkit::RawEvent { kind: appkit::event_type::MOUSE_MOVED, ..Default::default() });
assert!(!photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
send(prox(appkit::device::PEN, true));
assert!(photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
assert_eq!(app.ui.tool, Tool::Brush);
}
}

View File

@ -268,6 +268,8 @@ pub struct Tools {
pub double_click_layer_mask_launches_select_and_mask: bool,
/// What the right mouse button does on the canvas with the Brush and other painting tools.
pub right_click_with_painting_tools: RightClickPaint,
/// Pen tablets: pressure, tilt and rotation reach the brush (off: a pen paints like a mouse).
pub use_tablet_pressure: bool,
}
impl Default for Tools {
@ -283,6 +285,7 @@ impl Default for Tools {
overscroll: true,
double_click_layer_mask_launches_select_and_mask: true,
right_click_with_painting_tools: RightClickPaint::BrushPicker,
use_tablet_pressure: true,
}
}
}

37
crates/tablet/Cargo.toml Normal file
View File

@ -0,0 +1,37 @@
[package]
name = "photocraft-tablet"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Pen tablet pressure, tilt, rotation and eraser end on macOS (AppKit) and Linux X11 (XInput2)"
# The one isolated `unsafe` helper crate (craftrules never-crash, AGENTS.md): the AppKit event
# monitor needs Objective-C interop. Only `src/macos.rs` allows `unsafe`; everything else is
# denied, and every other crate keeps the workspace's `unsafe_code = "forbid"`.
[lints.rust]
unsafe_code = "deny"
[lints.clippy]
dbg_macro = "warn"
todo = "warn"
# Already in Cargo.lock (eframe/wgpu/fmv-macos-events and winit pull them in); pinned exactly:
# review the diff before bumping.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "=0.6.4"
block2 = "=0.6.2"
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSGeometry"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "block2", "NSEvent"] }
# Pure-Rust X11 client (no unsafe in the RustConnection path we use).
[target.'cfg(target_os = "linux")'.dependencies]
x11rb = { version = "=0.13.2", default-features = false, features = ["xinput"] }
# Tests build real tablet NSEvents (from CGEvents) and read them through the monitor's path.
[target.'cfg(target_os = "macos")'.dev-dependencies]
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "block2", "NSEvent", "NSGraphicsContext", "objc2-core-graphics", "NSApplication", "NSResponder"] }
objc2-core-graphics = { version = "=0.3.2", default-features = false, features = ["std", "CGEvent", "CGEventTypes"] }
[[test]]
name = "monitor_macos"
harness = false

225
crates/tablet/src/appkit.rs Normal file
View File

@ -0,0 +1,225 @@
//! AppKit `NSEvent` tablet fields → [`Sample`]s. Pure (no AppKit calls), so it is tested on
//! every platform; `macos.rs` reads the fields and feeds them here.
//!
//! - Mouse down/up/dragged/moved events whose `subtype` is `NSEventSubtypeTabletPoint` come from
//! a pen: `pressure` (0..1), `tilt` (-1..1 per axis) and `rotation` (degrees) are valid.
//! A mouse event with any other subtype is a mouse or trackpad: back to `None`.
//! - `NSEventTypeTabletPoint` events carry the same fields without a mouse event.
//! - `NSEventTypeTabletProximity` events (and mouse events with the proximity subtype) say a
//! tool entered or left proximity and which one (`pointingDeviceType`: pen, eraser, cursor).
use crate::{Sample, Update};
/// `NSEventType` values (AppKit headers; stable since 10.0/10.4).
pub mod event_type {
pub const LEFT_MOUSE_DOWN: usize = 1;
pub const LEFT_MOUSE_UP: usize = 2;
pub const RIGHT_MOUSE_DOWN: usize = 3;
pub const RIGHT_MOUSE_UP: usize = 4;
pub const MOUSE_MOVED: usize = 5;
pub const LEFT_MOUSE_DRAGGED: usize = 6;
pub const RIGHT_MOUSE_DRAGGED: usize = 7;
pub const TABLET_POINT: usize = 23;
pub const TABLET_PROXIMITY: usize = 24;
pub const OTHER_MOUSE_DOWN: usize = 25;
pub const OTHER_MOUSE_UP: usize = 26;
pub const OTHER_MOUSE_DRAGGED: usize = 27;
}
/// `NSEventSubtype` values of mouse events.
pub mod subtype {
pub const MOUSE: i16 = 0;
pub const TABLET_POINT: i16 = 1;
pub const TABLET_PROXIMITY: i16 = 2;
pub const TOUCH: i16 = 3;
}
/// `NSPointingDeviceType` values.
pub mod device {
pub const UNKNOWN: usize = 0;
pub const PEN: usize = 1;
pub const CURSOR: usize = 2;
pub const ERASER: usize = 3;
}
/// AppKit reports tilt as -1..1 of the device's range. Wacom pens report about ±60° at the
/// extremes (Qt maps AppKit tilt the same way).
pub const TILT_DEGREES: f64 = 60.0;
/// The tablet-related fields of one `NSEvent`, read as plain values.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct RawEvent {
/// `NSEventType`.
pub kind: usize,
/// `subtype` (only meaningful for mouse events).
pub subtype: i16,
pub pressure: f32,
/// `tilt` (x, y), each -1..1.
pub tilt: (f64, f64),
/// `rotation`, degrees.
pub rotation: f32,
/// `pointingDeviceType` (proximity events only).
pub device: usize,
/// `isEnteringProximity` (proximity events only).
pub entering: bool,
}
/// The mask of event types [`State::handle`] maps (`NSEventMask` bits are `1 << type`).
pub fn event_mask() -> u64 {
use event_type::*;
[
LEFT_MOUSE_DOWN,
LEFT_MOUSE_UP,
RIGHT_MOUSE_DOWN,
RIGHT_MOUSE_UP,
MOUSE_MOVED,
LEFT_MOUSE_DRAGGED,
RIGHT_MOUSE_DRAGGED,
TABLET_POINT,
TABLET_PROXIMITY,
OTHER_MOUSE_DOWN,
OTHER_MOUSE_UP,
OTHER_MOUSE_DRAGGED,
]
.iter()
.fold(0u64, |m, t| m | 1u64.checked_shl(*t as u32).unwrap_or(0))
}
/// Pen state across events: which end is in proximity.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct State {
/// The tool in proximity is the eraser end.
pub eraser: bool,
}
impl State {
/// Map one event.
pub fn handle(&mut self, e: &RawEvent) -> Update {
use event_type::*;
let mouse = matches!(
e.kind,
LEFT_MOUSE_DOWN
| LEFT_MOUSE_UP
| RIGHT_MOUSE_DOWN
| RIGHT_MOUSE_UP
| MOUSE_MOVED
| LEFT_MOUSE_DRAGGED
| RIGHT_MOUSE_DRAGGED
| OTHER_MOUSE_DOWN
| OTHER_MOUSE_UP
| OTHER_MOUSE_DRAGGED
);
if e.kind == TABLET_PROXIMITY || (mouse && e.subtype == subtype::TABLET_PROXIMITY) {
return self.proximity(e);
}
if e.kind == TABLET_POINT || (mouse && e.subtype == subtype::TABLET_POINT) {
return Update::Set(Some(self.sample(e)));
}
if mouse { Update::Set(None) } else { Update::Keep }
}
fn proximity(&mut self, e: &RawEvent) -> Update {
if !e.entering {
self.eraser = false;
return Update::Set(None);
}
// A puck (cursor) is a mouse as far as painting goes.
if e.device == device::CURSOR {
self.eraser = false;
return Update::Set(None);
}
self.eraser = e.device == device::ERASER;
// Hovering: no pressure yet, but the end in use is known (the eraser switch happens now).
Update::Set(Some(Sample { pressure: 0.0, tilt_x: 0.0, tilt_y: 0.0, rotation: 0.0, eraser: self.eraser }))
}
fn sample(&self, e: &RawEvent) -> Sample {
let tilt = |v: f64| if v.is_finite() { (v.clamp(-1.0, 1.0) * TILT_DEGREES) as f32 } else { 0.0 };
Sample {
pressure: e.pressure,
tilt_x: tilt(e.tilt.0),
// AppKit's +y tilts away from the user; W3C's +y tilts towards the user.
tilt_y: -tilt(e.tilt.1),
rotation: e.rotation,
eraser: self.eraser,
}
.sanitized()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pen(kind: usize, pressure: f32, tilt: (f64, f64), rotation: f32) -> RawEvent {
RawEvent { kind, subtype: subtype::TABLET_POINT, pressure, tilt, rotation, ..Default::default() }
}
fn set(u: Update) -> Option<Sample> {
match u {
Update::Set(s) => s,
Update::Keep => panic!("expected Set, got Keep"),
}
}
#[test]
fn tablet_mouse_events_carry_pressure_tilt_and_rotation() {
let mut st = State::default();
let s = set(st.handle(&pen(event_type::LEFT_MOUSE_DRAGGED, 0.5, (0.5, -0.25), 30.0))).unwrap();
assert_eq!(s, Sample { pressure: 0.5, tilt_x: 30.0, tilt_y: 15.0, rotation: 30.0, eraser: false });
let s = set(st.handle(&pen(event_type::TABLET_POINT, 0.8, (-1.0, 1.0), 0.0))).unwrap();
assert_eq!((s.pressure, s.tilt_x, s.tilt_y), (0.8, -60.0, -60.0));
}
#[test]
fn mouse_events_are_full_pressure_mice() {
let mut st = State::default();
for sub in [subtype::MOUSE, subtype::TOUCH, 99] {
let e = RawEvent { kind: event_type::LEFT_MOUSE_DOWN, subtype: sub, pressure: 1.0, ..Default::default() };
assert_eq!(st.handle(&e), Update::Set(None), "subtype {sub}");
}
// Unrelated event types (keys, scroll, pressure-trackpad) don't touch the sample.
for kind in [0, 10, 22, 29, 34, usize::MAX] {
assert_eq!(st.handle(&RawEvent { kind, ..Default::default() }), Update::Keep, "type {kind}");
}
}
#[test]
fn proximity_tracks_the_eraser_end() {
let mut st = State::default();
let prox = |device, entering| RawEvent { kind: event_type::TABLET_PROXIMITY, device, entering, ..Default::default() };
let s = set(st.handle(&prox(device::ERASER, true))).unwrap();
assert!(s.eraser && s.pressure == 0.0);
assert!(set(st.handle(&pen(event_type::LEFT_MOUSE_DOWN, 0.4, (0.0, 0.0), 0.0))).unwrap().eraser);
assert_eq!(st.handle(&prox(device::ERASER, false)), Update::Set(None));
assert!(!st.eraser);
assert!(!set(st.handle(&prox(device::PEN, true))).unwrap().eraser);
assert!(!set(st.handle(&pen(event_type::LEFT_MOUSE_DRAGGED, 0.4, (0.0, 0.0), 0.0))).unwrap().eraser);
// A puck is a mouse; unknown devices paint as a pen tip.
assert_eq!(st.handle(&prox(device::CURSOR, true)), Update::Set(None));
assert!(!set(st.handle(&prox(device::UNKNOWN, true))).unwrap().eraser);
// Proximity as a mouse-event subtype works the same.
let e = RawEvent { kind: event_type::MOUSE_MOVED, subtype: subtype::TABLET_PROXIMITY, device: device::ERASER, entering: true, ..Default::default() };
assert!(set(st.handle(&e)).unwrap().eraser);
}
#[test]
fn malformed_values_are_clamped_not_trusted() {
let mut st = State::default();
let s = set(st.handle(&pen(event_type::LEFT_MOUSE_DRAGGED, f32::NAN, (f64::NAN, f64::INFINITY), f32::NAN))).unwrap();
assert_eq!(s, Sample { pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, rotation: 0.0, eraser: false });
let s = set(st.handle(&pen(event_type::LEFT_MOUSE_DRAGGED, 4.0, (7.0, -9.0), -30.0))).unwrap();
assert_eq!(s, Sample { pressure: 1.0, tilt_x: 60.0, tilt_y: 60.0, rotation: 330.0, eraser: false });
let s = set(st.handle(&pen(event_type::LEFT_MOUSE_DRAGGED, -1.0, (f64::MAX, f64::MIN), f32::MAX))).unwrap();
assert!(s.pressure == 0.0 && s.tilt_x == 60.0 && s.tilt_y == 60.0 && (0.0..360.0).contains(&s.rotation));
}
#[test]
fn mask_covers_the_mapped_types() {
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 << 10), 0, "key down is not monitored");
}
}

131
crates/tablet/src/lib.rs Normal file
View File

@ -0,0 +1,131 @@
//! Pen tablet input that the windowing layer (winit 0.30) drops: pressure, tilt, barrel rotation
//! and which end of the pen is down (tip or eraser).
//!
//! - **macOS** ([`macos::Monitor`]): an AppKit local event monitor reads the tablet fields of
//! each `NSEvent` (`pressure`, `tilt`, `rotation`, `subtype`, and `pointingDeviceType` from
//! proximity events) before winit sees the event, so every pointer event the UI handles already
//! has its pen sample. AppKit's block-based monitor API needs Objective-C interop, so
//! `macos.rs` is the only module in the workspace that allows `unsafe` (craftrules
//! never-crash: an isolated, tested helper crate).
//! - **Linux X11** ([`x11::spawn`]): a second, pure-Rust X connection (x11rb) selects XInput2
//! raw events on the root window and maps the tablet device's valuators ("Abs Pressure",
//! "Abs Tilt X/Y", "Abs Rotary Z") to samples. Raw events reach every client that asks, also
//! while winit holds the pointer grab of a drag. No `unsafe`.
//! - **Wayland**: not covered. The `zwp_tablet_v2` protocol has to be bound on winit's own
//! `wl_display` connection (the tablet events name winit's `wl_surface`), which needs
//! `unsafe` foreign-display interop, and binding it makes compositors stop emulating the
//! pointer for the pen, so pen motion would have to be re-injected into egui as well.
//!
//! The platform glue only reads raw values and hands them to the pure mapping in [`appkit`] and
//! [`xi`], which normalise, clamp and track pen state and are tested on every platform.
//!
//! A sample is reported through a callback: `Some(sample)` while a pen is in use (hovering or
//! touching), `None` when the pointer is a mouse again.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod appkit;
pub mod xi;
#[cfg(target_os = "macos")]
pub mod macos;
#[cfg(target_os = "linux")]
pub mod x11;
/// One pen reading. Tilt is in degrees (-90..90, W3C Pointer Events convention: +x tilts to the
/// right, +y towards the user), rotation in degrees 0..360 (clockwise).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Sample {
/// Tip (or eraser) pressure 0..1; 0 while hovering.
pub pressure: f32,
pub tilt_x: f32,
pub tilt_y: f32,
pub rotation: f32,
/// The eraser end of the pen is in use.
pub eraser: bool,
}
impl Default for Sample {
fn default() -> Self {
Self { pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, rotation: 0.0, eraser: false }
}
}
impl Sample {
/// Clamp to the documented ranges and replace non-finite values (a NaN pressure reads as
/// full pressure, like a mouse; NaN tilt and rotation as 0).
pub fn sanitized(self) -> Self {
let f = |v: f32, lo: f32, hi: f32, d: f32| if v.is_finite() { v.clamp(lo, hi) } else { d };
let rotation = if self.rotation.is_finite() { self.rotation.rem_euclid(360.0) } else { 0.0 };
Self {
pressure: f(self.pressure, 0.0, 1.0, 1.0),
tilt_x: f(self.tilt_x, -90.0, 90.0, 0.0),
tilt_y: f(self.tilt_y, -90.0, 90.0, 0.0),
// `rem_euclid` can round up to exactly 360 for tiny negative inputs.
rotation: if rotation >= 360.0 { 0.0 } else { rotation },
eraser: self.eraser,
}
}
}
/// What a raw platform event means for the current pen sample.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Update {
/// Nothing to report (an event this module doesn't map).
Keep,
/// The new sample: `Some` for a pen, `None` for a mouse.
Set(Option<Sample>),
}
/// Why tablet input could not be started.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Error {
/// Must be called on the main thread (AppKit).
NotMainThread,
/// The platform or display server doesn't offer what this needs.
Unsupported(String),
/// The platform call failed.
Platform(String),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::NotMainThread => write!(f, "tablet input must be started on the main thread"),
Error::Unsupported(why) => write!(f, "tablet input unsupported: {why}"),
Error::Platform(why) => write!(f, "tablet input failed: {why}"),
}
}
}
impl std::error::Error for Error {}
/// Sanitize a sample and run a user callback with it, without letting a panic unwind into the platform's event loop (an unwind
/// through an Objective-C block or a detached thread would abort or silently stop input).
pub fn deliver(callback: &dyn Fn(Option<Sample>), sample: Option<Sample>) {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(sample.map(Sample::sanitized))));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitized_clamps_and_replaces_non_finite() {
let s = Sample { pressure: 1.7, tilt_x: -120.0, tilt_y: f32::NAN, rotation: -90.0, eraser: true }.sanitized();
assert_eq!(s, Sample { pressure: 1.0, tilt_x: -90.0, tilt_y: 0.0, rotation: 270.0, eraser: true });
let s = Sample { pressure: f32::NAN, tilt_x: f32::INFINITY, tilt_y: f32::NEG_INFINITY, rotation: f32::INFINITY, eraser: false }.sanitized();
assert_eq!(s, Sample::default());
let s = Sample { pressure: -0.5, rotation: -1e-9, ..Default::default() }.sanitized();
assert_eq!((s.pressure, s.rotation), (0.0, 0.0));
assert!(Sample { rotation: 725.0, ..Default::default() }.sanitized().rotation == 5.0);
}
#[test]
fn deliver_contains_a_panicking_callback() {
let seen = std::cell::Cell::new(None);
deliver(&|s| seen.set(s), Some(Sample { pressure: 3.0, ..Default::default() }));
assert_eq!(seen.get().map(|s| s.pressure), Some(1.0), "sanitized on the way out");
deliver(&|_| panic!("boom"), None);
}
}

181
crates/tablet/src/macos.rs Normal file
View File

@ -0,0 +1,181 @@
//! AppKit local event monitor for tablet data. The only module in the workspace that allows
//! `unsafe`: three calls into AppKit's Objective-C API that objc2 can't prove sound on its own
//! (each has a `SAFETY:` comment). Everything the monitor reads goes through the pure, tested
//! mapping in [`crate::appkit`].
//!
//! A *local* monitor sees this app's events only, on the main thread, inside `-[NSApplication
//! sendEvent:]` before the event reaches winit's view, so the sample is current when the UI
//! handles the matching pointer event. The monitor passes every event through unchanged.
#![allow(unsafe_code)]
use std::cell::RefCell;
use std::ptr::NonNull;
use block2::RcBlock;
use objc2::MainThreadMarker;
use objc2::rc::Retained;
use objc2::runtime::AnyObject;
use objc2_app_kit::{NSEvent, NSEventMask};
use crate::appkit::{RawEvent, State};
use crate::{Error, Sample, Update, deliver};
/// Keeps the monitor installed; dropping it removes the monitor. Main-thread only (not `Send`).
pub struct Monitor {
token: Retained<AnyObject>,
_handler: RcBlock<dyn Fn(NonNull<NSEvent>) -> *mut NSEvent>,
}
impl Monitor {
/// Install the monitor. `callback` gets `Some(sample)` for pen events and `None` when a mouse
/// moves; it runs on the main thread. Call on the main thread (before or after the event loop
/// starts).
pub fn install(callback: impl Fn(Option<Sample>) + 'static) -> Result<Self, Error> {
if MainThreadMarker::new().is_none() {
return Err(Error::NotMainThread);
}
let state = RefCell::new(State::default());
let handler = RcBlock::new(move |event: NonNull<NSEvent>| -> *mut NSEvent {
// SAFETY: AppKit calls a local monitor's handler with the event it is about to
// dispatch, a valid `NSEvent` that it keeps alive for the duration of the call; we
// only borrow it within the call.
let e: &NSEvent = unsafe { event.as_ref() };
let raw = read(e);
// The handler can't re-enter itself (AppKit runs it synchronously on the main
// thread), but `try_borrow_mut` keeps a re-entrant call from panicking anyway.
if let Ok(mut st) = state.try_borrow_mut()
&& let Update::Set(sample) = st.handle(&raw)
{
deliver(&callback, sample);
}
// Pass the event on unchanged.
event.as_ptr()
});
let mask = NSEventMask(crate::appkit::event_mask());
// SAFETY: the handler returns the (non-null, valid) event it was given, as the API
// requires ("block's return must be a valid pointer or null"). We keep the block alive
// in `Monitor` as well, although AppKit copies it.
let token = unsafe { NSEvent::addLocalMonitorForEventsMatchingMask_handler(mask, &handler) };
let token = token.ok_or_else(|| Error::Platform("addLocalMonitorForEventsMatchingMask returned nil".into()))?;
Ok(Self { token, _handler: handler })
}
}
impl Drop for Monitor {
fn drop(&mut self) {
// SAFETY: `token` is exactly the object `addLocalMonitorForEventsMatchingMask:handler:`
// returned, removed once (here). `Monitor` is not `Send`, so this runs on the main thread.
unsafe { NSEvent::removeMonitor(&self.token) };
}
}
/// Read the tablet fields of an event. Every getter is safe for every event type: AppKit
/// returns 0 / zero points for fields an event doesn't carry (`subtype` and the proximity fields
/// are only read for the event types that define them, see [`crate::appkit`]).
fn read(e: &NSEvent) -> RawEvent {
let kind = e.r#type().0;
let tabletish = matches!(kind, crate::appkit::event_type::TABLET_POINT | crate::appkit::event_type::TABLET_PROXIMITY);
let mouse = !tabletish && is_mouse(kind);
// `subtype` raises an exception for event types without one; ask only mouse events.
let subtype = if mouse { e.subtype().0 } else { 0 };
let proximity = kind == crate::appkit::event_type::TABLET_PROXIMITY || (mouse && subtype == crate::appkit::subtype::TABLET_PROXIMITY);
let point = kind == crate::appkit::event_type::TABLET_POINT || (mouse && subtype == crate::appkit::subtype::TABLET_POINT);
let (pressure, tilt, rotation) = if point {
let t = e.tilt();
(e.pressure(), (t.x, t.y), e.rotation())
} else {
(0.0, (0.0, 0.0), 0.0)
};
let (device, entering) = if proximity { (e.pointingDeviceType().0, e.isEnteringProximity()) } else { (0, false) };
RawEvent { kind, subtype, pressure, tilt, rotation, device, entering }
}
fn is_mouse(kind: usize) -> bool {
use crate::appkit::event_type::*;
matches!(
kind,
LEFT_MOUSE_DOWN
| LEFT_MOUSE_UP
| RIGHT_MOUSE_DOWN
| RIGHT_MOUSE_UP
| MOUSE_MOVED
| LEFT_MOUSE_DRAGGED
| RIGHT_MOUSE_DRAGGED
| OTHER_MOUSE_DOWN
| OTHER_MOUSE_UP
| OTHER_MOUSE_DRAGGED
)
}
#[cfg(test)]
mod tests {
use super::*;
use objc2_core_graphics::{CGEvent, CGEventField, CGEventMouseSubtype, CGEventType, CGMouseButton};
use objc2_foundation::NSPoint;
#[test]
fn install_off_the_main_thread_is_an_error() {
// `cargo test` runs tests on worker threads, never on the main thread.
let r = std::thread::spawn(|| Monitor::install(|_| {}).err()).join().unwrap();
assert_eq!(r, Some(Error::NotMainThread));
}
/// A real `NSEvent` built from a Quartz event, as the tablet driver posts them.
fn event(kind: CGEventType, set: impl Fn(&CGEvent)) -> objc2::rc::Retained<NSEvent> {
// Quartz builds mouse events only; other types start blank and get their type set.
let cg = CGEvent::new_mouse_event(None, kind, NSPoint::new(10.0, 10.0), CGMouseButton::Left)
.or_else(|| {
let e = CGEvent::new(None)?;
CGEvent::set_type(Some(&e), kind);
Some(e)
})
.expect("CGEvent");
set(&cg);
NSEvent::eventWithCGEvent(&cg).expect("NSEvent")
}
fn int(e: &CGEvent, f: CGEventField, v: i64) {
CGEvent::set_integer_value_field(Some(e), f, v);
}
fn dbl(e: &CGEvent, f: CGEventField, v: f64) {
CGEvent::set_double_value_field(Some(e), f, v);
}
/// Tablet mouse events read back through the same path as live ones.
#[test]
fn tablet_events_read_through_the_monitor_path() {
let mut st = State::default();
let drag = event(CGEventType::LeftMouseDragged, |e| {
int(e, CGEventField::MouseEventSubtype, i64::from(CGEventMouseSubtype::TabletPoint.0));
dbl(e, CGEventField::MouseEventPressure, 0.25);
dbl(e, CGEventField::TabletEventPointPressure, 0.25);
dbl(e, CGEventField::TabletEventTiltX, 0.5);
dbl(e, CGEventField::TabletEventTiltY, -0.5);
dbl(e, CGEventField::TabletEventRotation, 45.0);
});
let raw = read(&drag);
assert_eq!((raw.kind, raw.subtype), (6, 1));
let Update::Set(Some(s)) = st.handle(&raw) else { panic!("{raw:?}") };
assert!((s.pressure - 0.25).abs() < 0.01, "{s:?}"); // Quartz keeps 8 bits of mouse pressure.
assert!((s.tilt_x - 30.0).abs() < 0.1 && (s.tilt_y - 30.0).abs() < 0.1, "{s:?}");
assert!((s.rotation - 45.0).abs() < 0.1, "{s:?}");
assert!(!s.eraser);
// Eraser end entering proximity, then a press with it.
let prox = event(CGEventType::TabletProximity, |e| {
int(e, CGEventField::TabletProximityEventPointerType, crate::appkit::device::ERASER as i64);
int(e, CGEventField::TabletProximityEventEnterProximity, 1);
});
let raw = read(&prox);
assert_eq!((raw.kind, raw.device, raw.entering), (24, 3, true));
assert!(matches!(st.handle(&raw), Update::Set(Some(Sample { eraser: true, .. }))));
// A plain mouse event (subtype 0) is a mouse, whatever its pressure.
let mouse = event(CGEventType::LeftMouseDown, |e| dbl(e, CGEventField::MouseEventPressure, 0.7));
let raw = read(&mouse);
assert_eq!((raw.kind, raw.subtype), (1, 0));
assert_eq!(st.handle(&raw), Update::Set(None));
}
}

104
crates/tablet/src/x11.rs Normal file
View File

@ -0,0 +1,104 @@
//! XInput2 raw tablet events on X11, over a separate pure-Rust X connection (x11rb's
//! `RustConnection`, no `unsafe`, no libxcb).
//!
//! winit selects XI2 pointer events on its window for the master pointer, whose events carry the
//! valuators of whichever slave moved but whose valuator layout changes with it, and which go to
//! winit alone while a drag holds the implicit grab. Raw events (`XI_RawMotion`,
//! `XI_RawButtonPress/Release`) selected on the root window have neither problem: since XI 2.1
//! every client that selects them gets them, grabbed or not, tagged with the slave device. So
//! this thread listens to those, plus hierarchy and device changes to keep the device list
//! current, and maps them with [`crate::xi`].
//!
//! Raw events don't say which window the pen is over. That's fine for pressure: the UI only uses
//! the sample while its own canvas receives pointer events, and any other pointer moving resets
//! it to "mouse".
use std::thread::JoinHandle;
use x11rb::connection::Connection;
use x11rb::protocol::Event;
use x11rb::protocol::xinput::{self, ConnectionExt as _, DeviceClassData, DeviceType, XIEventMask};
use x11rb::protocol::xproto::ConnectionExt as _;
use x11rb::rust_connection::RustConnection;
use crate::xi::{Device, State, fp3232, valuators};
use crate::{Error, Sample, Update, deliver};
/// `XIAllDevices`: select for every device, slaves included.
const ALL_DEVICES: u16 = 0;
/// Start listening on `display` (`None` = `$DISPLAY`, the display winit uses). The connection
/// and the XI2 version are checked before this returns; events are then read on a background
/// thread that calls `callback` with `Some(sample)` for pen events and `None` when another
/// pointer moves. The thread ends when the X connection closes.
pub fn spawn(display: Option<&str>, callback: impl Fn(Option<Sample>) + Send + 'static) -> Result<JoinHandle<()>, Error> {
let (conn, screen) = RustConnection::connect(display).map_err(|e| Error::Unsupported(format!("cannot connect to the X server: {e}")))?;
let root = conn.setup().roots.get(screen).map(|s| s.root).ok_or_else(|| Error::Platform(format!("X screen {screen} not found")))?;
// Raw events for everyone (not just the grabbing client) need XI 2.1; ask for 2.2.
let version =
conn.xinput_xi_query_version(2, 2).map_err(|e| Error::Platform(e.to_string()))?.reply().map_err(|e| Error::Unsupported(format!("no XInput2: {e}")))?;
if (version.major_version, version.minor_version) < (2, 1) {
return Err(Error::Unsupported(format!("XInput {}.{} (raw events need 2.1)", version.major_version, version.minor_version)));
}
let mask = XIEventMask::RAW_MOTION | XIEventMask::RAW_BUTTON_PRESS | XIEventMask::RAW_BUTTON_RELEASE | XIEventMask::HIERARCHY | XIEventMask::DEVICE_CHANGED;
conn.xinput_xi_select_events(root, &[xinput::EventMask { deviceid: ALL_DEVICES, mask: vec![mask] }])
.map_err(|e| Error::Platform(e.to_string()))?
.check()
.map_err(|e| Error::Platform(format!("XISelectEvents: {e}")))?;
let mut state = State::default();
state.set_devices(query_devices(&conn));
std::thread::Builder::new()
.name("photocraft-tablet-x11".into())
.spawn(move || run(&conn, &mut state, &callback))
.map_err(|e| Error::Platform(format!("cannot start the tablet thread: {e}")))
}
fn run(conn: &RustConnection, state: &mut State, callback: &dyn Fn(Option<Sample>)) {
// A connection error (the display went away) ends the loop; malformed events are skipped by
// x11rb's parser and by the bounds in `xi`.
while let Ok(event) = conn.wait_for_event() {
let update = match event {
Event::XinputRawMotion(e) | Event::XinputRawButtonPress(e) | Event::XinputRawButtonRelease(e) => {
let values: Vec<f64> = e.axisvalues.iter().map(|v| fp3232(v.integral, v.frac)).collect();
state.handle(e.deviceid, valuators(&e.valuator_mask, &values))
}
Event::XinputHierarchy(_) | Event::XinputDeviceChanged(_) => {
state.set_devices(query_devices(conn));
Update::Keep
}
_ => Update::Keep,
};
if let Update::Set(sample) = update {
deliver(callback, sample);
}
}
}
/// The slave pointer devices with their pen valuators (empty on any error).
fn query_devices(conn: &RustConnection) -> Vec<Device> {
let Ok(reply) = conn.xinput_xi_query_device(ALL_DEVICES).map_err(drop).and_then(|c| c.reply().map_err(drop)) else { return Vec::new() };
let mut out = Vec::new();
for info in reply.infos.iter().take(crate::xi::MAX_DEVICES) {
if info.type_ != DeviceType::SLAVE_POINTER && info.type_ != DeviceType::FLOATING_SLAVE {
continue;
}
let name = String::from_utf8_lossy(&info.name);
let valuators = info.classes.iter().take(crate::xi::MAX_AXES).filter_map(|c| match &c.data {
DeviceClassData::Valuator(v) => {
let label = atom_name(conn, v.label)?;
Some((v.number, label, fp3232(v.min.integral, v.min.frac), fp3232(v.max.integral, v.max.frac)))
}
_ => None,
});
out.push(Device::new(info.deviceid, &name, valuators.collect::<Vec<_>>()));
}
out
}
fn atom_name(conn: &RustConnection, atom: u32) -> Option<String> {
if atom == 0 {
return None;
}
let reply = conn.get_atom_name(atom).ok()?.reply().ok()?;
Some(String::from_utf8_lossy(&reply.name).into_owned())
}

302
crates/tablet/src/xi.rs Normal file
View File

@ -0,0 +1,302 @@
//! XInput2 tablet valuators → [`Sample`]s. Pure (no X calls), so it is tested on every platform;
//! `x11.rs` queries the devices and feeds raw events here.
//!
//! A tablet tool is a slave pointer device with an "Abs Pressure" valuator (the xf86-input-wacom
//! and xf86-input-libinput drivers and Xwayland all label their axes with the standard
//! `AXIS_LABEL_PROP_*` atom names). Each valuator is normalised from its advertised `min..max`:
//!
//! - pressure → 0..1;
//! - tilt: drivers report degrees (libinput, Xwayland: -64..63; wacom: -64..63 units of about a
//! degree), used as-is when the range fits in ±90, otherwise scaled to ±[`TILT_DEGREES`];
//! - "Abs Rotary Z" (Art Pen barrel rotation) → 0..360.
//!
//! The eraser is a separate device whose name says so ("… Pen eraser", "… Eraser (0x…)"), as
//! GTK and Krita detect it. Raw events only carry the valuators that changed, so the last value
//! of each axis is kept per device.
use std::collections::HashMap;
use crate::{Sample, Update};
/// Tilt for valuators that don't report degrees: the extremes of the range.
pub const TILT_DEGREES: f64 = 60.0;
/// Valuator numbers beyond this are ignored (the protocol allows 65535; real devices have < 16).
pub const MAX_AXES: usize = 64;
/// Devices kept (hostile servers could report thousands).
pub const MAX_DEVICES: usize = 256;
/// Which pen axis a valuator carries.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AxisKind {
Pressure,
TiltX,
TiltY,
Rotation,
}
impl AxisKind {
/// From a valuator's label atom name.
pub fn from_label(label: &str) -> Option<Self> {
match label {
"Abs Pressure" => Some(AxisKind::Pressure),
"Abs Tilt X" => Some(AxisKind::TiltX),
"Abs Tilt Y" => Some(AxisKind::TiltY),
"Abs Rotary Z" => Some(AxisKind::Rotation),
_ => None,
}
}
}
/// One valuator of a device.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Axis {
pub kind: AxisKind,
/// Valuator number (index into a raw event's valuator mask).
pub number: u16,
pub min: f64,
pub max: f64,
}
impl Axis {
/// Map a raw value of this axis to the sample's units.
fn map(&self, v: f64) -> f32 {
if !v.is_finite() {
return match self.kind {
AxisKind::Pressure => 1.0,
_ => 0.0,
};
}
let span = self.max - self.min;
let unit = if span.is_finite() && span > 0.0 { ((v - self.min) / span).clamp(0.0, 1.0) } else { v.clamp(0.0, 1.0) };
match self.kind {
AxisKind::Pressure => unit as f32,
AxisKind::TiltX | AxisKind::TiltY => {
if self.min >= -90.0 && self.max <= 90.0 && span > 0.0 {
v.clamp(-90.0, 90.0) as f32
} else {
((unit * 2.0 - 1.0) * TILT_DEGREES) as f32
}
}
AxisKind::Rotation => (unit * 360.0) as f32,
}
}
}
/// A slave pointer device.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Device {
pub id: u16,
/// The eraser end of a pen.
pub eraser: bool,
/// Pen axes (other valuators are not kept).
pub axes: Vec<Axis>,
}
impl Device {
/// Build from what `XIQueryDevice` reports: the name and each valuator's (number, label,
/// min, max).
pub fn new(id: u16, name: &str, valuators: impl IntoIterator<Item = (u16, String, f64, f64)>) -> Self {
let axes = valuators
.into_iter()
.filter(|(n, ..)| usize::from(*n) < MAX_AXES)
.filter_map(|(number, label, min, max)| Some(Axis { kind: AxisKind::from_label(&label)?, number, min, max }))
.take(MAX_AXES)
.collect();
Self { id, eraser: name.to_ascii_lowercase().contains("eraser"), axes }
}
/// A pen (tip or eraser): has a pressure axis.
pub fn is_tablet(&self) -> bool {
self.axes.iter().any(|a| a.kind == AxisKind::Pressure)
}
}
/// The values a raw event carries: valuator numbers set in `mask` (32 per word, LSB first)
/// paired, in order, with `values`. Extra mask bits beyond the values are ignored.
pub fn valuators<'a>(mask: &'a [u32], values: &'a [f64]) -> impl Iterator<Item = (u16, f64)> + 'a {
let numbers = mask
.iter()
.enumerate()
.flat_map(|(w, bits)| (0..32u32).filter(move |b| bits & (1 << b) != 0).map(move |b| w.saturating_mul(32).saturating_add(b as usize)));
numbers.zip(values.iter().copied()).filter_map(|(n, v)| u16::try_from(n).ok().map(|n| (n, v)))
}
/// Devices and the last value of each pen axis.
#[derive(Clone, Debug, Default)]
pub struct State {
devices: HashMap<u16, Device>,
last: HashMap<u16, [Option<f32>; 4]>,
}
impl State {
/// Replace the device list (after `XIQueryDevice`, on start and hierarchy changes).
pub fn set_devices(&mut self, devices: impl IntoIterator<Item = Device>) {
self.devices = devices.into_iter().take(MAX_DEVICES).map(|d| (d.id, d)).collect();
self.last.retain(|id, _| self.devices.contains_key(id));
}
pub fn device(&self, id: u16) -> Option<&Device> {
self.devices.get(&id)
}
/// A raw motion / button event from slave device `id` with its changed valuators.
/// Tablet devices give a sample; other known pointers mean a mouse; unknown ids (masters,
/// devices added since the last query) change nothing.
pub fn handle(&mut self, id: u16, valuators: impl IntoIterator<Item = (u16, f64)>) -> Update {
let Some(dev) = self.devices.get(&id) else { return Update::Keep };
if !dev.is_tablet() {
return Update::Set(None);
}
let last = self.last.entry(id).or_default();
for (n, v) in valuators {
for a in dev.axes.iter().filter(|a| a.number == n) {
let slot = match a.kind {
AxisKind::Pressure => 0,
AxisKind::TiltX => 1,
AxisKind::TiltY => 2,
AxisKind::Rotation => 3,
};
if let Some(s) = last.get_mut(slot) {
*s = Some(a.map(v));
}
}
}
let [pressure, tilt_x, tilt_y, rotation] = *last;
Update::Set(Some(
Sample {
pressure: pressure.unwrap_or(0.0),
tilt_x: tilt_x.unwrap_or(0.0),
tilt_y: tilt_y.unwrap_or(0.0),
rotation: rotation.unwrap_or(0.0),
eraser: dev.eraser,
}
.sanitized(),
))
}
}
/// A 32.32 fixed-point X value as f64.
pub fn fp3232(integral: i32, frac: u32) -> f64 {
f64::from(integral) + f64::from(frac) / 4_294_967_296.0
}
#[cfg(test)]
mod tests {
use super::*;
fn wacom(id: u16, name: &str) -> Device {
Device::new(
id,
name,
[
(0, "Abs X".to_string(), 0.0, 44704.0),
(1, "Abs Y".to_string(), 0.0, 27940.0),
(2, "Abs Pressure".to_string(), 0.0, 65535.0),
(3, "Abs Tilt X".to_string(), -64.0, 63.0),
(4, "Abs Tilt Y".to_string(), -64.0, 63.0),
(5, "Abs Rotary Z".to_string(), -900.0, 899.0),
],
)
}
fn mouse(id: u16) -> Device {
Device::new(id, "Logitech USB Optical Mouse", [(0, "Rel X".to_string(), -1.0, -1.0), (1, "Rel Y".to_string(), -1.0, -1.0)])
}
fn sample(u: Update) -> Sample {
match u {
Update::Set(Some(s)) => s,
other => panic!("expected a sample, got {other:?}"),
}
}
#[test]
fn mask_pairs_numbers_with_values() {
let v: Vec<_> = valuators(&[0b10_0101, 1], &[1.0, 2.0, 3.0, 4.0]).collect();
assert_eq!(v, vec![(0, 1.0), (2, 2.0), (5, 3.0), (32, 4.0)]);
// Fewer values than mask bits (malformed): stop at the values.
assert_eq!(valuators(&[u32::MAX; 4], &[7.0]).count(), 1);
assert_eq!(valuators(&[], &[7.0]).count(), 0);
}
#[test]
fn devices_and_eraser_detection() {
assert!(wacom(9, "Wacom Intuos Pro M Pen stylus").is_tablet());
assert!(!wacom(9, "Wacom Intuos Pro M Pen stylus").eraser);
assert!(wacom(10, "Wacom Intuos Pro M Pen eraser").eraser);
assert!(wacom(11, "HUION Kamvas Pen Eraser (0x1)").eraser);
assert!(!mouse(12).is_tablet());
let pad = Device::new(13, "Wacom Intuos Pro M Pad pad", [(5, "Abs Wheel".to_string(), 0.0, 71.0)]);
assert!(!pad.is_tablet() && pad.axes.is_empty());
}
#[test]
fn valuators_normalise_to_pressure_degrees_and_rotation() {
let mut st = State::default();
st.set_devices([wacom(9, "Pen stylus"), wacom(10, "Pen eraser"), mouse(12)]);
let s = sample(st.handle(9, [(0, 100.0), (2, 32767.5), (3, -30.0), (4, 45.0), (5, 0.0)]));
assert_eq!(s.pressure, 0.5);
assert_eq!((s.tilt_x, s.tilt_y), (-30.0, 45.0));
assert!((s.rotation - 180.0).abs() < 0.2, "{}", s.rotation);
assert!(!s.eraser);
// Raw events only carry changed axes: the others keep their last value.
let s = sample(st.handle(9, [(2, 65535.0)]));
assert_eq!((s.pressure, s.tilt_x, s.tilt_y), (1.0, -30.0, 45.0));
// The eraser device has its own state and flag.
let s = sample(st.handle(10, [(2, 0.0)]));
assert!(s.eraser && s.pressure == 0.0 && s.tilt_x == 0.0);
// A mouse moving means mouse; a master or unknown id changes nothing.
assert_eq!(st.handle(12, [(0, 3.0)]), Update::Set(None));
assert_eq!(st.handle(2, [(2, 3.0)]), Update::Keep);
}
#[test]
fn tilt_ranges_that_are_not_degrees_are_scaled() {
let d =
Device::new(1, "pen", [(2, "Abs Pressure".into(), 0.0, 1023.0), (3, "Abs Tilt X".into(), 0.0, 127.0), (4, "Abs Tilt Y".into(), -1000.0, 1000.0)]);
let mut st = State::default();
st.set_devices([d]);
let s = sample(st.handle(1, [(3, 127.0), (4, -1000.0)]));
assert_eq!((s.tilt_x, s.tilt_y), (60.0, -60.0));
}
#[test]
fn hostile_device_data_does_not_panic() {
let mut st = State::default();
// Empty, inverted, zero-width and non-finite ranges; huge valuator numbers.
let d = Device::new(
1,
"",
[
(2, "Abs Pressure".into(), 5.0, 5.0),
(3, "Abs Tilt X".into(), 90.0, -90.0),
(4, "Abs Tilt Y".into(), f64::NEG_INFINITY, f64::INFINITY),
(5, "Abs Rotary Z".into(), f64::NAN, f64::NAN),
(u16::MAX, "Abs Pressure".into(), 0.0, 1.0),
],
);
assert_eq!(d.axes.len(), 4, "out-of-range valuator numbers are dropped");
st.set_devices([d]);
for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, f64::MIN, -0.0, 1e300] {
let s = sample(st.handle(1, [(2, v), (3, v), (4, v), (5, v)]));
assert!((0.0..=1.0).contains(&s.pressure), "{v}: {s:?}");
assert!((-90.0..=90.0).contains(&s.tilt_x) && (-90.0..=90.0).contains(&s.tilt_y), "{v}: {s:?}");
assert!((0.0..360.0).contains(&s.rotation), "{v}: {s:?}");
}
// Lots of devices are capped.
st.set_devices((0..2000u16).map(|i| Device::new(i, "x", [])));
assert!(st.devices.len() <= MAX_DEVICES);
// Valuator values with no matching device axis are ignored.
st.set_devices([wacom(3, "pen")]);
let s = sample(st.handle(3, valuators(&[u32::MAX; 2048], &[0.5; 4096]).collect::<Vec<_>>()));
assert!(s.pressure < 0.001);
}
#[test]
fn fixed_point() {
assert_eq!(fp3232(3, 1 << 31), 3.5);
assert_eq!(fp3232(-2, 0), -2.0);
}
}

View File

@ -0,0 +1,51 @@
//! The AppKit monitor end to end, on the main thread (`harness = false`): install it, dispatch
//! real tablet `NSEvent`s through `-[NSApplication sendEvent:]` (where AppKit runs local
//! monitors), and check the samples the callback gets; dropping the monitor removes it.
#[cfg(target_os = "macos")]
fn main() {
use std::cell::RefCell;
use std::rc::Rc;
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSEvent};
use objc2_core_graphics::{CGEvent, CGEventField, CGEventMouseSubtype, CGEventType, CGMouseButton};
use objc2_foundation::NSPoint;
use photocraft_tablet::Sample;
use photocraft_tablet::macos::Monitor;
let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread");
let app = NSApplication::sharedApplication(mtm);
let event = |kind: CGEventType, pressure: f64, tilt: (f64, f64), subtype: CGEventMouseSubtype| {
let cg = CGEvent::new_mouse_event(None, kind, NSPoint::new(5.0, 5.0), CGMouseButton::Left).expect("CGEvent");
let set = |f, v| CGEvent::set_double_value_field(Some(&cg), f, v);
CGEvent::set_integer_value_field(Some(&cg), CGEventField::MouseEventSubtype, i64::from(subtype.0));
set(CGEventField::MouseEventPressure, pressure);
set(CGEventField::TabletEventPointPressure, pressure);
set(CGEventField::TabletEventTiltX, tilt.0);
set(CGEventField::TabletEventTiltY, tilt.1);
NSEvent::eventWithCGEvent(&cg).expect("NSEvent")
};
let seen: Rc<RefCell<Vec<Option<Sample>>>> = Rc::default();
let monitor = {
let seen = seen.clone();
Monitor::install(move |s| seen.borrow_mut().push(s)).expect("install on the main thread")
};
app.sendEvent(&event(CGEventType::LeftMouseDragged, 0.5, (0.5, 0.0), CGEventMouseSubtype::TabletPoint));
app.sendEvent(&event(CGEventType::LeftMouseDragged, 1.0, (0.0, 0.0), CGEventMouseSubtype::Default));
{
let seen = seen.borrow();
assert_eq!(seen.len(), 2, "{seen:?}");
let s = seen[0].expect("pen sample");
assert!((s.pressure - 0.5).abs() < 0.01 && (s.tilt_x - 30.0).abs() < 0.1, "{s:?}");
assert_eq!(seen[1], None, "a mouse event resets to mouse");
}
drop(monitor);
app.sendEvent(&event(CGEventType::LeftMouseDragged, 0.5, (0.0, 0.0), CGEventMouseSubtype::TabletPoint));
assert_eq!(seen.borrow().len(), 2, "the monitor is gone after drop");
println!("monitor_macos: ok");
}
#[cfg(not(target_os = "macos"))]
fn main() {}

View File

@ -1310,8 +1310,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
}
// Pen pressure/tilt for this frame's tool events (mouse = 1.0).
// Pen pressure/tilt for this frame's tool events (mouse = 1.0), unless Preferences › Tools ›
// Use Tablet Pressure is off; the pen's eraser end selects the Eraser.
app.stylus.use_pressure = app.session.prefs().tools.use_tablet_pressure;
app.stylus.update(&ui.input(|i| i.events.clone()));
crate::stylus::Stylus::sync_eraser_tool(app);
let space_down = ui.input(|i| i.key_down(egui::Key::Space));
// Space while drawing a crop frame moves it instead of panning.
crate::crop_ui::set_space(app, space_down);
@ -2165,7 +2168,7 @@ mod tests {
app.ui.tool = Tool::Brush;
assert_eq!(ensure_texture(&mut app, &ctx, 0).map(|t| t.1), Some(0.5));
let m = egui::Modifiers::NONE;
app.stylus.feed.set(Some(crate::stylus::PenSample { pressure: 1.0, tilt_x: 60.0, tilt_y: 0.0, rotation: 0.0 }));
app.stylus.feed.set(Some(crate::stylus::PenSample { pressure: 1.0, tilt_x: 60.0, tilt_y: 0.0, rotation: 0.0, eraser: false }));
tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 45.0, pressure: 1.0 }, m);
for x in [300.0, 600.0, 900.0] {
tool_event(&mut app, ToolEvent::Move { x, y: 45.0, pressure: 1.0 }, m);

View File

@ -307,7 +307,7 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
let pen = (tilt("tiltX"), tilt("tiltY"), tilt("rotation"));
if pen != (None, None, None) {
let (tilt_x, tilt_y, rotation) = (pen.0.unwrap_or(0.0), pen.1.unwrap_or(0.0), pen.2.unwrap_or(0.0));
app.stylus.feed.set(Some(crate::stylus::PenSample { pressure: pr, tilt_x, tilt_y, rotation }));
app.stylus.feed.set(Some(crate::stylus::PenSample { pressure: pr, tilt_x, tilt_y, rotation, eraser: false }));
}
tool_event(app, ev, mods);
}

View File

@ -8,15 +8,20 @@
//! `egui::Event::Touch { force }` alongside the emulated pointer. [`Stylus::update`] reads it.
//! winit drops the pen's tilt and rotation, so those stay 0.
//! - **Web**: eframe forwards touch force but not pen pointer events, so the web runner listens
//! to `pointerdown`/`pointermove` itself and writes `pressure`, `tiltX`, `tiltY` and `twist`
//! of `pointerType == "pen"` events into the [`StylusFeed`].
//! - **macOS**: winit 0.30 reports no tablet pressure: the `NSEvent.pressure` of mouse-dragged
//! tablet events is discarded, and `pressureChangeWithEvent:` becomes
//! `WindowEvent::TouchpadPressure`, which egui-winit 0.36 ignores. Strokes paint at pressure 1
//! until winit/egui-winit forward it (no unsafe AppKit hooks here: the workspace forbids
//! `unsafe`).
//! - **Linux (X11/Wayland)**: winit 0.30 has no tablet (XInput2 valuator / tablet-v2) support;
//! pressure is 1.
//! to `pointerdown`/`pointermove` itself and writes `pressure`, `tiltX`, `tiltY`, `twist` and
//! the eraser button of `pointerType == "pen"` events into the [`StylusFeed`].
//! - **macOS**: winit 0.30 drops `NSEvent` tablet data, so the desktop app installs an AppKit
//! local event monitor (the `photocraft-tablet` crate) that writes pressure, tilt, rotation and
//! the eraser end into the [`StylusFeed`] before winit handles each event.
//! - **Linux X11**: the desktop app reads XInput2 raw valuator events on its own X connection
//! (`photocraft-tablet`, x11rb) and writes them into the [`StylusFeed`].
//! - **Linux Wayland**: no tablet input yet (`zwp_tablet_v2` would have to share winit's
//! connection); pressure is 1. Launching with `WAYLAND_DISPLAY=` runs the app under Xwayland,
//! which reports tablet valuators.
//!
//! Preferences › Tools › Use Tablet Pressure off makes a pen paint like a mouse. Flipping the pen
//! to its eraser end selects the Eraser tool and flipping back restores the previous tool, as in
//! Photoshop.
//!
//! Automation simulates a pen with `ui.pointer` events carrying `pressure`, `tiltX`, `tiltY`
//! and `rotation`.
@ -31,11 +36,13 @@ pub struct PenSample {
pub tilt_x: f32,
pub tilt_y: f32,
pub rotation: f32,
/// The pen's eraser end is in use.
pub eraser: bool,
}
impl Default for PenSample {
fn default() -> Self {
Self { pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, rotation: 0.0 }
Self { pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, rotation: 0.0, eraser: false }
}
}
@ -47,7 +54,9 @@ impl PenSample {
pressure: f(self.pressure, 0.0, 1.0, 1.0),
tilt_x: f(self.tilt_x, -90.0, 90.0, 0.0),
tilt_y: f(self.tilt_y, -90.0, 90.0, 0.0),
rotation: if self.rotation.is_finite() { self.rotation.rem_euclid(360.0) } else { 0.0 },
// `rem_euclid` rounds tiny negative angles up to exactly 360.
rotation: if self.rotation.is_finite() { self.rotation.rem_euclid(360.0) % 360.0 } else { 0.0 },
eraser: self.eraser,
}
}
}
@ -69,10 +78,16 @@ impl StylusFeed {
}
/// Per-app stylus state.
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug)]
pub struct Stylus {
/// Pen samples pushed by the platform (web runner) or by automation.
/// Pen samples pushed by the platform (desktop tablet monitor, web runner) or by automation.
pub feed: StylusFeed,
/// Preferences › Tools › Use Tablet Pressure: off, a pen paints like a mouse.
pub use_pressure: bool,
/// The pen end last seen (`Some(true)` = eraser), for the eraser tool switch.
end: Option<bool>,
/// The tool to restore when the pen tip comes back after the eraser end switched tools.
pub(crate) tool_before_eraser: Option<crate::state::Tool>,
/// Force of the touch/pen contact currently down (egui `Event::Touch`).
touch: Option<f32>,
/// The contact ended this frame: keep its force for this frame's last tool events, clear next frame.
@ -81,6 +96,12 @@ pub struct Stylus {
pub(crate) stroke: Vec<[f32; 3]>,
}
impl Default for Stylus {
fn default() -> Self {
Self { feed: StylusFeed::default(), use_pressure: true, end: None, tool_before_eraser: None, touch: None, lifted: false, stroke: Vec::new() }
}
}
impl Stylus {
/// Track touch/pen contacts from this frame's input events.
pub fn update(&mut self, events: &[egui::Event]) {
@ -101,11 +122,48 @@ impl Stylus {
}
}
/// The current pen sample, `None` for a mouse.
/// The current pen sample, `None` for a mouse (and for any pen while Use Tablet Pressure is
/// off).
pub fn sample(&self) -> Option<PenSample> {
if !self.use_pressure {
return None;
}
self.feed.get().or(self.touch.map(|pressure| PenSample { pressure, ..Default::default() }))
}
/// Did the pen just flip to its eraser end (`Some(true)`) or back to its tip (`Some(false)`)?
/// Each flip is reported once; a mouse in between changes nothing.
pub(crate) fn take_end_flip(&mut self) -> Option<bool> {
let eraser = self.feed.get()?.eraser;
let flipped = self.end.map_or(eraser, |e| e != eraser);
self.end = Some(eraser);
flipped.then_some(eraser)
}
/// Switch to the Eraser when the pen's eraser end comes in, and back to the previous tool when
/// the tip does (Photoshop). Not during a drag. Returns whether the tool changed.
pub fn sync_eraser_tool(app: &mut crate::PhotocraftApp) -> bool {
use crate::state::Tool;
if app.drag.is_some() {
return false;
}
match app.stylus.take_end_flip() {
Some(true) if app.ui.tool != Tool::Eraser => {
app.stylus.tool_before_eraser = Some(app.ui.tool);
app.ui.tool = Tool::Eraser;
true
}
Some(false) => match app.stylus.tool_before_eraser.take() {
Some(t) if app.ui.tool == Tool::Eraser => {
app.ui.tool = t;
true
}
_ => false,
},
_ => false,
}
}
/// Pressure for the next tool event (1 for a mouse).
pub fn pressure(&self) -> f32 {
self.sample().map_or(1.0, |s| s.pressure)
@ -180,8 +238,8 @@ mod tests {
fn feed_wins_and_is_sanitized() {
let mut s = Stylus::default();
s.update(&[touch(egui::TouchPhase::Start, Some(0.3))]);
s.feed.set(Some(PenSample { pressure: 0.6, tilt_x: 120.0, tilt_y: -30.0, rotation: -90.0 }));
assert_eq!(s.sample(), Some(PenSample { pressure: 0.6, tilt_x: 90.0, tilt_y: -30.0, rotation: 270.0 }));
s.feed.set(Some(PenSample { pressure: 0.6, tilt_x: 120.0, tilt_y: -30.0, rotation: -90.0, eraser: false }));
assert_eq!(s.sample(), Some(PenSample { pressure: 0.6, tilt_x: 90.0, tilt_y: -30.0, rotation: 270.0, eraser: false }));
s.feed.set(None);
assert_eq!(s.pressure(), 0.3);
}
@ -195,10 +253,10 @@ mod tests {
app.session.execute("layer.new.layer", json!({})).unwrap();
app.ui.tool = crate::state::Tool::Brush;
let m = egui::Modifiers::NONE;
app.stylus.feed.set(Some(PenSample { pressure: 0.2, tilt_x: 40.0, tilt_y: 0.0, rotation: 30.0 }));
app.stylus.feed.set(Some(PenSample { pressure: 0.2, tilt_x: 40.0, tilt_y: 0.0, rotation: 30.0, eraser: false }));
let pr = app.stylus.pressure();
tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 40.0, pressure: pr }, m);
app.stylus.feed.set(Some(PenSample { pressure: 0.9, tilt_x: 10.0, tilt_y: -5.0, rotation: 60.0 }));
app.stylus.feed.set(Some(PenSample { pressure: 0.9, tilt_x: 10.0, tilt_y: -5.0, rotation: 60.0, eraser: false }));
let pr = app.stylus.pressure();
tool_event(&mut app, ToolEvent::Move { x: 100.0, y: 40.0, pressure: pr }, m);
tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 40.0 }, m);
@ -222,10 +280,51 @@ mod tests {
s.begin_stroke();
s.record_point();
assert_eq!(s.stroke_points(&pts)[1], vec![10.0, 0.0, 0.7]);
s.feed.set(Some(PenSample { pressure: 0.7, tilt_x: 30.0, tilt_y: 10.0, rotation: 45.0 }));
s.feed.set(Some(PenSample { pressure: 0.7, tilt_x: 30.0, tilt_y: 10.0, rotation: 45.0, eraser: false }));
s.record_point();
let out = s.stroke_points(&pts);
assert_eq!(out[0], vec![0.0, 0.0, 0.5, 0.0, 0.0, 0.0]);
assert_eq!(out[2], vec![20.0, 0.0, 0.7, 30.0, 10.0, 45.0]);
}
#[test]
fn use_pressure_off_ignores_pen_and_touch() {
let mut s = Stylus { use_pressure: false, ..Default::default() };
s.update(&[touch(egui::TouchPhase::Start, Some(0.3))]);
s.feed.set(Some(PenSample { pressure: 0.2, tilt_x: 40.0, ..Default::default() }));
assert_eq!((s.sample(), s.pressure()), (None, 1.0));
s.begin_stroke();
assert_eq!(s.stroke_points(&[[1.0, 2.0, 1.0]]), vec![vec![1.0, 2.0, 1.0]], "no tilt either");
}
#[test]
fn eraser_end_switches_tools_once_and_never_mid_drag() {
use crate::canvas::{ToolEvent, tool_event};
use crate::state::Tool;
let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.session.execute("file.new", serde_json::json!({"width": 60, "height": 60})).unwrap();
app.ui.tool = Tool::Brush;
let pen = |eraser| Some(PenSample { pressure: 0.5, eraser, ..Default::default() });
// A mouse never switches.
assert!(!Stylus::sync_eraser_tool(&mut app));
app.stylus.feed.set(pen(true));
assert!(Stylus::sync_eraser_tool(&mut app));
assert_eq!(app.ui.tool, Tool::Eraser);
assert!(!Stylus::sync_eraser_tool(&mut app), "reported once");
// Flipping back during a drag waits for the drag to end.
tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 0.5 }, egui::Modifiers::NONE);
app.stylus.feed.set(pen(false));
assert!(!Stylus::sync_eraser_tool(&mut app));
assert_eq!(app.ui.tool, Tool::Eraser);
tool_event(&mut app, ToolEvent::Up { x: 20.0, y: 10.0 }, egui::Modifiers::NONE);
assert!(Stylus::sync_eraser_tool(&mut app));
assert_eq!(app.ui.tool, Tool::Brush, "the tip restores the previous tool");
// The eraser end while the Eraser is already chosen remembers nothing to restore.
app.ui.tool = Tool::Eraser;
app.stylus.feed.set(pen(true));
assert!(!Stylus::sync_eraser_tool(&mut app));
app.stylus.feed.set(pen(false));
assert!(!Stylus::sync_eraser_tool(&mut app));
assert_eq!(app.ui.tool, Tool::Eraser);
}
}

View File

@ -18,7 +18,7 @@ readiness; treat it as an upper bound.
| M3 Viewer app | ✅ | egui shell (Pro / Studio / Classic themes), 13+ codecs, native and web (trunk) builds |
| M4 Native format + engine | ✅ | 500+ commands, `.pcraft` (incremental, autosave, crash recovery), CLI, persistent preferences |
| M5 GPU compositor | 🟡 | wgpu compositor drives the canvas, layer effects, vector masks, artboards, pattern fills and every clip case included (≤1/255 vs CPU); Multichannel documents fall back to the CPU |
| M6 Paint + select | 🟡 | brush engine, all selection tools, multi-layer selection, snapping + smart guides, free transform + warp; stylus pressure on Windows (WM_POINTER via winit) and the web (Pointer Events, with tilt/twist); macOS and Linux wait on winit tablet support |
| M6 Paint + select | 🟡 | brush engine, all selection tools, multi-layer selection, snapping + smart guides, free transform + warp; stylus pressure on Windows (WM_POINTER via winit), the web (Pointer Events, with tilt/twist), macOS (AppKit event monitor: pressure, tilt, rotation, eraser end) and Linux X11 (XInput2 raw valuators); Linux Wayland (tablet-v2) still open (#79) |
| M7 Adjust + filters | 🟡 | 16 adjustment layers + destructive-only adjustments, 70+ filters incl. Blur Gallery, Actions record/replay, Fade |
| M8 PSD v2 | 🟡 | adjustments (incl. Selective Color, Color Lookup), fills, effects, patterns, text, shapes, smart objects, alpha channels; oracle 111/170 |
| M9 Text, vector, styles | 🟡 | type engine + Warp Text, shapes / pen / paths, all 10 effects on CPU and GPU (parity ≤1/255, 30/31 corpus effect files on the GPU) |
@ -67,7 +67,7 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either
| Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. |
| UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157) | low–medium | Needs recurring visual QA with realistic documents. |
| Tools | ~20 Photoshop tools missing: Pencil, Mixer Brush (tool), Patch, Content-Aware Move, Red Eye, Pattern Stamp, Art History Brush, Freeform/Curvature Pen, anchor tools, Direct Selection, Magnetic Lasso, single row/column marquee, Color Sampler, Perspective Crop, Rotate View, the Vertical Type tool (vertical layout itself landed, #199: toggle via Type › Orientation) and type masks, Frame | low–medium | Magic/Background Eraser added; live gradients in progress (#180). |
| Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pressure on Windows and web only | medium | macOS/Linux pressure blocked on the windowing layer (#79). |
| Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Wayland pen input open (#79); macOS/X11 pressure not yet verified on tablet hardware. |
| Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. |
| Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. |
| Performance | 14k+ px on the GPU at ~⅓ the memory; adjustment preview 285 ms → 4–9 ms; font-size edits 297 ms → 4.6 ms | medium-high on rasters | Complex layout documents still laggy (#125/#128); >16384 px GPU tiling in progress (#49). |
@ -93,7 +93,7 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either
6. **Recurring visual QA** (`cargo run -p photocraft-engine --example designer_psd`) and fast
turnaround on user reports (OS, document size, layer count, screenshot).
7. Later / needs decisions: generative AI backend (#41), scripting compatibility (ExtendScript /
UXP / .atn), Flathub (#173), macOS/Linux pen pressure (#79).
UXP / .atn), Flathub (#173), Wayland pen pressure (#79).
## Current focus (infrastructure before the long tail)

View File

@ -9,10 +9,10 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| [Performance](#performance) | 3 | 22 | 10 | 44 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 0 not run, 10 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 0 | 6 | 21 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 126/256, round trip ≥ 256/256 |
| [Tools](#tools) | 0 | 16 | 14 | |
| [Workspace and UI](#workspace-and-ui) | 0 | 3 | 20 | settings that do nothing: 61 of 130 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 0 | 3 | 20 | settings that do nothing: 61 of 131 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 1 | 3 | 7 | 626 of 626 menu items live (100.0%) |
| [Reliability](#reliability) | 2 | 1 | 4 | never-crash lints on 25 of 25 crates; panic_hunt present |
| [Reliability](#reliability) | 2 | 1 | 4 | never-crash lints on 26 of 26 crates; panic_hunt present |
| [Distribution](#distribution) | 4 | 2 | 4 | |
| [Open bugs](#open-bugs) | 0 | 3 | 29 | |
@ -151,7 +151,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
## Workspace and UI
**Settings that do nothing: 61 of 130** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
**Settings that do nothing: 61 of 131** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
Unread: `general.color_picker`, `general.beep_when_done`, `general.export_clipboard`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `general.use_legacy_free_transform`, `interface.ui_font_size`, `interface.show_channels_in_color`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.open_documents_as_tabs`, `workspace.enable_floating_document_window_docking`, `workspace.large_tabs`, `workspace.enable_narrow_options_bar`, `tools.zoom_clicked_point_to_center`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `tools.overscroll`, `tools.double_click_layer_mask_launches_select_and_mask`, `file_handling.image_previews`, `file_handling.lowercase_extension`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.ask_before_saving_layered_tiff`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `units_and_rulers.screen_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.use_esc_to_commit`, `type_.text_engine`, `type_.font_preview`, `type_.fill_new_type_layers_with_placeholder`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `enhanced_controls.rotate_view_with_trackpad`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
@ -215,7 +215,7 @@ Menu wiring (`docs/parity.md`): 626 of 626 menu items live (100.0%). This counts
## Reliability
Never-crash attribute on 25 of 25 crate roots.
Never-crash attribute on 26 of 26 crate roots.
| Id | Target | Status | Issue | Note |
|---|---|---|---|---|

View File

@ -41,6 +41,8 @@ pub const TABLE: &[(&str, Class)] = &[
("codecs", Class::Standalone),
("raw", Class::Standalone),
("adobe-assets", Class::Standalone),
// Pen tablet input (the one isolated `unsafe` helper: AppKit interop on macOS).
("tablet", Class::Standalone),
("doc", Class::Layer(1)),
("ops", Class::Layer(2)),
("paint", Class::Layer(2)),