diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index d68db8162..0b7883f31 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -53,9 +53,10 @@ winsafe = { version = "0.0.29", default-features = false, features = ["user"], o # DirectWrite's system font collection (src/system_fonts.rs): fonts that font services such as # Adobe Fonts load in place (#579). MIT OR Apache-2.0, already in the build through accesskit. # Win32_UI_WindowsAndMessaging has `MSG` and `WM_POINTERDOWN`, which src/windows_stylus.rs uses to -# read a pen's barrel button from its pointer messages. +# read a pen's barrel button from its pointer messages. Win32_UI_Input_Pointer has +# `GetPointerPenInfo`, which it uses to read the pen's pressure and which end of the pen is down. [target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies] -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_UI_WindowsAndMessaging"] } +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } [target.'cfg(target_os = "macos")'.dependencies] # The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index d41c8bb72..73e02b79e 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -614,6 +614,9 @@ fn services(parent: Parent, ui: egui::Context) -> Services { // macOS reads it from AppKit's events (#852). #[cfg(target_os = "macos")] stylus: mac_tablet::start(), + // Windows reads it from the pen's pointer messages (`windows_stylus`). + #[cfg(all(windows, not(target_vendor = "win7")))] + stylus: Some(windows_stylus::start()), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(write_file)), exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), @@ -974,9 +977,9 @@ fn main() -> std::process::ExitCode { } options }; - // With Windows Ink, winit reports a pen as a touch and drops its barrel button; the message - // hook notes each pointer contact and whether it starts with the barrel button held - // (`windows_stylus`). + // With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end + // is down; the message hook notes each pointer contact and whether it starts with the barrel + // button held, and writes the pen's end into the pen feed (`windows_stylus`). #[cfg(all(windows, not(target_vendor = "win7")))] let options = { use winit::platform::windows::EventLoopBuilderExtWindows as _; diff --git a/apps/vectorcraft/src/windows_stylus.rs b/apps/vectorcraft/src/windows_stylus.rs index cf17976b5..814ba55fe 100644 --- a/apps/vectorcraft/src/windows_stylus.rs +++ b/apps/vectorcraft/src/windows_stylus.rs @@ -5,30 +5,74 @@ //! second-button flag ("analogous to a mouse right button down", //! ): [`msg_hook`] notes //! it, and [`raw_input_hook`] makes the primary button of that contact the secondary one. +//! +//! winit also drops which end of the pen is down. For each pointer message of a pen, [`msg_hook`] +//! reads the pen's `POINTER_PEN_INFO` and writes its pressure and end into the canvas's pen feed +//! ([`start`]): `PEN_FLAG_INVERTED` ("The pen is inverted") or `PEN_FLAG_ERASER` ("The eraser +//! button is pressed") is the eraser end +//! (). Pointer +//! messages also come while the pen hovers, so turning the pen over selects the Eraser tool before +//! it touches. A message of another pointer, a pen leaving detection range or the window, or a +//! mouse message that Windows did not make from a pen or a touch +//! () +//! clears the feed. #![cfg_attr(not(all(windows, not(target_vendor = "win7"))), allow(dead_code))] use std::cell::RefCell; +use std::sync::OnceLock; use egui::{Event, Modifiers, PointerButton, Pos2, TouchId, TouchPhase}; +use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed}; /// `POINTER_MESSAGE_FLAG_SECONDBUTTON` (`winuser.h`): in the high word of a pointer message's /// `wParam`, a pen in contact with its barrel button pressed. const POINTER_MESSAGE_FLAG_SECONDBUTTON: usize = 0x0020; +/// `POINTER_MESSAGE_FLAG_INRANGE` (`winuser.h`): not set on the message of a pointer that has left +/// detection range. +const POINTER_MESSAGE_FLAG_INRANGE: usize = 0x0002; /// At most this many pointer contacts wait for their touch event. const MAX_PENDING: usize = 16; +/// `PEN_FLAG_INVERTED` and `PEN_FLAG_ERASER` (`winuser.h`), in `POINTER_PEN_INFO::penFlags`. +const PEN_FLAG_INVERTED: u32 = 0x0002; +const PEN_FLAG_ERASER: u32 = 0x0004; +/// `PEN_MASK_PRESSURE` (`winuser.h`), in `POINTER_PEN_INFO::penMask`: the pen reports pressure. +const PEN_MASK_PRESSURE: u32 = 0x0001; +/// `MI_WP_SIGNATURE` and `SIGNATURE_MASK`: the extra information of a mouse message that Windows +/// made from a pen or a touch. +const MI_WP_SIGNATURE: u32 = 0xFF51_5700; +const SIGNATURE_MASK: u32 = 0xFFFF_FF00; +/// `WM_MOUSEFIRST` to `WM_MOUSELAST` (`winuser.h`). +const MOUSE_MESSAGES: std::ops::RangeInclusive = 0x0200..=0x020E; +/// `WM_POINTERUPDATE`, `WM_POINTERDOWN`, `WM_POINTERUP`, `WM_POINTERENTER` and `WM_POINTERLEAVE` +/// (`winuser.h`). +const WM_POINTERUPDATE: u32 = 0x0245; +const WM_POINTERDOWN: u32 = 0x0246; +const WM_POINTERUP: u32 = 0x0247; +const WM_POINTERENTER: u32 = 0x0249; +const WM_POINTERLEAVE: u32 = 0x024A; thread_local! { static STROKES: RefCell = RefCell::new(BarrelStrokes::default()); } +/// The pen feed [`msg_hook`] writes, once [`start`] has made it. +static FEED: OnceLock = OnceLock::new(); + +/// The canvas's pen feed, which [`msg_hook`] writes on the UI thread before winit handles each +/// message, so a press never waits for it. +pub fn start() -> StylusFeed { + FEED.get_or_init(StylusFeed::on_ui_thread).clone() +} + /// winit's message hook (`EventLoopBuilderExtWindows::with_msg_hook`), which winit calls for each /// message it takes from the thread's queue, before dispatching it: notes each pointer contact's id -/// and whether the barrel button was held. +/// and whether the barrel button was held, and writes the pen's pressure and end into the feed. /// It never consumes the message. #[cfg(all(windows, not(target_vendor = "win7")))] #[allow(unsafe_code)] pub fn msg_hook(msg: *const std::ffi::c_void) -> bool { - use windows::Win32::UI::WindowsAndMessaging::{MSG, WM_POINTERDOWN}; + use windows::Win32::UI::Input::Pointer::{GetPointerPenInfo, POINTER_FLAG_INCONTACT, POINTER_PEN_INFO}; + use windows::Win32::UI::WindowsAndMessaging::{GetMessageExtraInfo, MSG}; // SAFETY: winit passes the `MSG` that `PeekMessageW` has just filled, which lives for this // call; `as_ref` gives `None` for a null pointer. let Some(msg) = (unsafe { msg.cast::().as_ref() }) else { @@ -41,9 +85,63 @@ pub fn msg_hook(msg: *const std::ffi::c_void) -> bool { } }); } + let Some(feed) = FEED.get() else { + return false; + }; + let read_pen = || { + let mut info = POINTER_PEN_INFO::default(); + // SAFETY: `info` is a `POINTER_PEN_INFO` that lives for the call. The call fails for a + // pointer that is not a pen. + unsafe { GetPointerPenInfo((msg.wParam.0 & 0xffff) as u32, &mut info) }.ok()?; + let in_contact = info.pointerInfo.pointerFlags.0 & POINTER_FLAG_INCONTACT.0 != 0; + Some(pen_sample(info.pressure, info.penMask, info.penFlags, in_contact)) + }; + // SAFETY: `GetMessageExtraInfo` reads the extra information of the thread's last message. + let from_mouse = || is_mouse(unsafe { GetMessageExtraInfo() }.0 as u32); + if let Some(sample) = feed_update(msg.message, msg.wParam.0, read_pen, from_mouse) { + feed.set_sample(sample); + } false } +/// What a pen whose `POINTER_PEN_INFO` holds `pressure` (0..1024), `pen_mask` and `pen_flags` +/// reports: the pressure as winit gives it to a touch, 0 while it hovers, a full press for a pen +/// in contact that reports none; and the eraser end while it is inverted or its eraser button is +/// pressed. +fn pen_sample(pressure: u32, pen_mask: u32, pen_flags: u32, in_contact: bool) -> PenSample { + let pressure = match pressure { + 1..=1024 if pen_mask & PEN_MASK_PRESSURE != 0 => pressure as f32 / 1024.0, + _ if in_contact => 1.0, + _ => 0.0, + }; + PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0 } +} + +/// Whether a mouse message with this extra information (`GetMessageExtraInfo`) comes from a mouse, +/// not from a pen or a touch. +fn is_mouse(extra_info: u32) -> bool { + extra_info & SIGNATURE_MASK != MI_WP_SIGNATURE +} + +/// What the feed is set to for a message with this `wParam`: `Some(Some(sample))` for a pointer +/// message of a pen in range (`read_pen`), `Some(None)` for a pointer message of another pointer, +/// a pointer leaving detection range or the window, or a mouse message from a mouse +/// (`from_mouse`), and `None` to leave it as it is. +fn feed_update( + message: u32, + wparam: usize, + read_pen: impl FnOnce() -> Option, + from_mouse: impl FnOnce() -> bool, +) -> Option> { + match message { + WM_POINTERENTER | WM_POINTERUPDATE | WM_POINTERDOWN | WM_POINTERUP if (wparam >> 16) & POINTER_MESSAGE_FLAG_INRANGE == 0 => Some(None), + WM_POINTERENTER | WM_POINTERUPDATE | WM_POINTERDOWN | WM_POINTERUP => Some(read_pen()), + WM_POINTERLEAVE => Some(None), + m if MOUSE_MESSAGES.contains(&m) && from_mouse() => Some(None), + _ => None, + } +} + /// Before egui reads a frame's input: the primary button of a contact that started with the /// barrel button held becomes the secondary button. pub fn raw_input_hook(raw: &mut egui::RawInput) { @@ -287,6 +385,59 @@ mod tests { assert_eq!(buttons(&rewrite(&mut strokes, &events)), [PRIMARY_DOWN]); } + /// `PEN_FLAG_BARREL` (`winuser.h`). + const PEN_FLAG_BARREL: u32 = 0x0001; + + #[test] + fn an_inverted_pen_or_its_eraser_button_is_the_eraser_end() { + let eraser = |flags| pen_sample(512, PEN_MASK_PRESSURE, flags, true).eraser; + assert!(!eraser(0), "the tip"); + assert!(!eraser(PEN_FLAG_BARREL), "the barrel button"); + assert!(eraser(PEN_FLAG_INVERTED), "turned over"); + assert!(eraser(PEN_FLAG_ERASER), "the eraser button"); + assert!(eraser(PEN_FLAG_INVERTED | PEN_FLAG_ERASER)); + assert!(eraser(PEN_FLAG_INVERTED | PEN_FLAG_BARREL)); + } + + #[test] + fn a_pens_pressure_is_the_one_winit_gives_its_touch() { + assert_eq!(pen_sample(512, PEN_MASK_PRESSURE, 0, true).pressure, 0.5); + assert_eq!(pen_sample(1024, PEN_MASK_PRESSURE, 0, true).pressure, 1.0); + assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false), PenSample { pressure: 0.0, eraser: true }, "hovering"); + assert_eq!(pen_sample(0, 0, 0, true).pressure, 1.0, "a pen in contact that reports no pressure presses fully"); + assert_eq!(pen_sample(300, 0, 0, true).pressure, 1.0, "a pressure without the pressure mask is not one"); + assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, 0, true).pressure, 1.0, "as winit, which gives no force for 0"); + assert_eq!(pen_sample(2000, PEN_MASK_PRESSURE, 0, false).pressure, 0.0); + } + + #[test] + fn mouse_messages_from_a_pen_or_a_touch_are_told_apart() { + assert!(is_mouse(0), "a mouse"); + assert!(!is_mouse(0xFF51_5701), "a pen"); + assert!(!is_mouse(0xFF51_5780), "a touch"); + assert!(is_mouse(0xFF51_5600)); + } + + #[test] + fn pen_messages_set_the_feed_and_other_pointers_clear_it() { + let pen = PenSample { pressure: 0.0, eraser: true }; + // `POINTER_MESSAGE_FLAG_INRANGE` in the high word of `wParam`, pointer 7 in the low word. + let in_range = (POINTER_MESSAGE_FLAG_INRANGE << 16) | 7; + let no_mouse = || -> bool { panic!("a pointer message is not a mouse message") }; + let no_pen = || -> Option { panic!("no pen to read") }; + for m in [WM_POINTERENTER, WM_POINTERUPDATE, WM_POINTERDOWN, WM_POINTERUP] { + assert_eq!(feed_update(m, in_range, || Some(pen), no_mouse), Some(Some(pen)), "{m:#x}"); + assert_eq!(feed_update(m, in_range, || None, no_mouse), Some(None), "a touch: {m:#x}"); + assert_eq!(feed_update(m, 7, no_pen, no_mouse), Some(None), "out of range: {m:#x}"); + } + assert_eq!(feed_update(WM_POINTERLEAVE, in_range, no_pen, no_mouse), Some(None)); + // WM_MOUSEMOVE and WM_LBUTTONDOWN. + assert_eq!(feed_update(0x0200, 0, no_pen, || true), Some(None), "a mouse moved"); + assert_eq!(feed_update(0x0201, 0, no_pen, || false), None, "made from a pen"); + // WM_KEYDOWN. + assert_eq!(feed_update(0x0100, 0, no_pen, || -> bool { panic!("not a mouse message") }), None); + } + #[test] fn contacts_whose_touch_never_arrives_are_bounded() { let mut strokes = BarrelStrokes::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 440eb11ea..93d1366a6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -332,7 +332,7 @@ pub struct Services { /// pointer visible next to the tool glyph ([`cursors::hide_system_pointer`], #2450). pub no_cursor_theme: bool, /// The pen pressure and pen end the host reads from the system itself (desktop Linux under - /// X11 and XWayland). The canvas uses the pressure when a frame's events carry no touch force, + /// X11 and XWayland, macOS and Windows). The canvas uses the pressure when a frame's events carry no touch force, /// and the end to switch to the Eraser tool and back. Without it, pen pressure comes from /// touch events only ([`stylus`]). pub stylus: Option, diff --git a/crates/ui-egui/src/stylus.rs b/crates/ui-egui/src/stylus.rs index f45251b15..f2f65b226 100644 --- a/crates/ui-egui/src/stylus.rs +++ b/crates/ui-egui/src/stylus.rs @@ -6,8 +6,10 @@ //! from XInput2 on a connection of its own and writes it into a [`StylusFeed`] //! ([`crate::Services::stylus`]). On macOS, where winit reports none either, the app reads each //! event's pressure from AppKit before the window handles it, and also keeps it for that event's -//! pointer move ([`StylusFeed::set_move`]), so the moves of a fast stroke each get their own. The -//! canvas uses the feed when a frame's events carry no touch force. +//! pointer move ([`StylusFeed::set_move`]), so the moves of a fast stroke each get their own. On +//! Windows, where winit drops which end of the pen is down, the app reads the pressure and the end +//! from each pointer message of a pen before winit handles it. The canvas uses the feed when a +//! frame's events carry no touch force. //! //! The feed also says which end of the pen moved. Turning the pen to its eraser end selects the //! Eraser tool, and turning it back to the tip selects the tool used before diff --git a/docs/gaps.md b/docs/gaps.md index 48ec6b817..658571250 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -443,9 +443,10 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** pen pressure on macOS (#852), read from AppKit's tablet events before the window gets them, each move of a stroke with its own pressure, and the pen's eraser end there selecting the Eraser tool, as on Linux - under X11 and XWayland; not yet tried on a Mac with a tablet. + under X11 and XWayland; not yet tried on a Mac with a tablet. The pen's eraser end on Windows with Windows Ink, + read from the pen's pointer messages; not yet tried with a pen. - **Missing:** pen pressure on native Wayland (#491, #764) — winit 0.30 reports none there; tilt, bearing and - barrel rotation for brushes (6D Art Pen, #372); the eraser end on Windows, native Wayland and the web. + barrel rotation for brushes (6D Art Pen, #372); the eraser end on native Wayland and the web. - **Estimate:** 4–10 h, partly waiting on winit 0.31; needs pen hardware to verify. ## G5 Brushes, symbols and libraries diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 7e8ab2ff4..e92d95233 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -12,7 +12,7 @@ documentation (GPU Performance, touch workspace, pen pressure pages); ours from **~35% (estimated), 25–45 h.** VectorCraft rasterizes the canvas on the CPU (multithreaded `vello_cpu`) and uses the GPU only to composite, so any GPU that can show a window works, at the cost of GPU-accelerated preview on huge documents. VectorCraft reads pen pressure on Windows, on the web, on macOS, and on Linux under X11 and XWayland, and it -reads the pen's eraser end on macOS and on Linux under X11 and XWayland. +reads the pen's eraser end on Windows, on macOS and on Linux under X11 and XWayland. | Feature | Illustrator | VectorCraft macOS | Windows | Linux | Web | Status / hours | |---|---|---|---|---|---|---| @@ -22,7 +22,7 @@ reads the pen's eraser end on macOS and on Linux under X11 and XWayland. | Restart on another GPU when the first fails to present | — | yes (#502, #964) | yes | yes | restart on lost device (#369) | done | | Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | yes, read from AppKit's tablet events before the window gets them (winit reports none), each move of a stroke with its own pressure, mouse event coalescing off (#852; not yet tried on a Mac) | yes (pointer force via `WM_POINTER`) | under X11 and XWayland (Preferences › Performance › Linux Display Server, #1151, #2028), read from XInput2 raw events (#764); **no** on native Wayland, where winit reports none and the pen may not reach the window (#491) | browser pointer events, with coalesced events; Apple Pencil on iPad (hover, palm rejection: fingers navigate after a pen contact until reload) | partial: 2–4 h for Wayland (winit 0.31), plus a check on a Mac | | Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | no | no | no | no | missing: 4–8 h after the input layer (#372) | -| Pen eraser end | yes | the eraser end (AppKit's tablet proximity events) selects the Eraser tool and the tip brings back the tool used before, as on Linux (#852; not yet tried on a Mac) | no | under X11 and XWayland, the eraser end selects the Eraser tool and the tip brings back the tool used before (#764); **no** on native Wayland | no | partial | +| Pen eraser end | yes | the eraser end (AppKit's tablet proximity events) selects the Eraser tool and the tip brings back the tool used before, as on Linux (#852; not yet tried on a Mac) | with Windows Ink, an inverted pen or its eraser button (`PEN_FLAG_INVERTED`, `PEN_FLAG_ERASER` in the pen's pointer messages) selects the Eraser tool and the tip brings back the tool used before, as on Linux (not yet tried with a pen) | under X11 and XWayland, the eraser end selects the Eraser tool and the tip brings back the tool used before (#764); **no** on native Wayland | no | partial | | Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch, scroll and rotate | pinch, pan; two-finger tap undo, three-finger redo (#585) | same where winit reports touch | eframe until the first pen contact, then Pointer Events (pen and fingers told apart); long press opens the context menu | partial: touch workspace and touch-screen rotation missing, 3–5 h | | Trackpad (smooth scroll, pinch) | yes | yes | precision touchpads | yes | yes | done | | HiDPI / Retina, per-monitor scale | yes | yes | yes | yes | yes | done; a window dragged between monitors of different scale can still grow on Windows (#1459, open); the custom title bar's window on Windows stays truly borderless so the pointer lines up with what is drawn (#1791, #1758; awaiting the reporter's confirmation) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 457857325..52f937406 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -996,6 +996,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Linux pen pressure (part of #764):** when the window is on X11, XWayland included, the desktop app reads a drawing tablet's pen pressure from XInput2 raw events on a second X connection, and the Paintbrush's pressure-sensitive brushes and the Liquify tools' Use Pressure Pen get it. - **Linux pen eraser end (part of #764):** when the window is on X11, XWayland included, turning a drawing tablet's pen to its eraser end selects the Eraser tool, and turning it back to the tip selects the tool used before, unless another tool was chosen meanwhile. A turn during a press takes effect after the release, and a turn made while the pointer is over another window takes effect when it comes back over VectorCraft's window. - **Pen barrel button on Windows:** with Windows Ink, a pen that touches down with its barrel button held acts as the right mouse button until it lifts, so a tap opens the canvas context menu; on Linux the barrel button is the right button (#3659). A touch with the tip alone is the left button, with pressure, as before. +- **Pen eraser end on Windows:** with Windows Ink, turning a pen over, or holding its eraser button, selects the Eraser tool, and the tip selects the tool used before, as on Linux and macOS; the switch comes while the pen hovers, so the eraser end erases from its first touch. - **Control transport:** idle sockets expire after 5 minutes, partial lines after an absolute 30 seconds from their first byte, and reply writes after 60 seconds (#1726). Real TCP regressions cover stalled peers and slot recovery; command execution has its separate existing timeout. MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727). MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739). - **MCP vertical type:** `add_text {vertical: true}` forwards to `text.create` or `text.createInPath`. Regression coverage checks point type, rectangular area type, type in a path and type on a path with true, false and omitted direction. This exposes an existing engine option; it does not change glyph orientation, CJK composition or the type/automation scores. - **Malformed MCP replies:** invalid JSON from the app drops the connection, reports that the operation may have completed and leaves the next call to reconnect; it never replays the edit (#1761). TCP regressions cover malformed and uncorrelated replies, connection closure and a subsequent inspection. Envelope regressions also verify zero backend calls and unchanged initialization state for invalid versions.