Add an opt-in WinTab backend for pen pressure on Windows (#11331)

* Add an opt-in WinTab backend for pen pressure on Windows

Windows Ink stays the default. Preferences > Tools > Pen Tablet has a Tablet
input dropdown (Windows Ink (default) / WinTab). WinTab opens a WinTab 1.4
system context on PhotoCraft's window and polls pressure and the eraser end
(tilt on devices that report orientation axes; rotation only with a pen and
driver that report twist) into the stylus feed. The row says to turn Windows
Ink off in the tablet's software utility; the choice applies at next launch.

- crates/tablet: Windows-only wintab/wintab-sys backend. Devices with
  orientation axes use the orientation packet; pressure-only devices open a
  packet without it and report neutral tilt. Leaving is the driver's hardware
  proximity message (WT_PROXIMITY) and the packet's out-of-context status, with
  a long fallback for drivers that never report either. Overlap priority
  follows window focus, and the message hook requests a frame for each WinTab
  message so pressure-only strokes update without cursor movement.
- apps/photocraft: install the backend when the preference is on; poll it at
  the start of each frame; the installed wake repaints the UI.
- ui-egui: TabletPollFn service; the Windows-only Pen Tablet row with the
  Tablet input dropdown and the note.
- engine: tools.tabletInput (windowsInk/winTab), default windowsInk.
- docs: hardware-parity WinTab gap entry, qualifying tilt to orientation axes
  and rotation to twist-reporting pens.

Tested locally on a Wacom tablet with Windows Ink off: pressure, tilt and the
eraser end reach the brush (rotation where the pen reports twist); the default
path is unchanged.

* WinTab: a message that finds the state borrowed is skipped, not a panic in the hook

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WinTab: rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Scorecard: count the Tablet input setting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Triwarr
2026-11-06 18:39:40 +01:00
committed by GitHub
co-authored by Claude Opus 5.5 FredApps
parent d2ce418bb3
commit f23d173933
17 changed files with 536 additions and 17 deletions
Generated
+22
View File
@@ -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"
+8
View File
@@ -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();
+38 -1
View File
@@ -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> {
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<photocraft_ui_egui::TabletPollFn> {
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")]
+9
View File
@@ -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,
+14
View File
@@ -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();
+3 -2
View File
@@ -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,
+10 -1
View File
@@ -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"] }
+5
View File
@@ -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;
+3
View File
@@ -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;
+334
View File
@@ -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<PenPacket>, axes: [wintab::Axis; 3] },
PressureOnly { context: wintab::Context<PressurePacket> },
}
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<dyn Fn()>,
}
thread_local! {
static STATE: RefCell<Option<State>> = 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<dyn Fn()>) -> 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::<PenPacket>(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::<PressurePacket>(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<Sample>)) {
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:?}");
}
}
+4
View File
@@ -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() {
+1 -1
View File
@@ -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;
+60 -4
View File
@@ -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<String, Value>, 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<String, Value>, 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<String, Value>, 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<String, Value>, ke
let labels: Vec<String> = 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<String, Value>, 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"));
}
}
@@ -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,
}
}
+6
View File
@@ -400,6 +400,10 @@ pub type CaptionStyleFn = Box<dyn Fn(&egui::Context) -> CaptionStyle>;
/// Physical right mouse button or stylus barrel button down (Windows; read for brush resize).
pub type RightButtonDownFn = Box<dyn Fn() -> 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<dyn FnMut(bool)>;
/// 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<CaptionStyleFn>,
/// Physical right mouse button or pen barrel button currently down.
pub right_button_down: Option<RightButtonDownFn>,
/// WinTab packet poll (Windows with Windows Ink disabled; see `photocraft_tablet::wintab`).
pub tablet_poll: Option<TabletPollFn>,
/// 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.
+15 -6
View File
@@ -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) |
+2 -2
View File
@@ -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`.