diff --git a/Cargo.lock b/Cargo.lock index 05f7de9cf2..201ca61db0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5531,10 +5531,13 @@ name = "photocraft-tablet" version = "0.20.1" dependencies = [ "block2 0.6.2", + "log", "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-graphics", "objc2-foundation 0.3.2", + "windows", + "wintab", "x11rb", ] @@ -9208,6 +9211,25 @@ version = "0.0.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" +[[package]] +name = "wintab" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e05efdae68aa69ef1f4bc0148a5c6cf7f2bc234963a723252fd75f2f9e70d86" +dependencies = [ + "windows", + "wintab-sys", +] + +[[package]] +name = "wintab-sys" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3d0743ba07fe30ca1ccc1471ee50adf32bc70932b86a00c8f1051177950de2" +dependencies = [ + "windows", +] + [[package]] name = "winx" version = "0.36.4" diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index c599e16ab6..7d9f4f4e03 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -670,6 +670,14 @@ fn main() -> eframe::Result { if let Some(monitor) = tablet::install_windows() { let _ = tablet_monitor_win.set(monitor); } + // With Windows Ink disabled for the tablet, WinTab supplies the pen pressure, tilt, + // rotation and eraser end; the vendor driver still moves the cursor. + #[cfg(target_os = "windows")] + if let Some(poll) = + tablet::install_wintab(cc, &app.stylus.feed, app.session.prefs().tools.tablet_input == photocraft_engine::prefs::TabletInput::WinTab) + { + app.services.tablet_poll = Some(poll); + } #[cfg(target_os = "linux")] tablet::spawn_x11(&app.stylus.feed, display); app.services.right_button_down = tablet::right_button_service(); diff --git a/apps/photocraft/src/tablet.rs b/apps/photocraft/src/tablet.rs index 19ba28996c..224b682075 100644 --- a/apps/photocraft/src/tablet.rs +++ b/apps/photocraft/src/tablet.rs @@ -6,7 +6,9 @@ //! 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. On Windows, winit forwards `WM_POINTER` pressure //! as touch force, but drops `PEN_FLAG_BARREL` (`0x0001`); `photocraft-tablet` recovers the barrel -//! button through a thread-local message hook beside winit. +//! button through a thread-local message hook beside winit. With Windows Ink disabled for the +//! tablet, the optional WinTab mode reads pressure, tilt, rotation and the eraser end from a +//! WinTab 1.4 system context instead ([`install_wintab`]). //! //! Wayland compositors give a pen only to clients that bind the tablet protocol //! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs @@ -39,6 +41,41 @@ pub fn install_windows() -> Option { photocraft_tablet::windows::Monitor::install().map_err(|e| log::warn!("{e}")).ok() } +/// Installs the WinTab backend on the real PhotoCraft window and returns its per-frame poll. +/// +/// `enabled` is Preferences > Tools > Pen Tablet > Tablet input set to WinTab. With Windows Ink disabled there, the vendor +/// driver still moves the cursor; this backend only supplies pressure/tilt/rotation/eraser +/// through the normal stylus feed. On failure it logs why and leaves Windows Ink untouched. +#[cfg(target_os = "windows")] +pub fn install_wintab(cc: &eframe::CreationContext<'_>, feed: &StylusFeed, enabled: bool) -> Option { + if !enabled { + return None; + } + use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle}; + let hwnd = match cc.window_handle().ok()?.as_raw() { + RawWindowHandle::Win32(handle) => handle.hwnd.get(), + _ => return None, + }; + let ctx = cc.egui_ctx.clone(); + if let Err(error) = photocraft_tablet::wintab::install(hwnd, Box::new(move || ctx.request_repaint())) { + log::warn!("WinTab backend: {error}"); + return None; + } + let sink = sink(feed.clone()); + log::info!("WinTab backend active (system context)"); + let logged = std::cell::Cell::new(false); + Some(Box::new(move |focused| { + photocraft_tablet::wintab::poll(focused, &|sample| { + if let Some(sample) = sample + && !logged.replace(true) + { + log::info!("WinTab sample: pressure={:.3} tilt=({:.1},{:.1}) eraser={}", sample.pressure, sample.tilt_x, sample.tilt_y, sample.eraser); + } + sink(sample); + }); + })) +} + /// Stylus barrel button down check (Windows). The right mouse button is left to egui, which /// reports the logical secondary button (swapped buttons included) for the app's own window only. #[cfg(target_os = "windows")] diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 6a4413fe9a..ee61af791c 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -100,6 +100,11 @@ choice!( /// pointer (Photoshop), or erase with the current brush while dragging (Krita, Paint). RightClickPaint { BrushPicker = "brushPicker", Erase = "erase" } default BrushPicker ); +choice!( + /// Windows: which pen input supplies pressure. Windows Ink is the default; WinTab needs + /// Windows Ink turned off in the tablet driver's utility and a restart. + TabletInput { WindowsInk = "windowsInk", WinTab = "winTab" } default WindowsInk +); choice!(OtherCursor { Standard = "standard", Precise = "precise" } default Standard); choice!(CheckerSize { None = "none", Small = "small", Medium = "medium", Large = "large" } default Medium); choice!(CheckerColors { Light = "light", Medium = "medium", Dark = "dark", Red = "red", Orange = "orange", Green = "green", Blue = "blue", Purple = "purple", Custom = "custom" } default Light); @@ -392,6 +397,9 @@ pub struct 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, + /// Windows: which pen input supplies pressure. Windows Ink is the default; WinTab needs + /// Windows Ink turned off in the tablet driver's utility and a restart. + pub tablet_input: TabletInput, /// Pen pressure response: `[input, output]` control points in 0..1 that the pen's pressure /// passes through before it reaches any brush ([`PressureCurve`]). Linear by default. pub pressure_curve: Vec<[f32; 2]>, @@ -419,6 +427,7 @@ impl Default for Tools { double_click_layer_mask_launches_select_and_mask: true, right_click_with_painting_tools: RightClickPaint::BrushPicker, use_tablet_pressure: true, + tablet_input: TabletInput::WindowsInk, pressure_curve: vec![[0.0, 0.0], [1.0, 1.0]], bundle_nudges: true, nudge_bundle_pause_ms: 1000, diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 026b6befc3..351ce2883c 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -512,6 +512,20 @@ fn svg_group_rasterization_threshold_is_bounded_by_document_depth() { assert_eq!(s.prefs().file_handling.rasterize_svg_groups_deeper_than, 0, "out-of-range preferences leave the valid value unchanged"); } +#[test] +fn tablet_input_pref_defaults_to_windows_ink() { + let mut s = session(); + assert_eq!(s.prefs().tools.tablet_input, TabletInput::WindowsInk); + assert_eq!(s.execute("prefs.get", json!({"path": "tools.tabletInput"})).unwrap(), json!("windowsInk")); + assert_eq!(choices("tools.tabletInput"), Some(TabletInput::NAMES)); + s.execute("prefs.set", json!({"path": "tools.tabletInput", "value": "winTab"})).unwrap(); + assert_eq!(s.prefs().tools.tablet_input, TabletInput::WinTab); + for bad in [json!("ink"), json!(1), json!(true), json!(null)] { + assert!(s.execute("prefs.set", json!({"path": "tools.tabletInput", "value": bad})).is_err()); + } + assert_eq!(s.prefs().tools.tablet_input, TabletInput::WinTab, "rejected values change nothing"); +} + #[test] fn right_click_with_painting_tools_pref() { let mut s = session(); diff --git a/crates/engine/src/prefs/validate.rs b/crates/engine/src/prefs/validate.rs index 175d33f26a..f9c0b9f805 100644 --- a/crates/engine/src/prefs/validate.rs +++ b/crates/engine/src/prefs/validate.rs @@ -2,8 +2,8 @@ use super::shortcuts::normalize_shortcut; use super::{ AppearanceMode, Ask, CanvasBorder, CanvasColor, CheckerColors, CheckerSize, ColorPicker, DarkTheme, ExportLocation, ExportMetadata, FontPreview, GpuBackend, Interpolation, LightTheme, LineStyle, LinuxDisplayServer, LogDestination, LogDetail, OtherCursor, PaintingCursor, PlaybackPerformance, - PointSize, PressureCurve, QuickExportFormat, RawColorSpace, RawDemosaic, RawDepth, RawSharpen, RenderingMode, RightClickPaint, TextEngine, Theme, TypeUnit, - UiFontSize, UiScale, Unit, Value, + PointSize, PressureCurve, QuickExportFormat, RawColorSpace, RawDemosaic, RawDepth, RawSharpen, RenderingMode, RightClickPaint, TabletInput, TextEngine, + Theme, TypeUnit, UiFontSize, UiScale, Unit, Value, }; /// Preferences dialog sections in Photoshop's order: (id, title). @@ -88,6 +88,7 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> { "cursors.painting" => PaintingCursor::NAMES, "cursors.other" => OtherCursor::NAMES, "tools.rightClickWithPaintingTools" => RightClickPaint::NAMES, + "tools.tabletInput" => TabletInput::NAMES, "transparencyAndGamut.gridSize" => CheckerSize::NAMES, "transparencyAndGamut.gridColors" => CheckerColors::NAMES, "unitsAndRulers.rulers" | "guidesGridAndSlices.gridUnit" => Unit::NAMES, diff --git a/crates/tablet/Cargo.toml b/crates/tablet/Cargo.toml index e14e4a5ae0..cc68771f61 100644 --- a/crates/tablet/Cargo.toml +++ b/crates/tablet/Cargo.toml @@ -3,7 +3,7 @@ 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), and barrel button on Windows" +description = "Pen tablet pressure, tilt, rotation and eraser end on macOS (AppKit), Linux X11 (XInput2) and Windows (WinTab), and barrel button on Windows" # The one isolated `unsafe` helper crate (see AGENTS.md, Never crash): the AppKit event # monitor on macOS and the WH_GETMESSAGE pointer hook on Windows need platform interop. Only @@ -31,6 +31,15 @@ objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std [target.'cfg(target_os = "linux")'.dependencies] x11rb = { version = "=0.13.2", default-features = false, features = ["xinput"] } +# WinTab 1.4: a system context on PhotoCraft's real window supplies pressure, tilt, rotation and +# the eraser end while Windows Ink is disabled. `wintab` safely wraps the vendor's wintab32.dll, +# loaded at run time; only the Windows target builds it. `windows` is pinned to the version +# `wintab` uses. +[target.'cfg(target_os = "windows")'.dependencies] +log = { workspace = true } +wintab = "=0.1.1" +windows = { version = "=0.62.2", features = ["Win32_Foundation"] } + # 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"] } diff --git a/crates/tablet/src/lib.rs b/crates/tablet/src/lib.rs index 41b48f9966..ca158a9137 100644 --- a/crates/tablet/src/lib.rs +++ b/crates/tablet/src/lib.rs @@ -8,6 +8,9 @@ //! - **Windows** ([`windows::Monitor`]): a thread-local message hook inspects `WM_POINTER*` //! messages on the UI thread's message queue to read `PEN_FLAG_BARREL` (`GetPointerPenInfo`), //! which winit 0.30 drops by mapping pen inputs to touch events. +//! - **Windows WinTab** ([`wintab`]): with Windows Ink disabled for the tablet, a WinTab 1.4 +//! system context on PhotoCraft's window supplies pressure, tilt, rotation and the eraser end. +//! The vendor driver still moves the cursor; [`wintab::poll`] is called once per frame. //! AppKit's monitor API and Windows' thread hook need OS interop, so `macos.rs` and `windows.rs` //! allow `unsafe` (an isolated, tested helper crate with `SAFETY:` //! comments on every block). @@ -37,6 +40,8 @@ pub mod xi; pub mod macos; #[cfg(target_os = "windows")] pub mod windows; +#[cfg(target_os = "windows")] +pub mod wintab; #[cfg(target_os = "linux")] pub mod x11; diff --git a/crates/tablet/src/windows.rs b/crates/tablet/src/windows.rs index eb5b2b28c2..c8426fefa5 100644 --- a/crates/tablet/src/windows.rs +++ b/crates/tablet/src/windows.rs @@ -102,6 +102,9 @@ unsafe extern "system" fn get_msg_proc(code: i32, w_param: usize, l_param: isize // SAFETY: Win32 guarantees that for code >= 0, l_param points to a valid MSG struct // dispatched to this thread's message queue. let msg = unsafe { &*(l_param as *const MSG) }; + // The WinTab backend shares this thread hook: it decodes the driver's proximity + // messages (and ignores everything else) to clear a pen that left the tablet. + crate::wintab::on_message(msg.message, msg.w_param, msg.l_param); match msg.message { WM_POINTERDOWN | WM_POINTERUPDATE => { let pointer_id = (msg.w_param & 0xFFFF) as u32; diff --git a/crates/tablet/src/wintab.rs b/crates/tablet/src/wintab.rs new file mode 100644 index 0000000000..3193ae24f1 --- /dev/null +++ b/crates/tablet/src/wintab.rs @@ -0,0 +1,334 @@ +//! WinTab 1.4 backend for Windows. +//! +//! The app opens a WinTab context on its real top-level window and polls queued packets once per +//! frame. This module only translates Wintab packets into [`Sample`]s; it does not synthesise +//! pointer events. With Windows Ink disabled for the tablet, the vendor driver provides the +//! mouse cursor while this module provides pressure, tilt, rotation and the eraser flag. +//! +//! Devices without orientation axes (pressure-only tablets) are opened with a packet that asks +//! only for the fields the driver reports; their tilt is neutral rather than a made-up value. +//! +//! The context is thread-local because Wintab contexts belong to the thread and window that +//! opened them. [`install`] and [`poll`] must therefore run on the UI thread. + +use std::cell::RefCell; +use std::ffi::c_void; +use std::time::{Duration, Instant}; + +use windows::Win32::Foundation::{HWND, LPARAM, WPARAM}; + +use crate::{Error, Sample}; + +wintab::packet! { + struct PenPacket { status, buttons, x, y, normal_pressure, orientation } +} + +wintab::packet! { + struct PressurePacket { status, buttons, x, y, normal_pressure } +} + +/// The open context. A device that reports orientation gets tilt and rotation; a pressure-only +/// device gets a packet without the orientation field, so a zeroed field never becomes a tilt. +enum Backend { + Oriented { context: wintab::Context, axes: [wintab::Axis; 3] }, + PressureOnly { context: wintab::Context }, +} + +struct State { + backend: Backend, + pressure_axis: wintab::Axis, + last_packet: Instant, + emitted: bool, + focused: bool, + /// The pen is inside the tablet's sensing range (`WT_PROXIMITY`, hardware transitions). + proximity_in: bool, + /// Requests a frame when a WinTab message arrives, so pressure-only strokes update without + /// cursor movement. + wake: Box, +} + +thread_local! { + static STATE: RefCell> = const { RefCell::new(None) }; +} + +/// Opens a WinTab system context on `hwnd`. +/// +/// Call on the UI thread. A previous context on this thread is replaced, so the caller keeps the +/// app's normal single-window lifetime. The context is polled with [`poll`]. `wake` is called from +/// the thread's message hook for every WinTab message: a frame is then requested even when the +/// pen only changes pressure (no cursor movement, no other input event). +pub fn install(hwnd: isize, wake: Box) -> Result<(), Error> { + if hwnd == 0 { + return Err(Error::Unsupported("no window handle".into())); + } + let wintab = wintab::Wintab::load().map_err(|e| Error::Platform(e.to_string()))?; + let device = wintab.device(0).ok_or_else(|| Error::Unsupported("no Wintab digitizer".into()))?; + let pressure_axis = device.pressure.ok_or_else(|| Error::Unsupported("Wintab device has no pressure axis".into()))?; + let hwnd = HWND(hwnd as *mut c_void); + let options = wintab::ContextOptions::new().system(true).name("PhotoCraft WinTab"); + // Ask only for the fields the device reports: a pressure-only tablet never fills + // orientation, and reading a zeroed orientation would invent a maximum tilt. + let backend = match device.orientation { + Some(axes) => { + let context = wintab.open::(hwnd, &options).map_err(|e| Error::Platform(e.to_string()))?; + if let Err(error) = context.overlap(true) { + log::warn!("WinTab: could not take overlap priority: {error}"); + } + Backend::Oriented { context, axes } + } + None => { + let context = wintab.open::(hwnd, &options).map_err(|e| Error::Platform(e.to_string()))?; + if let Err(error) = context.overlap(true) { + log::warn!("WinTab: could not take overlap priority: {error}"); + } + Backend::PressureOnly { context } + } + }; + STATE.with_borrow_mut(|slot| { + *slot = Some(State { backend, pressure_axis, last_packet: Instant::now(), emitted: false, focused: false, proximity_in: true, wake }); + }); + Ok(()) +} + +/// Drains queued packets and reports the newest sample. +/// +/// Reports `Some` while a pen is in use, `None` once the pen has left the tablet. Leaving is the +/// packet's proximity status, not a quiet period: a pen held still may send no packets for a +/// while, and treating that as left painted a held airbrush build-up at mouse pressure. A long +/// timeout remains only as a fallback for drivers that never report the leave. +/// +/// Coordinates in the packet are not used: with Windows Ink off, the vendor driver moves the +/// normal cursor, and PhotoCraft's existing pointer path supplies the position. +/// +/// `focused` is whether the app's window has focus. Another tablet application can take the top +/// WinTab overlap priority; when PhotoCraft is activated again it has to ask for it back. The +/// window procedure belongs to winit, so focus from the app's input is the activation signal. +pub fn poll(focused: bool, callback: &dyn Fn(Option)) { + STATE.with_borrow_mut(|slot| { + let Some(state) = slot.as_mut() else { return }; + if focused && !state.focused { + let result = match &state.backend { + Backend::Oriented { context, .. } => context.overlap(true), + Backend::PressureOnly { context } => context.overlap(true), + }; + if let Err(error) = result { + log::warn!("WinTab: could not take overlap priority: {error}"); + } + } else if !focused && state.focused { + let result = match &state.backend { + Backend::Oriented { context, .. } => context.overlap(false), + Backend::PressureOnly { context } => context.overlap(false), + }; + if let Err(error) = result { + log::warn!("WinTab: could not release overlap priority: {error}"); + } + } + state.focused = focused; + let State { backend, pressure_axis, last_packet, emitted, proximity_in, .. } = state; + match backend { + Backend::Oriented { context, axes } => { + let mut packets = Vec::new(); + context.drain(&mut packets); + for packet in packets { + if packet.status.out_of_context() { + if *emitted { + *emitted = false; + callback(None); + } + continue; + } + let sample = sample(&packet, pressure_axis, axes); + *last_packet = Instant::now(); + *emitted = true; + callback(Some(sample)); + } + } + Backend::PressureOnly { context } => { + let mut packets = Vec::new(); + context.drain(&mut packets); + for packet in packets { + if packet.status.out_of_context() { + if *emitted { + *emitted = false; + callback(None); + } + continue; + } + let sample = sample_pressure(&packet, pressure_axis); + *last_packet = Instant::now(); + *emitted = true; + callback(Some(sample)); + } + } + } + if !*proximity_in && *emitted { + *emitted = false; + callback(None); + } + if *emitted && last_packet.elapsed() >= Duration::from_secs(60) { + *emitted = false; + callback(None); + } + }); +} + +/// Handles a WinTab window message from the thread's message hook (`windows.rs`). +/// +/// A hardware proximity leave is a `WT_PROXIMITY` message, not a packet status: the context's +/// `TPS_PROXIMITY` status only says the cursor left the context. Clearing the feed here covers +/// drivers that send the hardware leave without a following out-of-context packet. +pub(crate) fn on_message(message: u32, wparam: usize, lparam: isize) { + // This runs inside the Win32 hook, where a panic aborts the app: a driver call under `poll` + // that pumps this thread's messages would find `STATE` borrowed. Such a message is skipped. + let _ = STATE.try_with(|cell| { + let Ok(mut slot) = cell.try_borrow_mut() else { return }; + let Some(state) = slot.as_mut() else { return }; + let decoded = match &state.backend { + Backend::Oriented { context, .. } => context.message(message, WPARAM(wparam), LPARAM(lparam)), + Backend::PressureOnly { context } => context.message(message, WPARAM(wparam), LPARAM(lparam)), + }; + let win_message = decoded.is_some(); + if let Some(wintab::Message::Proximity { entering, hardware }) = decoded + && hardware + { + state.proximity_in = entering; + } + if win_message { + // Packets can arrive with no cursor movement; the frame then polls them. + (state.wake)(); + } + }); +} + +/// Closes the context on this thread. +pub fn uninstall() { + STATE.with_borrow_mut(|slot| *slot = None); +} + +/// One packet from a device with orientation axes as the UI's pen sample. +/// +/// Angles come from the device's own axes (`TU_CIRCLE`), falling back to Wintab's usual tenths +/// of a degree when an axis does not measure angles. +fn sample(packet: &PenPacket, pressure: &wintab::Axis, orientation: &[wintab::Axis; 3]) -> Sample { + let raw = packet.orientation; + let degrees = |index: usize, value: i32| orientation[index].degrees(value).map_or(value as f32 / 10.0, |degrees| degrees as f32); + let azimuth = degrees(0, raw.azimuth).to_radians(); + // Wintab altitude is the angle from the tablet plane (0 = flat, 90 = upright); W3C tilt is + // the angle from the perpendicular, per axis: atan2 of the in-plane projection over height. + // Wintab azimuth 0 points at the tablet's top and grows clockwise, while W3C +x is right and + // +y towards the user, so the in-plane components are (sin(azimuth), -cos(azimuth)). + let elevation = degrees(1, raw.altitude).abs().to_radians(); + let in_plane_x = elevation.cos() * azimuth.sin(); + let in_plane_y = -(elevation.cos() * azimuth.cos()); + let height = elevation.sin(); + Sample { + pressure: pressure.normalize(packet.normal_pressure as i32), + tilt_x: in_plane_x.atan2(height).to_degrees(), + tilt_y: in_plane_y.atan2(height).to_degrees(), + rotation: degrees(2, raw.twist), + eraser: packet.status.inverted(), + } + .sanitized() +} + +/// One packet from a device without orientation axes: pressure and the eraser end, neutral tilt. +fn sample_pressure(packet: &PressurePacket, pressure: &wintab::Axis) -> Sample { + Sample { pressure: pressure.normalize(packet.normal_pressure as i32), tilt_x: 0.0, tilt_y: 0.0, rotation: 0.0, eraser: packet.status.inverted() } + .sanitized() +} + +#[cfg(test)] +mod tests { + use super::*; + use wintab::Units; + + fn pressure_axis() -> wintab::Axis { + wintab::Axis { min: 0, max: 32767, units: Units::None, resolution: 0.0 } + } + + fn angle_axis() -> wintab::Axis { + wintab::Axis { min: 0, max: 3599, units: Units::Circle, resolution: 3600.0 } + } + + fn axes() -> [wintab::Axis; 3] { + [angle_axis(), angle_axis(), angle_axis()] + } + + fn packet(normal_pressure: u32) -> PenPacket { + PenPacket { status: wintab::Status(0), buttons: wintab::Buttons(0), x: 0, y: 0, normal_pressure, orientation: wintab::Orientation::default() } + } + + fn pressure_packet(normal_pressure: u32) -> PressurePacket { + PressurePacket { status: wintab::Status(0), buttons: wintab::Buttons(0), x: 0, y: 0, normal_pressure } + } + + /// Pressure is normalized through the device's own axis. + #[test] + fn pressure_maps_through_the_device_axis() { + let axes = axes(); + let s = sample(&packet(16384), &pressure_axis(), &axes); + assert!((s.pressure - 0.5).abs() < 0.01, "{}", s.pressure); + let s = sample(&packet(0), &pressure_axis(), &axes); + assert_eq!(s.pressure, 0.0); + } + + /// The inverted status bit is the eraser end. + #[test] + fn eraser_comes_from_the_inverted_status() { + let axes = axes(); + let inverted = PenPacket { status: wintab::Status(wintab::sys::TPS_INVERT), ..packet(0) }; + assert!(sample(&inverted, &pressure_axis(), &axes).eraser); + assert!(!sample(&packet(0), &pressure_axis(), &axes).eraser); + } + + /// Orientation axes are converted to degrees for the W3C tilt projection. + #[test] + fn orientation_axes_report_degrees() { + let axes = axes(); + let tilted = PenPacket { orientation: wintab::Orientation { azimuth: 450, altitude: 300, twist: 900 }, ..packet(0) }; + let sample = sample(&tilted, &pressure_axis(), &axes); + assert!((sample.tilt_x - 50.8).abs() < 0.1, "{}", sample.tilt_x); + assert!((sample.tilt_y + 50.8).abs() < 0.1, "{}", sample.tilt_y); + assert!((sample.rotation - 90.0).abs() < 0.1, "{}", sample.rotation); + } + + /// A pressure-only device keeps pressure and the eraser end, with neutral tilt. + #[test] + fn pressure_only_devices_get_pressure_and_neutral_tilt() { + let s = sample_pressure(&pressure_packet(16384), &pressure_axis()); + assert!((s.pressure - 0.5).abs() < 0.01, "{}", s.pressure); + assert_eq!((s.tilt_x, s.tilt_y, s.rotation), (0.0, 0.0, 0.0)); + let inverted = PressurePacket { status: wintab::Status(wintab::sys::TPS_INVERT), ..pressure_packet(0) }; + assert!(sample_pressure(&inverted, &pressure_axis()).eraser); + } + + /// Wintab azimuth 0 is the tablet top; W3C axes are right and toward the user. + #[test] + fn cardinal_azimuths_match_w3c_tilt() { + let axes = axes(); + let tilt = |azimuth: i32| { + let p = PenPacket { orientation: wintab::Orientation { azimuth, altitude: 450, twist: 0 }, ..packet(0) }; + sample(&p, &pressure_axis(), &axes) + }; + let top = tilt(0); + assert!(top.tilt_x.abs() < 0.1 && (top.tilt_y + 45.0).abs() < 0.1, "{top:?}"); + let right = tilt(900); + assert!((right.tilt_x - 45.0).abs() < 0.1 && right.tilt_y.abs() < 0.1, "{right:?}"); + let bottom = tilt(1800); + assert!(bottom.tilt_x.abs() < 0.1 && (bottom.tilt_y - 45.0).abs() < 0.1, "{bottom:?}"); + let left = tilt(2700); + assert!((left.tilt_x + 45.0).abs() < 0.1 && left.tilt_y.abs() < 0.1, "{left:?}"); + } + + /// An upright pen has no tilt; a flat one has the most. + #[test] + fn an_upright_pen_has_no_tilt_and_a_flat_one_has_the_most() { + let axes = axes(); + let upright = PenPacket { orientation: wintab::Orientation { azimuth: 0, altitude: 900, twist: 0 }, ..packet(0) }; + let s = sample(&upright, &pressure_axis(), &axes); + assert!(s.tilt_x.abs() < 0.1 && s.tilt_y.abs() < 0.1, "{s:?}"); + let flat = PenPacket { orientation: wintab::Orientation { azimuth: 0, altitude: 0, twist: 0 }, ..packet(0) }; + let s = sample(&flat, &pressure_axis(), &axes); + assert!(s.tilt_x.abs() < 0.1 && (s.tilt_y + 90.0).abs() < 0.1, "{s:?}"); + } +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 34c2ef16e1..0a77bbdf3b 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -245,8 +245,12 @@ impl eframe::App for PhotocraftApp { self.finish_type_reset_logic(ctx); } + /// Drain the platform's optional WinTab queue before the frame's input is processed. fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { let frame_start = gpu_canvas::now_ms(); + if let Some(tablet_poll) = self.services.tablet_poll.as_mut() { + tablet_poll(raw_input.focused); + } // An undecorated window gets its caption back when the toolkit changes its flags // (maximize, restore, …): take it off again before the frame is laid out (#2246). if let Some(window_frame) = self.services.window_frame.as_mut() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 25d77a32e2..794f1c7e26 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -261,7 +261,7 @@ pub use services::{ CaptionStyleFn, ClipboardGetFn, ClipboardGetSvgFn, ClipboardSetFn, ClipboardUnreadableFn, CursorPosFn, DiscardAutosaveFn, EncodePngFn, ExportFn, ExportSettings, ExternalSvgEditor, ExternalSvgSession, ExternalVectorSave, ExternalVectorSource, FileStamp, FileStampFn, FormatChoices, ImportFn, Inbox, LoadTextFn, OpenExternalSvgFn, OpenUrlFn, OsEventsFn, PhotoshopShortcutsFn, PlaceInbox, QuitFn, RecoverFn, Recoverable, RightButtonDownFn, SameFileFn, - SaveFileFn, SaveTextFn, Services, SystemThemeFn, WindowFrameFn, WriteFn, + SaveFileFn, SaveTextFn, Services, SystemThemeFn, TabletPollFn, WindowFrameFn, WriteFn, }; pub use text::bidi_text; pub use text::cjk_fonts; diff --git a/crates/ui-egui/src/prefs/prefs_ui/fields.rs b/crates/ui-egui/src/prefs/prefs_ui/fields.rs index 6b02ddf785..65c25dbc14 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/fields.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/fields.rs @@ -101,8 +101,11 @@ pub(super) fn humanize(key: &str) -> String { .replace("Lut ", "LUT ") } +/// The visible label of a stored choice value (`windowsInk` -> "Windows Ink (default)"). pub(super) fn choice_label(v: &str) -> String { match v { + "windowsInk" => "Windows Ink (default)".into(), + "winTab" => "WinTab".into(), "exr" => "EXR".into(), "cm" => "Centimeters".into(), "mm" => "Millimeters".into(), @@ -148,7 +151,11 @@ pub(super) fn color_of(s: &str) -> Color32 { /// Search the same visible, editable preference labels used in the dialog. pub(super) fn preference_matches(section: &str, key: &str, query: &str) -> bool { let label = humanize(key); - format!("{section}.{key}").to_lowercase().contains(query) || label.to_lowercase().contains(query) || tl!(&label).to_lowercase().contains(query) + format!("{section}.{key}").to_lowercase().contains(query) + || label.to_lowercase().contains(query) + || tl!(&label).to_lowercase().contains(query) + // The Tablet input row and its Pen Tablet note are the visible terms, not just the key. + || (cfg!(target_os = "windows") && section == "tools" && key == "tabletInput" && "windows ink pen tablet wintab tablet input".contains(query)) } pub(super) fn preference_sections(values: &Value, search: &str) -> Vec<(&'static str, &'static str)> { @@ -298,8 +305,13 @@ pub(super) fn export_field_visible(obj: &Map, key: &str) -> bool } /// The pause that bundles arrow-key nudges means nothing with Bundle Arrow-Key Nudges off (#2259). +/// WinTab is a Windows-only opt-in. pub(super) fn tools_field_visible(obj: &Map, key: &str) -> bool { - key != "nudgeBundlePauseMs" || obj.get("bundleNudges").and_then(Value::as_bool) != Some(false) + match key { + "tabletInput" => cfg!(target_os = "windows"), + "nudgeBundlePauseMs" => obj.get("bundleNudges").and_then(Value::as_bool) != Some(false), + _ => true, + } } /// A titled, outlined group of settings (Photoshop's Interface page has Appearance, @@ -374,7 +386,7 @@ pub(super) fn field_visible(section: &str, obj: &Map, k: &str, qu // The browser presents frames itself: Low-Latency Drawing does nothing there. || (cfg!(target_arch = "wasm32") && path == "performance.lowLatencyDrawing") || (section == "export" && !export_field_visible(obj, k)) - || (section == "tools" && !tools_field_visible(obj, k)); + || (section == "tools" && (!tools_field_visible(obj, k) || k == "tabletInput")); (query.is_empty() || preference_matches(section, k, query)) && !drawn_elsewhere && !prefs::is_hidden(&path) @@ -405,6 +417,29 @@ pub(super) fn section_fields( if section == "performance" && (query.is_empty() || preference_matches(section, "memoryUsageMb", &query)) { memory_usage_row(ui, obj); } + // WinTab is an explicit Windows choice; Windows Ink stays the default everywhere else. + if section == "tools" + && cfg!(target_os = "windows") + && (query.is_empty() || preference_matches(section, "tabletInput", &query)) + && obj.contains_key("tabletInput") + { + let t = Tokens::get(ui.ctx()); + pref_group(ui, "Pen Tablet", |ui| { + egui::Grid::new("prefs-grid-pen-tablet").num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| { + field_rows(ui, section, obj, vec!["tabletInput".to_string()], lang); + }); + ui.add( + egui::Label::new( + RichText::new(tl!( + "WinTab reads pressure, tilt, rotation and the eraser end from the tablet driver. Turn off Windows Ink in your tablet's software utility (for example Wacom Center) before using it." + )) + .small() + .color(t.text_dim), + ) + .wrap(), + ); + }); + } keys.retain(|k| field_visible(section, obj, k, &query)); egui::Grid::new(("prefs-grid", section)).num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| field_rows(ui, section, obj, keys, lang)); } @@ -457,7 +492,12 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map, ke let labels: Vec = opts.iter().map(|o| choice_label(o)).collect(); let pairs: Vec<(String, &str)> = opts.iter().map(|o| o.to_string()).zip(labels.iter().map(String::as_str)).collect(); let mut cur = s.clone(); - crate::widgets::dropdown(ui, &format!("pref-{path}"), tl!(&label), &mut cur, &pairs, 220.0); + ui.vertical(|ui| { + crate::widgets::dropdown(ui, &format!("pref-{path}"), tl!(&label), &mut cur, &pairs, 220.0); + if read_at_launch(&path) { + ui.label(RichText::new(tl!("Applies at next launch.")).color(t.text_dim)); + } + }); obj.insert(k, json!(cur)); } Value::String(s) if prefs::is_color(&path) => { @@ -546,3 +586,19 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map, ke ui.end_row(); } } + +#[cfg(test)] +mod tests { + use super::preference_matches; + + /// The Tablet input row matches its visible search terms. + #[test] + fn wintab_row_matches_its_visible_terms() { + if cfg!(target_os = "windows") { + for query in ["windows ink", "pen tablet", "wintab", "tablet input"] { + assert!(preference_matches("tools", "tabletInput", query), "{query}"); + } + } + assert!(preference_matches("tools", "useTabletPressure", "tablet pressure")); + } +} diff --git a/crates/ui-egui/src/prefs/prefs_ui/graphics.rs b/crates/ui-egui/src/prefs/prefs_ui/graphics.rs index 166f5ed299..45b2e9ccdb 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/graphics.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/graphics.rs @@ -136,6 +136,8 @@ pub(super) fn read_at_launch(path: &str) -> bool { "interface.systemTitleBar" => cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))), // The desktop app decides on the control server before its window opens. "integrations.allowAgentControl" | "integrations.controlPort" => cfg!(not(target_arch = "wasm32")), + // The WinTab context is opened once, when the window starts. + "tools.tabletInput" => cfg!(target_os = "windows"), _ => false, } } diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 8224b28698..ab5efac975 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -400,6 +400,10 @@ pub type CaptionStyleFn = Box CaptionStyle>; /// Physical right mouse button or stylus barrel button down (Windows; read for brush resize). pub type RightButtonDownFn = Box bool + Send + Sync>; +/// Polls the platform's optional WinTab packet queue into the app's stylus feed (Windows), with +/// whether the app window is focused (to re-assert WinTab overlap priority on activation). +pub type TabletPollFn = Box; + /// Platform services injected by the app binary (file dialogs, codecs), keeping this crate free of /// I/O dependencies. #[derive(Default)] @@ -522,6 +526,8 @@ pub struct Services { pub caption_style: Option, /// Physical right mouse button or pen barrel button currently down. pub right_button_down: Option, + /// WinTab packet poll (Windows with Windows Ink disabled; see `photocraft_tablet::wintab`). + pub tablet_poll: Option, /// The persistent brush preset store, loading in the background (desktop; see /// `photocraft_engine::preset_store`). Attached to the session once it arrives; without /// one, brush presets are session-only unless the shell attached a store before startup. diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 0fe3e98549..7ed3d1e71e 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (GPU compositor startup builds in the background, measurements pending; Camera Raw's preview develops its per-pixel stages on the GPU) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (optional WinTab backend on Windows: pressure and the eraser end; tilt on devices with orientation axes; rotation only with a pen and driver that report twist, with Windows Ink off) · **Target:** Adobe Photoshop 2026 Each hardware feature Photoshop 2026 (27.11.0) uses, per platform, against PhotoCraft. Photoshop side: its bundle (Metal and OpenCL paths, "Precise Color Management for HDR Display" and EDR @@ -9,7 +9,9 @@ strings, Touch Bar resources, pen pressure/tilt strings) and Adobe's GPU FAQ. Ph `engine/src/display_color.rs`, `apps/photocraft/src/monitor_profile.rs`. **Summary (estimated):** ~50% ready. GPU compositing and colour-managed display are solid; pen -input is uneven by platform (Windows lacks tilt/rotation, Wayland is broken) and there is no HDR +input is uneven by platform (the default Windows Ink path lacks tilt and rotation, Wayland is +broken; the opt-in WinTab path on Windows supplies tilt only on devices that report orientation +axes, and rotation only when the pen and driver report twist) and there is no HDR output. Remaining: 80–150 h (gap G12). Human: tablets (Wacom, XP-Pen, Huion) on each OS and an HDR display to verify. @@ -31,12 +33,18 @@ HDR display to verify. | Feature | Photoshop | macOS | Windows | Linux X11 | Linux Wayland | Web | |---|---|---|---|---|---|---| | Pressure | yes | yes (AppKit monitor) | yes (WM_POINTER), jumpy (#2046) | yes (XInput2) | via Xwayland only; GNOME 50 pen doesn't click (#2622), KDE (#2297) | yes | -| Tilt | yes | yes | **no** | yes | no | yes | -| Barrel rotation | yes | yes (not applied to tip angle, #1301) | no | partial | no | yes (twist) | -| Eraser end | yes | yes | no (#2285) | partial | no | partial | -| WinTab driver mode | yes | – | no | – | – | – | +| Tilt | yes | yes | **no** (WinTab: with orientation axes, opt-in) | yes | no | yes | +| Barrel rotation | yes | yes (not applied to tip angle, #1301) | no (WinTab: with a twist-reporting pen and driver, opt-in) | partial | no | yes (twist) | +| Eraser end | yes | yes | no (#2285); WinTab: yes (opt-in) | partial | no | partial | +| WinTab driver mode | yes | – | yes (opt-in; Windows Ink off) | – | – | – | | Pressure curve | Windows Ink / driver | global curve in Preferences (#2195) | same | same | same | same | +Windows: the opt-in WinTab backend (`tools.tabletInput = "winTab"`, with Windows Ink turned off +in the tablet utility) supplies pressure and the eraser end, tilt only on devices that report +orientation axes, and rotation only when the pen and driver report twist (pressure-only devices +get neutral tilt and rotation). The default Windows Ink path is unchanged and still lacks tilt, +rotation and the eraser. + ## Displays and colour | Feature | Photoshop | PhotoCraft | Status | @@ -63,6 +71,7 @@ HDR display to verify. | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | Optional WinTab driver mode on Windows: pressure and eraser; tilt with orientation axes; rotation with a twist-reporting pen, with Windows Ink off | | 2026-10-28 | minor | Build GPU compositor pipelines in the background; benchmark baseline pending (#9157) | | 2026-10-10 | major | First version | | 2026-10-17 | minor | GPU filters: Camera Raw's preview, per-pixel stages (#6916 step 1) | diff --git a/docs/scorecard.md b/docs/scorecard.md index de78fae0de..a3ecd984cb 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 51 | 85 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 47 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 | | [Tools](#tools) | 36 | 9 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 191 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 192 (target 0) | | [Type](#type) | 5 | 5 | 0 | | | [Automation](#automation) | 5 | 3 | 3 | 660 of 660 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -209,7 +209,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu ## Workspace and UI -**Settings that do nothing: 10 of 191** (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: 10 of 192** (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`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`.