Merge pull request #4097 from dotCooCoo/feat/windows-pen-eraser-end

Pen tablets: a pen's eraser end selects the Eraser on Windows
This commit is contained in:
BFlat
2026-10-26 09:22:01 +07:00
committed by GitHub
8 changed files with 173 additions and 14 deletions
+3 -2
View File
@@ -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
+6 -3
View File
@@ -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 _;
+153 -2
View File
@@ -5,30 +5,74 @@
//! second-button flag ("analogous to a mouse right button down",
//! <https://learn.microsoft.com/en-us/windows/win32/inputmsg/wm-pointerdown>): [`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
//! (<https://learn.microsoft.com/en-us/windows/win32/inputmsg/pen-flags-constants>). 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
//! (<https://learn.microsoft.com/en-us/windows/win32/tablet/system-events-and-mouse-messages>)
//! 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<u32> = 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<BarrelStrokes> = RefCell::new(BarrelStrokes::default());
}
/// The pen feed [`msg_hook`] writes, once [`start`] has made it.
static FEED: OnceLock<StylusFeed> = 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::<MSG>().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<PenSample>,
from_mouse: impl FnOnce() -> bool,
) -> Option<Option<PenSample>> {
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<PenSample> { 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();
+1 -1
View File
@@ -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<stylus::StylusFeed>,
+4 -2
View File
@@ -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
+3 -2
View File
@@ -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
+2 -2
View File
@@ -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) |
+1
View File
@@ -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.