mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-07 12:13:10 +00:00
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:
co-authored by
Claude Opus 5.5
FredApps
parent
d2ce418bb3
commit
f23d173933
Generated
+22
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user