From 44b1310f4936be17374085b5d0acbe0700ec0bfa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 22 Oct 2026 17:37:36 -0700 Subject: [PATCH] Linux: a tablet pen's pressure reaches the tools under X11 and XWayland On Linux under X11, and under XWayland, winit 0.30 hands a drawing tablet's pen to the window as a mouse without its pressure. Every pen stroke reached the tools at full pressure: a Calligraphic brush with Size on Pressure drew one width, and the Liquify tools' Use Pressure Pen applied full intensity. When the window is on X11, the desktop app now reads XInput2 raw events on a second X connection (x11rb's RustConnection, without unsafe). A device with an "Abs Pressure" valuator is a pen, unless its XInput type is TOUCHPAD or TOUCHSCREEN. Its pressure, scaled from the valuator's range to 0..1, goes into Services::stylus, and another pointer moving clears it. The canvas uses that pressure when a frame has no touch force. A press takes the highest pressure of the pen's latest touch since the canvas last read the feed, and waits up to 5 ms for the touch of a hovering pen. The moves and the release of a held press keep the pressure the pen last touched with when its lift has already arrived. Native Wayland is unchanged. The valuator mapping and the reader come from PhotoCraft's tablet crate (storytold/photocraft#255), with the pressure axis only. Docs: README.md, docs/development.md, docs/hardware-parity.md, docs/target-app-parity.md. --- Cargo.lock | 1 + README.md | 8 +- apps/vectorcraft/Cargo.toml | 4 + apps/vectorcraft/src/main.rs | 9 + apps/vectorcraft/src/tablet.rs | 356 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 108 +++++++++- crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/stylus.rs | 210 +++++++++++++++++++ docs/development.md | 3 +- docs/hardware-parity.md | 2 +- docs/target-app-parity.md | 3 +- 11 files changed, 691 insertions(+), 18 deletions(-) create mode 100644 apps/vectorcraft/src/tablet.rs create mode 100644 crates/ui-egui/src/stylus.rs diff --git a/Cargo.lock b/Cargo.lock index b61f2ced4..193dc0946 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4608,6 +4608,7 @@ dependencies = [ "winit", "winresource", "winsafe", + "x11rb", "zbus", ] diff --git a/README.md b/README.md index 1ce6f2d01..d85cef156 100644 --- a/README.md +++ b/README.md @@ -206,10 +206,10 @@ the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayl (see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)). On Linux with Wayland, under KDE Plasma 6.3 or later and under GNOME, VectorCraft doesn't respond to a drawing tablet's -pen yet (#491, #2028). Open the window through XWayland instead, where the pen works as a mouse: set the same -preference to **X11 (XWayland)**, or to **X11 (XWayland) When a Pen Is Attached** to use XWayland only when a pen is -attached as VectorCraft starts, and restart; or start the app with `WAYLAND_DISPLAY= vectorcraft` (see -[`docs/development.md`](docs/development.md#linux-wayland-and-x11)). +pen yet (#491, #2028). Open the window through XWayland instead, where the pen works as a mouse and the app reads its +pressure: set the same preference to **X11 (XWayland)**, or to **X11 (XWayland) When a Pen Is Attached** to use +XWayland only when a pen is attached as VectorCraft starts, and restart; or start the app with +`WAYLAND_DISPLAY= vectorcraft` (see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)). ### Use it from Claude Code and other agents diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 18bbde8a1..86235afbb 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -80,6 +80,10 @@ pollster = "1" [target.'cfg(target_os = "linux")'.dependencies] # GNOME/Wayland needs client-side window controls without a bundled title font. winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] } +# A drawing tablet's pen pressure under X11 and XWayland (src/tablet.rs): XInput2 raw events on a +# second X connection, x11rb's pure-Rust `RustConnection`. MIT OR Apache-2.0, already in the build +# through winit with the `xinput` feature. +x11rb = { version = "0.13", default-features = false, features = ["xinput"] } # Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs), # so Automatic draws on that one. Both are already in the build through winit. diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 64cda274f..b819ff185 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -51,6 +51,7 @@ mod prefs_dir; mod printing; #[cfg(all(windows, not(target_vendor = "win7")))] mod system_fonts; +mod tablet; mod window; mod window_frame; mod windows_stylus; @@ -546,6 +547,12 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti fn services(parent: Parent, ui: egui::Context) -> Services { use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; let wayland = parent.as_ref().and_then(|w| w.window_handle().ok()).is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Wayland(_))); + // A window on X11 (XWayland included) reads a tablet pen's pressure itself (#764). + #[cfg(target_os = "linux")] + let x11 = parent + .as_ref() + .and_then(|w| w.window_handle().ok()) + .is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_))); let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent); #[cfg(target_os = "macos")] let sampled_ui = ui.clone(); @@ -559,6 +566,8 @@ fn services(parent: Parent, ui: egui::Context) -> Services { dialogs_are_sheets: cfg!(target_os = "macos"), wayland, no_cursor_theme: wayland && cursor_theme::theme_missing(), + #[cfg(target_os = "linux")] + stylus: if x11 { tablet::start() } else { None }, 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())), diff --git a/apps/vectorcraft/src/tablet.rs b/apps/vectorcraft/src/tablet.rs new file mode 100644 index 000000000..58b1afeeb --- /dev/null +++ b/apps/vectorcraft/src/tablet.rs @@ -0,0 +1,356 @@ +//! Linux: a drawing tablet's pen pressure under X11, XWayland included (#764). winit 0.30 hands a +//! pen to the window there as a mouse, without its pressure, so the app reads the pressure from +//! XInput2 itself and writes it into the UI's [`StylusFeed`] (`Services::stylus`), which the canvas +//! reads. +//! +//! The reader opens a second connection to the X server (x11rb's `RustConnection`, without +//! `unsafe` or libxcb) and selects XInput2 raw events for every device on the root window. Raw +//! events reach every client that selects them, also while winit holds the pointer grab of a +//! drag, and name the device that moved. A pointer device with an "Abs Pressure" valuator is a pen +//! (xf86-input-wacom, xf86-input-libinput and XWayland give a pen's pressure axis that label), +//! unless its XInput type is a touchpad's or a touchscreen's: its pressure, scaled from the +//! valuator's range to 0..1, goes into the feed. Any other pointer +//! device moving sets the feed to `None`. Raw events carry only the valuators that changed, so +//! the last pressure of each pen is kept. +//! +//! The reader starts only for a window on X11. On native Wayland, `DISPLAY` names an XWayland +//! that receives none of the window's input. +//! +//! The mapping and the reader come from PhotoCraft's `crates/tablet` (`xi.rs` and `x11.rs`), with +//! the pressure axis only. +#![cfg_attr(not(target_os = "linux"), allow(dead_code))] + +use std::collections::HashMap; + +use vectorcraft_ui_egui::stylus::StylusFeed; + +/// Valuator numbers from this one on are ignored (the protocol allows 65,535; tablets have fewer +/// than 16). +const MAX_AXES: u16 = 64; + +/// Devices kept (a server could list thousands). +const MAX_DEVICES: usize = 256; + +/// The XInput types of finger devices (`XI_TOUCHPAD` and `XI_TOUCHSCREEN`): xf86-input-evdev labels +/// a touchpad's or a touchscreen's pressure axis "Abs Pressure" too, and they are not pens. +const FINGER_TYPES: &[&str] = &["TOUCHPAD", "TOUCHSCREEN"]; + +/// The "Abs Pressure" valuator of a pen: its number in a raw event's valuator mask, and its range. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Axis { + number: u16, + min: f64, + max: f64, +} + +impl Axis { + /// A raw value of this valuator as a pressure of 0..1; a value that is not a number is a + /// mouse's, 1. + fn pressure(&self, v: f64) -> f32 { + if !v.is_finite() { + return 1.0; + } + let span = self.max - self.min; + let unit = if span.is_finite() && span > 0.0 { (v - self.min) / span } else { v }; + unit.clamp(0.0, 1.0) as f32 + } +} + +/// A pointer device the X server lists. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Device { + id: u16, + /// The pressure valuator: a pen has one, a mouse, a touchpad or a touchscreen has none. + pressure: Option, +} + +impl Device { + /// From what the X server lists for the device: its XInput type (`ListInputDevices`), and each + /// valuator's number, label, minimum and maximum (`XIQueryDevice`). + fn new(id: u16, kind: Option<&str>, valuators: impl IntoIterator) -> Self { + let finger = kind.is_some_and(|k| FINGER_TYPES.contains(&k)); + let pressure = valuators + .into_iter() + .filter(|(number, ..)| *number < MAX_AXES) + .find(|(_, label, ..)| label == "Abs Pressure") + .map(|(number, _, min, max)| Axis { number, min, max }) + .filter(|_| !finger); + Self { id, pressure } + } +} + +/// What a raw event means for the feed. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Update { + /// Nothing: a device the last listing didn't have (a master, or one added since). + Keep, + /// The pressure of a pen, or `None` for another pointer. + Set(Option), +} + +/// The pointer devices and the last pressure of each pen. +#[derive(Debug, Default)] +struct Pens { + devices: HashMap, + last: HashMap, +} + +impl Pens { + /// Replace the device list (at the start, and when devices come and go or change). + fn set_devices(&mut self, devices: impl IntoIterator) { + self.devices = devices.into_iter().take(MAX_DEVICES).map(|d| (d.id, d)).collect(); + self.last.retain(|id, _| self.devices.contains_key(id)); + } + + /// A raw motion or button event of device `id`, with the valuators that changed. + fn handle(&mut self, id: u16, valuators: impl IntoIterator) -> Update { + let Some(device) = self.devices.get(&id) else { return Update::Keep }; + let Some(axis) = device.pressure else { return Update::Set(None) }; + let last = self.last.entry(id).or_insert(0.0); + for (number, v) in valuators { + if number == axis.number { + *last = axis.pressure(v); + } + } + Update::Set(Some(*last)) + } + + /// A raw event as the X server sends it: its valuator mask and the values of the valuators + /// set in it. A pen's pressure or another pointer's `None` goes into `feed`. + fn raw_event(&mut self, feed: &StylusFeed, id: u16, mask: &[u32], values: &[f64]) { + if let Update::Set(pressure) = self.handle(id, valuators(mask, values)) { + feed.set(pressure); + } + } +} + +/// The values a raw event carries: the valuator numbers set in `mask` (32 to a word, lowest bit +/// first), each paired in order with one of `values`. Mask bits past the last value are ignored. +fn valuators<'a>(mask: &'a [u32], values: &'a [f64]) -> impl Iterator + 'a { + let numbers = mask + .iter() + .enumerate() + .flat_map(|(w, bits)| (0..32u32).filter(move |b| bits & (1 << b) != 0).map(move |b| w.saturating_mul(32).saturating_add(b as usize))); + numbers.zip(values.iter().copied()).filter_map(|(n, v)| u16::try_from(n).ok().map(|n| (n, v))) +} + +/// An X 32.32 fixed-point value. +fn fp3232(integral: i32, frac: u32) -> f64 { + f64::from(integral) + f64::from(frac) / 4_294_967_296.0 +} + +/// Start reading pen pressure for a window on X11: the feed the reader writes into, or `None` +/// (logged) when the X server can't be reached or has no XInput 2.1. +#[cfg(target_os = "linux")] +pub fn start() -> Option { + let feed = StylusFeed::default(); + match x11::spawn(feed.clone()) { + Ok(()) => Some(feed), + Err(e) => { + log::warn!("tablet pressure: {e}"); + None + } + } +} + +#[cfg(target_os = "linux")] +mod x11 { + use std::collections::HashMap; + + use vectorcraft_ui_egui::stylus::StylusFeed; + use x11rb::connection::{Connection as _, RequestConnection as _}; + use x11rb::protocol::Event; + use x11rb::protocol::xinput::{self, ConnectionExt as _, DeviceClassData, DeviceType, XIEventMask}; + use x11rb::protocol::xproto::ConnectionExt as _; + use x11rb::rust_connection::RustConnection; + + use super::{Device, MAX_AXES, MAX_DEVICES, Pens, fp3232}; + + /// `XIAllDevices`: every device, the slave devices that name a pen included. + const ALL_DEVICES: u16 = 0; + + /// Connect to `$DISPLAY` (the display winit opened the window on), check for XInput 2.1 (raw + /// events for every client that selects them), select the raw events, and read them on a + /// thread of its own until the connection closes. + pub fn spawn(feed: StylusFeed) -> Result<(), String> { + let (conn, screen) = RustConnection::connect(None).map_err(|e| format!("can't connect to the X server: {e}"))?; + let root = conn.setup().roots.get(screen).map(|s| s.root).ok_or_else(|| format!("X screen {screen} not found"))?; + let version = + conn.xinput_xi_query_version(2, 2).map_err(|e| e.to_string())?.reply().map_err(|e| format!("the X server has no XInput 2: {e}"))?; + if (version.major_version, version.minor_version) < (2, 1) { + return Err(format!("the X server has XInput {}.{}, and raw events need 2.1", version.major_version, version.minor_version)); + } + let mask = XIEventMask::RAW_MOTION + | XIEventMask::RAW_BUTTON_PRESS + | XIEventMask::RAW_BUTTON_RELEASE + | XIEventMask::HIERARCHY + | XIEventMask::DEVICE_CHANGED; + conn.xinput_xi_select_events(root, &[xinput::EventMask { deviceid: ALL_DEVICES, mask: vec![mask] }]) + .map_err(|e| e.to_string())? + .check() + .map_err(|e| format!("XISelectEvents failed: {e}"))?; + let mut pens = Pens::default(); + pens.set_devices(query_devices(&conn)); + std::thread::Builder::new() + .name("vectorcraft-tablet-x11".into()) + .spawn(move || run(&conn, &mut pens, &feed)) + .map(drop) + .map_err(|e| format!("can't start the tablet thread: {e}")) + } + + fn run(conn: &RustConnection, pens: &mut Pens, feed: &StylusFeed) { + // A connection error (the display went away) ends the loop; an event that x11rb cannot + // parse is skipped, and `Pens` bounds what the rest can hold. + while let Ok(raw) = conn.wait_for_raw_event() { + let Ok(event) = conn.parse_event(&raw) else { continue }; + match event { + Event::XinputRawMotion(e) | Event::XinputRawButtonPress(e) | Event::XinputRawButtonRelease(e) => { + let values: Vec = e.axisvalues.iter().map(|v| fp3232(v.integral, v.frac)).collect(); + pens.raw_event(feed, e.deviceid, &e.valuator_mask, &values); + } + Event::XinputHierarchy(_) | Event::XinputDeviceChanged(_) => pens.set_devices(query_devices(conn)), + _ => {} + } + } + } + + /// The slave pointer devices with their pressure valuators (none on any error). + fn query_devices(conn: &RustConnection) -> Vec { + let Ok(reply) = conn.xinput_xi_query_device(ALL_DEVICES).map_err(drop).and_then(|c| c.reply().map_err(drop)) else { return Vec::new() }; + let kinds = device_kinds(conn); + let mut out = Vec::new(); + for info in reply.infos.iter().take(MAX_DEVICES) { + if info.type_ != DeviceType::SLAVE_POINTER && info.type_ != DeviceType::FLOATING_SLAVE { + continue; + } + let valuators = info.classes.iter().take(usize::from(MAX_AXES)).filter_map(|c| match &c.data { + DeviceClassData::Valuator(v) => { + let label = atom_name(conn, v.label)?; + Some((v.number, label, fp3232(v.min.integral, v.min.frac), fp3232(v.max.integral, v.max.frac))) + } + _ => None, + }); + out.push(Device::new(info.deviceid, kinds.get(&info.deviceid).map(String::as_str), valuators.collect::>())); + } + out + } + + /// Each device's XInput type name, by device id (none on any error). + fn device_kinds(conn: &RustConnection) -> HashMap { + let Ok(reply) = conn.xinput_list_input_devices().map_err(drop).and_then(|c| c.reply().map_err(drop)) else { return HashMap::new() }; + reply.devices.iter().take(MAX_DEVICES).filter_map(|d| Some((u16::from(d.device_id), atom_name(conn, d.device_type)?))).collect() + } + + fn atom_name(conn: &RustConnection, atom: u32) -> Option { + if atom == 0 { + return None; + } + let reply = conn.get_atom_name(atom).ok()?.reply().ok()?; + Some(String::from_utf8_lossy(&reply.name).into_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The valuators of a Wacom pen as the X server lists them: x and y, then pressure from 0 to + /// 65,535. + fn pen_valuators() -> Vec<(u16, String, f64, f64)> { + vec![ + (0, "Abs X".to_string(), 0.0, 44704.0), + (1, "Abs Y".to_string(), 0.0, 27940.0), + (2, "Abs Pressure".to_string(), 0.0, 65535.0), + (3, "Abs Tilt X".to_string(), -64.0, 63.0), + ] + } + + fn pen(id: u16) -> Device { + Device::new(id, Some("STYLUS"), pen_valuators()) + } + + fn mouse(id: u16) -> Device { + Device::new(id, Some("MOUSE"), [(0, "Rel X".to_string(), -1.0, -1.0), (1, "Rel Y".to_string(), -1.0, -1.0)]) + } + + #[test] + fn the_mask_pairs_valuator_numbers_with_values() { + let v: Vec<_> = valuators(&[0b10_0101, 1], &[1.0, 2.0, 3.0, 4.0]).collect(); + assert_eq!(v, vec![(0, 1.0), (2, 2.0), (5, 3.0), (32, 4.0)]); + // Fewer values than mask bits (malformed): the values that are there. + assert_eq!(valuators(&[u32::MAX; 4], &[7.0]).count(), 1); + assert_eq!(valuators(&[], &[7.0]).count(), 0); + } + + #[test] + fn a_device_with_an_abs_pressure_valuator_is_a_pen() { + assert_eq!(pen(9).pressure, Some(Axis { number: 2, min: 0.0, max: 65535.0 })); + assert_eq!(mouse(12).pressure, None); + let pad = Device::new(13, Some("PAD"), [(5, "Abs Wheel".to_string(), 0.0, 71.0)]); + assert_eq!(pad.pressure, None, "a tablet's pad is no pen"); + let far = Device::new(14, Some("STYLUS"), [(MAX_AXES, "Abs Pressure".to_string(), 0.0, 1.0)]); + assert_eq!(far.pressure, None, "valuator numbers past the limit are ignored"); + // The types libinput and XWayland give pens, and a device whose type wasn't listed. + for kind in [Some("TABLET"), Some("ERASER"), Some("xwayland-tablet stylus"), None] { + assert!(Device::new(15, kind, pen_valuators()).pressure.is_some(), "{kind:?}"); + } + // xf86-input-evdev labels a touchpad's and a touchscreen's finger pressure "Abs Pressure". + for kind in ["TOUCHPAD", "TOUCHSCREEN"] { + assert_eq!(Device::new(16, Some(kind), pen_valuators()).pressure, None, "{kind}"); + } + } + + #[test] + fn a_pens_valuator_becomes_its_pressure_and_another_pointer_clears_it() { + let mut pens = Pens::default(); + pens.set_devices([pen(9), mouse(12)]); + let feed = StylusFeed::default(); + pens.raw_event(&feed, 9, &[0b101], &[100.0, 32767.5]); + assert_eq!(feed.held(), Some(0.5)); + // Raw events carry only the valuators that changed: a move without pressure keeps it. + pens.raw_event(&feed, 9, &[0b1], &[200.0]); + assert_eq!(feed.held(), Some(0.5)); + pens.raw_event(&feed, 9, &[0b100], &[65535.0]); + assert_eq!(feed.held(), Some(1.0)); + // The mouse moves: no pen pressure until the pen moves again. + pens.raw_event(&feed, 12, &[0b1], &[3.0]); + assert_eq!(feed.held(), None); + pens.raw_event(&feed, 9, &[0b1], &[300.0]); + assert_eq!(feed.held(), Some(1.0), "the pen's last pressure"); + // A master device or one not listed yet changes nothing. + pens.raw_event(&feed, 2, &[0b100], &[0.0]); + assert_eq!(feed.held(), Some(1.0)); + // A pen whose first event carries no pressure is hovering: it presses with 0. + pens.set_devices([pen(9), pen(10)]); + pens.raw_event(&feed, 10, &[0b1], &[5.0]); + assert_eq!(feed.press(), Some(0.0)); + } + + #[test] + fn hostile_device_data_gives_a_pressure_within_0_to_1() { + let ranges = [(5.0, 5.0), (1.0, -1.0), (f64::NEG_INFINITY, f64::INFINITY), (f64::NAN, f64::NAN)]; + for (min, max) in ranges { + let mut pens = Pens::default(); + pens.set_devices([Device::new(1, None, [(2, "Abs Pressure".to_string(), min, max)])]); + for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, f64::MIN, -0.0, 1e300, 0.5] { + let Update::Set(Some(p)) = pens.handle(1, [(2, v)]) else { panic!("{min}..{max}: {v} gave no pressure") }; + assert!((0.0..=1.0).contains(&p), "{min}..{max}: {v} gave {p}"); + } + } + let mut pens = Pens::default(); + pens.set_devices((0..2000u16).map(|i| Device::new(i, None, []))); + assert!(pens.devices.len() <= MAX_DEVICES, "the device list is bounded"); + // Values for valuators the pen doesn't have are ignored. + pens.set_devices([pen(3)]); + let Update::Set(Some(p)) = pens.handle(3, valuators(&[u32::MAX; 2048], &[0.5; 4096]).collect::>()) else { + panic!("the pen gave no pressure") + }; + assert!(p < 0.001, "{p}"); + } + + #[test] + fn fixed_point_values() { + assert_eq!(fp3232(3, 1 << 31), 3.5); + assert_eq!(fp3232(-2, 0), -2.0); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3cbb4f0fd..318c8ace5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -10,6 +10,7 @@ use vectorcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind}; use crate::opening; use crate::state::View; +use crate::stylus::StylusFeed; use crate::theme::{self, Tokens}; use crate::{CacheKey, VectorcraftApp, now_ms, widgets}; @@ -152,9 +153,12 @@ fn tool_drag_moved(ui: &Ui, xf: &Xf, p: Pos2, pointer: &egui::PointerState) -> b } /// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch -/// input, else the last one seen since the press (`pressed`: a new press, whose mouse has none +/// input, else the pressure in the host's feed while a pen is the pointer that moved last +/// ([`crate::Services::stylus`]: [`StylusFeed::press`] for a new press, [`StylusFeed::held`] +/// after it), else the last one seen since the press (`pressed`: a new press, whose mouse has none /// until a pen reports it). A mouse presses fully (1). -fn pen_pressure(ui: &Ui, pressed: bool) -> f32 { +fn pen_pressure(ui: &Ui, app: &VectorcraftApp, pressed: bool) -> f32 { + let stylus = app.services.stylus.as_ref(); let id = egui::Id::new("canvas-pressure"); let force = ui.input(|i| { i.events.iter().rev().find_map(|e| match e { @@ -162,6 +166,7 @@ fn pen_pressure(ui: &Ui, pressed: bool) -> f32 { _ => None, }) }); + let force = force.or_else(|| stylus.and_then(if pressed { StylusFeed::press } else { StylusFeed::held })); let p = force.or_else(|| if pressed { None } else { ui.data(|d| d.get_temp::(id)) }).unwrap_or(1.0); ui.data_mut(|d| d.insert_temp(id, p)); p @@ -999,7 +1004,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space } } else { // Cmd with another tool drags with the selection tool used last (the session lends it). - let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) }; + let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, true) }; dispatch(app, &ev, view); crate::document_drag::begin(app, ui.ctx(), ev.pos); Drag::Tool @@ -1066,7 +1071,8 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: Drag::Art if on_canvas(ui, resp, p) => { // Back on the canvas: the move goes on from where it left, with the pointer. egui::DragAndDrop::clear_payload(ui.ctx()); - let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) }; + let ev = + PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; dispatch(app, &ev, view); ui.data_mut(|dd| { dd.insert_temp(drag_id(), Drag::Tool); @@ -1079,13 +1085,16 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: } Drag::Tool => { if tool_drag_moved(ui, &xf, p, &pointer) { - // Every position the pen or mouse passed this frame, not just the last: a - // fast stroke keeps its curve (a pen reports far more often than frames come). + // In a frame with touch events (a pen on Windows or the web, a finger), every + // position the pen passed, not just the last: a fast stroke keeps its curve (a + // pen reports far more often than frames come). Mouse events, a pen's on X11 + // among them, give the last position only, with the host's latest pressure. for (at, force) in frame_moves(ui) { - let pressure = force.unwrap_or_else(|| pen_pressure(ui, false)); + let pressure = force.unwrap_or_else(|| pen_pressure(ui, app, false)); dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure }, view); } - let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) }; + let ev = + PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; dispatch(app, &ev, view); } // Time held (Twirl, Pucker and Bloat keep applying); a stalled frame counts @@ -1141,16 +1150,17 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: if let Some(last) = last_drag && last != xf.to_doc(p) { - let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) }; + let ev = + PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; dispatch(app, &ev, view); } - let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) }; + let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; dispatch(app, &ev, view); } Drag::Art if on_canvas(ui, resp, p) => { // Came back to the canvas and released in one frame: finish the move here. egui::DragAndDrop::clear_payload(ui.ctx()); - let pressure = pen_pressure(ui, false); + let pressure = pen_pressure(ui, app, false); let pos = xf.to_doc(p); dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos, mods: mods(m, space), pressure }, view); dispatch(app, &PointerEvent { kind: PointerKind::Up, pos, mods: mods(m, space), pressure }, view); @@ -8529,6 +8539,82 @@ mod tests { assert!(pressures.contains(&0.5) && pressures.contains(&0.8), "{pressures:?}"); } + /// #764: on Linux under X11 a pen reaches egui as a mouse, and the host reads its pressure + /// itself (`Services::stylus`): the Paintbrush and Liquify's Use Pressure Pen get that + /// pressure, and once a mouse moves (`None`) a stroke presses fully. + #[test] + fn the_hosts_pen_pressure_drives_the_tools_under_mouse_events() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let feed = StylusFeed::default(); + app.services.stylus = Some(feed.clone()); + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + // One frame per sample: the reader has set the pressure before the frame's mouse events. + let stroke = |app: &mut VectorcraftApp, pts: &[(f64, f64, Option)]| { + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let mut at = Pos2::ZERO; + for (i, &(x, y, pressure)) in pts.iter().enumerate() { + feed.set(pressure); + at = xf.to_screen(Point::new(x, y)); + let mut events = vec![egui::Event::PointerMoved(at)]; + if i == 0 { + events.push(button(at, true)); + } + frame(app, &ctx, events); + } + frame(app, &ctx, vec![button(at, false)]); + }; + let pressures = |p: &serde_json::Value| -> Vec { + // A point without a third value was pressed fully. + let pressure = |s: &serde_json::Value| s.get(2).and_then(serde_json::Value::as_f64).map_or(1.0, |f| (f * 100.0).round() / 100.0); + p["points"].as_array().unwrap().iter().map(pressure).collect() + }; + + app.select_tool("paintbrush"); + stroke(&mut app, &[(100.0, 100.0, Some(0.3)), (140.0, 120.0, Some(0.6)), (180.0, 150.0, Some(0.9))]); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!(cmd, "path.freehand"); + assert_eq!(pressures(&p), [0.3, 0.6, 0.9], "{p}"); + // The lift reaches the feed before the frame of the stroke's last move: that move keeps the + // pressure the pen last touched with, not the lift's 0. + stroke(&mut app, &[(100.0, 300.0, Some(0.3)), (140.0, 320.0, Some(0.5)), (180.0, 350.0, Some(0.0))]); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!(cmd, "path.freehand"); + assert_eq!(pressures(&p), [0.3, 0.5, 0.5], "{p}"); + + app.session.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 200, "height": 200})).unwrap(); + app.select_tool("bloat"); + app.session.set_tool_option("usePressure", &json!(true)); + stroke(&mut app, &[(500.0, 350.0, Some(0.2)), (500.0, 400.0, Some(0.5)), (500.0, 420.0, Some(0.8))]); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!(cmd, "object.liquify"); + let pen = pressures(&p); + assert_eq!(pen.first(), Some(&0.2), "the press's own pressure: {pen:?}"); + assert!(pen.contains(&0.5) && pen.contains(&0.8), "{pen:?}"); + + // A mouse moved last: no pen pressure. + stroke(&mut app, &[(350.0, 350.0, None), (350.0, 400.0, None), (350.0, 420.0, None)]); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!(cmd, "object.liquify"); + assert!(pressures(&p).iter().all(|f| *f == 1.0), "{p}"); + + // A tap whose touch and lift both reach the feed before the frame that handles its press + // presses with the tap's pressure, not the lift's 0. + for p in [0.0, 0.4, 0.7, 0.0] { + feed.set(Some(p)); + } + let at = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(450.0, 400.0)); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), button(true)]); + frame(&mut app, &ctx, vec![button(false)]); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!(cmd, "object.liquify"); + assert_eq!(pressures(&p).first(), Some(&0.7), "{p}"); + } + /// #491: a pen tap whose press and lift come in one frame selects what it tapped, and the /// lift (the pointer gone with it) doesn't drag the selection to the canvas's centre. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a77880060..6af59c2fb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -55,6 +55,7 @@ mod scrub; pub mod shortcut_editor; pub mod shortcuts; pub mod state; +pub mod stylus; pub mod sysclip; pub mod theme; pub mod thumb_worker; @@ -321,6 +322,10 @@ pub struct Services { /// default one would never come back once the canvas hid it: the canvas keeps the system /// pointer visible next to the tool glyph ([`cursors::hide_system_pointer`], #2450). pub no_cursor_theme: bool, + /// The pen pressure the host reads from the system itself (desktop Linux under X11 and + /// XWayland). The canvas uses it when a frame's events carry no touch force. Without it, pen + /// pressure comes from touch events only ([`stylus`]). + pub stylus: Option, } /// Cached canvas raster. diff --git a/crates/ui-egui/src/stylus.rs b/crates/ui-egui/src/stylus.rs new file mode 100644 index 000000000..1bc274321 --- /dev/null +++ b/crates/ui-egui/src/stylus.rs @@ -0,0 +1,210 @@ +//! Pen pressure that the host reads from the system itself (#764). +//! +//! winit 0.30 hands a pen's pressure to egui only on Windows, as the force of a touch event; the +//! web build makes the same touch events from the browser's Pointer Events. On Linux under X11 +//! (XWayland included), winit reports a pen as a mouse, so the desktop app reads the pressure +//! from XInput2 on a connection of its own and writes it into a [`StylusFeed`] +//! ([`crate::Services::stylus`]). The canvas uses it when a frame's events carry no touch force. + +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; +use std::time::Duration; + +/// How long a press waits for the touch of a pen that hovers ([`StylusFeed::press`]). +const PRESS_WAIT: Duration = Duration::from_millis(5); + +/// What the feed holds. +#[derive(Clone, Copy, Debug, Default)] +struct Pen { + /// The pressure of the pen when a pen was the last pointer to move. + latest: Option, + /// The highest pressure set since the UI last read the feed, counted from the latest sample + /// whose pressure rose above 0. + peak: Option, + /// The last pressure above 0 set since a pen was the last pointer to move (0 when it has not + /// touched). + touched: f32, +} + +impl Pen { + /// A pen hovers, and no touch has been set since the UI last read the feed. + fn hovering(&self) -> bool { + self.latest == Some(0.0) && self.peak.is_none() + } +} + +#[derive(Default)] +struct Shared { + pen: Mutex, + /// Signaled on every [`StylusFeed::set`]. + set: Condvar, +} + +/// The pen pressure the host last read, written from any thread and read on the UI thread. Cheap +/// to clone; the clones share the value. +#[derive(Clone, Default)] +pub struct StylusFeed(Arc); + +impl StylusFeed { + fn lock(&self) -> MutexGuard<'_, Pen> { + self.0.pen.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// A pen moved with `pressure` (0..1; 0 while it hovers), or `None`: another pointer, such as + /// a mouse, moved. A pressure outside 0..1 is clamped, and one that is not a number is a + /// mouse's, 1. + pub fn set(&self, pressure: Option) { + let pressure = pressure.map(|p| if p.is_finite() { p.clamp(0.0, 1.0) } else { 1.0 }); + let mut pen = self.lock(); + match pressure { + Some(p) if p > 0.0 => { + let touching = pen.latest.is_some_and(|l| l > 0.0); + pen.peak = Some(if touching { pen.peak.map_or(p, |q| q.max(p)) } else { p }); + pen.touched = p; + } + Some(_) => {} + None => { + pen.peak = None; + pen.touched = 0.0; + } + } + pen.latest = pressure; + drop(pen); + self.0.set.notify_all(); + } + + /// The pressure of a press the UI holds (a drag and its release) when a pen was the last + /// pointer to move, otherwise `None`: the latest pressure, or, when the pen has lifted (0), the + /// last pressure it touched with. The lift can reach the feed before the UI handles the last + /// moves and the release of the press, which then keep the pen's pressure, not the lift's 0. + /// The pressures set so far count as read for [`Self::press`]. + pub fn held(&self) -> Option { + let mut pen = self.lock(); + pen.peak = None; + let touched = pen.touched; + pen.latest.map(|l| if l > 0.0 { l } else { touched }) + } + + /// The pressure of a press when a pen was the last pointer to move, otherwise `None`: the + /// highest pressure set since the last read, counted from the latest touch, or else the latest + /// pressure. A quick tap can touch and lift before the frame that handles its press, which + /// then gets the tap's pressure, not the lift's 0. The host's reader can also set the touch + /// just after the UI handles the press: when the pen still hovers, the press waits up to 5 ms + /// for it, and gets 0 if none comes. + pub fn press(&self) -> Option { + self.press_within(PRESS_WAIT) + } + + fn press_within(&self, wait: Duration) -> Option { + let mut pen = self.lock(); + if pen.hovering() { + pen = self.wait_for_touch(pen, wait); + } + let peak = pen.peak.take(); + pen.latest.map(|l| peak.unwrap_or(l)) + } + + #[cfg(not(target_arch = "wasm32"))] + fn wait_for_touch<'a>(&'a self, pen: MutexGuard<'a, Pen>, wait: Duration) -> MutexGuard<'a, Pen> { + match self.0.set.wait_timeout_while(pen, wait, |p| p.hovering()) { + Ok((pen, _)) => pen, + Err(e) => e.into_inner().0, + } + } + + /// The browser's main thread can't wait, and the web build sets no feed. + #[cfg(target_arch = "wasm32")] + fn wait_for_touch<'a>(&'a self, pen: MutexGuard<'a, Pen>, _wait: Duration) -> MutexGuard<'a, Pen> { + pen + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_feed_keeps_the_last_pressure_within_0_to_1() { + let feed = StylusFeed::default(); + assert_eq!(feed.held(), None, "no pen until one moves"); + let shared = feed.clone(); + shared.set(Some(0.25)); + assert_eq!(feed.held(), Some(0.25), "the clones share the value"); + feed.set(Some(1.5)); + assert_eq!(feed.held(), Some(1.0)); + feed.set(Some(f32::NAN)); + assert_eq!(feed.held(), Some(1.0), "a pressure that is not a number is a mouse's"); + feed.set(None); + feed.set(Some(-0.5)); + assert_eq!(feed.press_within(Duration::ZERO), Some(0.0)); + feed.set(None); + assert_eq!((feed.held(), feed.press()), (None, None), "a mouse moved"); + } + + #[test] + fn a_held_press_keeps_the_last_touch_when_the_lift_comes_first() { + let feed = StylusFeed::default(); + feed.set(Some(0.0)); + assert_eq!(feed.held(), Some(0.0), "a pen that has not touched"); + // The pen moves and lifts before the UI handles the last move and the release. + for p in [0.5, 0.6, 0.0] { + feed.set(Some(p)); + } + assert_eq!(feed.held(), Some(0.6)); + feed.set(Some(0.3)); + assert_eq!(feed.held(), Some(0.3), "a touch again: its own pressure"); + feed.set(None); + assert_eq!(feed.held(), None, "a mouse moved"); + feed.set(Some(0.0)); + assert_eq!(feed.held(), Some(0.0), "the pen's touch before the mouse doesn't count"); + } + + #[test] + fn a_press_takes_the_highest_pressure_since_the_last_read() { + let feed = StylusFeed::default(); + feed.set(Some(0.0)); + assert_eq!(feed.press(), Some(0.0), "a hovering pen"); + // A tap: it touches, presses harder, and lifts before the frame that handles the press. + for p in [0.4, 0.7, 0.3, 0.0] { + feed.set(Some(p)); + } + assert_eq!(feed.press(), Some(0.7)); + assert_eq!(feed.press(), Some(0.0), "the press read the tap: the latest pressure"); + // A new touch counts from its own pressure, not the touch before it. + for p in [0.9, 0.0, 0.2, 0.5, 0.3] { + feed.set(Some(p)); + } + assert_eq!(feed.press(), Some(0.5)); + // Pressures the UI has read do not count for the next press. + feed.set(Some(0.8)); + assert_eq!(feed.held(), Some(0.8)); + feed.set(Some(0.4)); + assert_eq!(feed.press(), Some(0.4)); + feed.set(None); + assert_eq!(feed.press(), None, "a mouse presses with no pen pressure"); + } + + #[test] + fn a_press_waits_for_the_touch_of_a_hovering_pen() { + let feed = StylusFeed::default(); + feed.set(Some(0.0)); + let reader = feed.clone(); + let late = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(20)); + reader.set(Some(0.6)); + }); + assert_eq!(feed.press_within(Duration::from_secs(10)), Some(0.6), "the touch set while the press waited"); + late.join().unwrap(); + // No touch comes: the hover's 0 once the wait ends. + feed.set(Some(0.0)); + let start = std::time::Instant::now(); + assert_eq!(feed.press_within(Duration::from_millis(30)), Some(0.0)); + assert!(start.elapsed() >= Duration::from_millis(30)); + // A pen that touched, or a mouse, does not wait. + feed.set(Some(0.3)); + let start = std::time::Instant::now(); + assert_eq!(feed.press_within(Duration::from_secs(10)), Some(0.3)); + feed.set(None); + assert_eq!(feed.press_within(Duration::from_secs(10)), None); + assert!(start.elapsed() < Duration::from_secs(5)); + } +} diff --git a/docs/development.md b/docs/development.md index 9a6576f33..cd87ff67b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -114,7 +114,8 @@ The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On - **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. A paste itself still reads on the UI thread, so pasting from such an owner waits for it once. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. - **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EPS, EMF/WMF or a bitmap; a `.ai` or `.ait` file from the editing data it holds, else as the PDF or PostScript it holds), before the clipboard's other formats (which include the files' paths as text). - **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.36 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own. -- **Pens and pen displays under Wayland (#491, #2028):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336), commit [25c65abd](https://invent.kde.org/plasma/kwin/-/commit/25c65abddd95eb87fb892a8594faccbf509ff62f)), and GNOME's Mutter does not turn it into pointer input either (that commit's message): on KWin the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, either run the app under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer (Preferences › Performance › Linux Display Server › X11 (XWayland); X11 (XWayland) When a Pen Is Attached (`x11WithPen`), which opens the window through XWayland only when `/proc/bus/input/devices` lists a pen as the app starts, a device with the `BTN_TOOL_PEN` key and the `ABS_X` and `ABS_Y` axes, and logs `pen found: opening the window through XWayland`; `WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse, without pressure: winit 0.30 reads pen pressure on Windows only (Windows Ink, which reaches the Liquify tools' Use Pressure Pen). Other compositors that don't emulate a pointer for tablets behave the same way. +- **Pens and pen displays under Wayland (#491, #2028):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336), commit [25c65abd](https://invent.kde.org/plasma/kwin/-/commit/25c65abddd95eb87fb892a8594faccbf509ff62f)), and GNOME's Mutter does not turn it into pointer input either (that commit's message): on KWin the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, either run the app under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer (Preferences › Performance › Linux Display Server › X11 (XWayland); X11 (XWayland) When a Pen Is Attached (`x11WithPen`), which opens the window through XWayland only when `/proc/bus/input/devices` lists a pen as the app starts, a device with the `BTN_TOOL_PEN` key and the `ABS_X` and `ABS_Y` axes, and logs `pen found: opening the window through XWayland`; `WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse. Under XWayland the app also reads the pen's pressure (next item); with KWin's emulation on native Wayland the pen has no pressure. Other compositors that don't emulate a pointer for tablets behave the same way. +- **Pen pressure under X11 and XWayland (#764):** winit 0.30 hands a pen to an X11 window as a mouse, without its pressure. When the window is on X11, XWayland included, the desktop app opens a second X connection (x11rb's `RustConnection`) and reads XInput2 raw events for every device (`apps/vectorcraft/src/tablet.rs`, from PhotoCraft's `crates/tablet`). A device with an `Abs Pressure` valuator is a pen, unless its XInput type is `TOUCHPAD` or `TOUCHSCREEN`. While a pen is the pointer that moved last, its pressure, scaled from the valuator's range to 0..1, reaches the tools that use pressure: the Paintbrush's pressure-sensitive brushes and the Liquify tools' Use Pressure Pen. The X server needs XInput 2.1 or later; otherwise the log has a `tablet pressure:` warning with the reason, and the pen draws at full pressure. ## Fonts: craft-fonts (optional build input) diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 41e3c6508..6cd0d94f4 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -19,7 +19,7 @@ on huge documents. Pen input is pressure-only and Windows-first. | Multithreaded rendering off the UI thread | partial | yes | yes | yes | single-threaded | beyond Illustrator | | Hybrid-graphics GPU choice | automatic | power-saving GPU by default, Preferences › Graphics Processor | same; DX12 then OpenGL; no Vulkan unless asked (#545, #806) | the desktop's GPU | browser | done | | 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) | **no** (winit reports none, #852) | yes (pointer force via `WM_POINTER`) | **no** (winit reports none on X11 or Wayland); through XWayland (Preferences › Performance › Linux Display Server, #1151, #2028) the pen works as a mouse, and on Wayland it may not reach the window (#491, #764) | browser pointer events, with coalesced events; Apple Pencil on iPad (hover, palm rejection: fingers navigate after a pen contact until reload) | partial: 4–8 h for macOS and Wayland (winit 0.31) | +| Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | **no** (winit reports none, #852) | 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: 4–8 h for macOS and Wayland (winit 0.31) | | 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 | no | no | no | no | missing | | 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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f3cf347b8..ac75ebff1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -947,8 +947,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns. The Layers panel's Delete Selection, Duplicate Selection, Collect in New Layer, Merge Selected, Flatten Artwork, Release to Layers (Sequence) and row drags also take time in proportion to the size of the document, and so do Cut Artboard and Duplicate Artboard with their art and each move of an Alt-drag of one artboard with the Artboard tool while Move/Copy Artwork with Artboard is on (#1429). In Outline view the renderer draws paths and compound paths from the geometry it keeps for them between frames, and it culls the members of clip groups by the bounds it keeps for them. Layers with Preview off and the outlines of Image Trace objects are drawn the same way. -- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; after a start that a graphics driver crashed before its first frame, the next starts leave that adapter out, or list OpenGL's adapters alone when the crash came while the adapters were being listed (#2243; verified by unit tests, not on the reporter's Intel UHD Graphics 605); Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Tool states have cursors of their own as the public manual describes them: Direct Selection shows a hollow or filled square on its white arrow over an anchor (path unselected or selected) and the Reshape Segment cursor over a curved segment, as do the Anchor Point tool and the Pen with Alt (whose caret it lends); Group Selection, and Direct Selection with Alt, show a plus; a drag shows the filled arrow, hollow while it snaps to a point or a guide, and an Alt-drag copy a double arrowhead; Curvature has its own pen and Free Transform's Cmd/Ctrl side-shear two arrows passing along the side. Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors, Mesh and Puppet Warp return cursors of their own, which borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed until they're drawn; there's no busy cursor yet. `crates/tools/src/cursors.toml` lists every cursor, when it shows and its hotspot, for whoever draws them. Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland. +- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; after a start that a graphics driver crashed before its first frame, the next starts leave that adapter out, or list OpenGL's adapters alone when the crash came while the adapters were being listed (#2243; verified by unit tests, not on the reporter's Intel UHD Graphics 605); Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Tool states have cursors of their own as the public manual describes them: Direct Selection shows a hollow or filled square on its white arrow over an anchor (path unselected or selected) and the Reshape Segment cursor over a curved segment, as do the Anchor Point tool and the Pen with Alt (whose caret it lends); Group Selection, and Direct Selection with Alt, show a plus; a drag shows the filled arrow, hollow while it snaps to a point or a guide, and an Alt-drag copy a double arrowhead; Curvature has its own pen and Free Transform's Cmd/Ctrl side-shear two arrows passing along the side. Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors, Mesh and Puppet Warp return cursors of their own, which borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed until they're drawn; there's no busy cursor yet. `crates/tools/src/cursors.toml` lists every cursor, when it shows and its hotspot, for whoever draws them. Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Paintbrush's pressure-sensitive brushes and the Liquify tools' Use Pressure Pen on Windows, on the web and on Linux under X11 and XWayland, while winit 0.30 reports none on macOS or native Wayland. On Linux, see `docs/development.md` for pens under Wayland. - **Linux display server for pens (part of #2028):** Preferences › Performance › Linux Display Server has X11 (XWayland) When a Pen Is Attached (`x11WithPen`, off by default), which opens the window through XWayland on a Wayland session only when a pen is attached as the app starts. Under XWayland a drawing tablet's pen draws as a mouse. Below the choice, the Performance page tells users whose tablet pen doesn't draw to choose an X11 (XWayland) option. +- **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. - **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. - **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.