This commit is contained in:
parent
02c07eb021
commit
47f4abfed4
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|---|---|---|---|---|
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
Loading…
Reference in New Issue