diff --git a/apps/vectorcraft/src/tablet.rs b/apps/vectorcraft/src/tablet.rs index 58b1afeeb..f2c1fd725 100644 --- a/apps/vectorcraft/src/tablet.rs +++ b/apps/vectorcraft/src/tablet.rs @@ -13,6 +13,12 @@ //! 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 X server lists a pen's eraser end as a device of its own, whose name or XInput type contains +//! "eraser" (xf86-input-wacom's "… Pen eraser" of type `ERASER`, xf86-input-libinput's +//! "… Eraser (0x…)" of type `ERASER`, XWayland's "xwayland-tablet eraser:N"). Its samples go into +//! the feed marked as the eraser end, and the canvas switches to the Eraser tool +//! ([`vectorcraft_ui_egui::stylus`]). +//! //! The reader starts only for a window on X11. On native Wayland, `DISPLAY` names an XWayland //! that receives none of the window's input. //! @@ -22,7 +28,7 @@ use std::collections::HashMap; -use vectorcraft_ui_egui::stylus::StylusFeed; +use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed}; /// Valuator numbers from this one on are ignored (the protocol allows 65,535; tablets have fewer /// than 16). @@ -62,12 +68,14 @@ struct Device { id: u16, /// The pressure valuator: a pen has one, a mouse, a touchpad or a touchscreen has none. pressure: Option, + /// The eraser end of a pen: its name or its XInput type contains "eraser", in any case. + eraser: bool, } 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 { + /// From what the X server lists for the device: its name and each valuator's number, label, + /// minimum and maximum (`XIQueryDevice`), and its XInput type (`ListInputDevices`). + fn new(id: u16, name: &str, kind: Option<&str>, valuators: impl IntoIterator) -> Self { let finger = kind.is_some_and(|k| FINGER_TYPES.contains(&k)); let pressure = valuators .into_iter() @@ -75,7 +83,8 @@ impl Device { .find(|(_, label, ..)| label == "Abs Pressure") .map(|(number, _, min, max)| Axis { number, min, max }) .filter(|_| !finger); - Self { id, pressure } + let eraser = [Some(name), kind].into_iter().flatten().any(|s| s.to_ascii_lowercase().contains("eraser")); + Self { id, pressure, eraser } } } @@ -84,8 +93,8 @@ impl Device { 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 pressure and end of a pen, or `None` for another pointer. + Set(Option), } /// The pointer devices and the last pressure of each pen. @@ -106,20 +115,21 @@ impl Pens { 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 eraser = device.eraser; 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)) + Update::Set(Some(PenSample { pressure: *last, eraser })) } /// 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`. + /// set in it. A pen's sample 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); + if let Update::Set(sample) = self.handle(id, valuators(mask, values)) { + feed.set_sample(sample); } } } @@ -214,7 +224,7 @@ mod x11 { } } - /// The slave pointer devices with their pressure valuators (none on any error). + /// The slave pointer devices with their names and 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); @@ -230,7 +240,8 @@ mod x11 { } _ => None, }); - out.push(Device::new(info.deviceid, kinds.get(&info.deviceid).map(String::as_str), valuators.collect::>())); + let name = String::from_utf8_lossy(&info.name); + out.push(Device::new(info.deviceid, &name, kinds.get(&info.deviceid).map(String::as_str), valuators.collect::>())); } out } @@ -266,11 +277,11 @@ mod tests { } fn pen(id: u16) -> Device { - Device::new(id, Some("STYLUS"), pen_valuators()) + Device::new(id, "Wacom Intuos Pro M Pen stylus", 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)]) + Device::new(id, "Logitech USB Optical Mouse", Some("MOUSE"), [(0, "Rel X".to_string(), -1.0, -1.0), (1, "Rel Y".to_string(), -1.0, -1.0)]) } #[test] @@ -286,20 +297,70 @@ mod tests { 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)]); + let pad = Device::new(13, "Wacom Intuos Pro M Pad pad", 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)]); + 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:?}"); + 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}"); + assert_eq!(Device::new(16, "", Some(kind), pen_valuators()).pressure, None, "{kind}"); } } + /// The eraser end of a pen is a device of its own, whose name or XInput type contains + /// "eraser", as xf86-input-wacom, xf86-input-libinput and XWayland list it. + #[test] + fn an_eraser_device_is_named_so() { + let erasers = [ + ("Wacom Intuos Pro M Pen eraser", Some("ERASER")), + ("HUION Kamvas Pen Eraser (0x1)", Some("ERASER")), + ("xwayland-tablet eraser:16", Some("xwayland-tablet eraser")), + ("Wacom Intuos Pro M Pen eraser", None), + ("Tablet Pen (0x2)", Some("ERASER")), + ]; + for (name, kind) in erasers { + let d = Device::new(10, name, kind, pen_valuators()); + assert!(d.eraser && d.pressure.is_some(), "{name} ({kind:?})"); + } + let tips = [ + ("Wacom Intuos Pro M Pen stylus", Some("STYLUS")), + ("HUION Kamvas Pen Pen (0x1)", Some("STYLUS")), + ("xwayland-tablet stylus:16", Some("xwayland-tablet stylus")), + ("Logitech USB Optical Mouse", Some("MOUSE")), + ("", None), + ]; + for (name, kind) in tips { + assert!(!Device::new(9, name, kind, pen_valuators()).eraser, "{name} ({kind:?})"); + } + } + + #[test] + fn the_eraser_device_sets_the_eraser_end() { + let mut pens = Pens::default(); + pens.set_devices([pen(9), Device::new(10, "Wacom Intuos Pro M Pen eraser", Some("ERASER"), pen_valuators()), mouse(12)]); + let feed = StylusFeed::default(); + pens.raw_event(&feed, 9, &[0b100], &[32767.5]); + assert_eq!(pens.handle(9, []), Update::Set(Some(PenSample { pressure: 0.5, eraser: false }))); + assert_eq!(feed.eraser(), Some(false)); + // Turned over, the pen hovers with its eraser end: the eraser device's first event, with + // no pressure yet. + pens.raw_event(&feed, 10, &[0b1], &[100.0]); + assert_eq!(pens.handle(10, []), Update::Set(Some(PenSample { pressure: 0.0, eraser: true }))); + assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(0.0))); + pens.raw_event(&feed, 10, &[0b100], &[65535.0]); + assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(1.0))); + // Back to the tip, with the tip's own last pressure. + pens.raw_event(&feed, 9, &[0b1], &[200.0]); + assert_eq!((feed.eraser(), feed.held()), (Some(false), Some(0.5))); + // A mouse: no pen end. + pens.raw_event(&feed, 12, &[0b1], &[3.0]); + assert_eq!(feed.eraser(), None); + } + #[test] fn a_pens_valuator_becomes_its_pressure_and_another_pointer_clears_it() { let mut pens = Pens::default(); @@ -331,18 +392,18 @@ mod tests { 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)])]); + 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") }; + let Update::Set(Some(PenSample { pressure: 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, []))); + 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 { + let Update::Set(Some(PenSample { pressure: p, .. })) = pens.handle(3, valuators(&[u32::MAX; 2048], &[0.5; 4096]).collect::>()) else { panic!("the pen gave no pressure") }; assert!(p < 0.001, "{p}"); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 318c8ace5..a8d0f9b7c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -971,6 +971,13 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: return; } + // A pen turned to its eraser end selects the Eraser, and a pen turned back to its tip selects + // the tool before it. The switch runs only while the pointer is in this window. It runs before + // this frame's press, which then uses the new tool; while a press is held, it waits for the + // release. A turn made over another window takes effect when the pointer comes in. + if drag.is_none() && ui.input(|i| i.pointer.has_pointer()) { + crate::stylus::sync_eraser_tool(app); + } let tool = app.session.tool_id(); // Held, Space is the Hand tool for the moment, Space+Shift is the Rotate View tool and // Cmd+Space is the Zoom tool (with Alt, zooming out), whatever the tool. @@ -8615,6 +8622,199 @@ mod tests { assert_eq!(pressures(&p).first(), Some(&0.7), "{p}"); } + /// An app with an 800 × 600 document whose host reads the pen itself (`Services::stylus`), + /// after its first frame, with the pointer in the window. + fn pen_app() -> (VectorcraftApp, StylusFeed, egui::Context) { + 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![egui::Event::PointerMoved(Pos2::new(400.0, 300.0))]); + (app, feed, ctx) + } + + /// The pen turned to its eraser end, with `pressure`. + fn eraser_end(pressure: f32) -> Option { + Some(crate::stylus::PenSample { pressure, eraser: true }) + } + + /// #764: turning the pen to its eraser end selects the Eraser, and turning it back to the tip + /// selects the tool before it, as the reference app's help describes ("Erase parts of a path + /// using a stylus pen"). Each turn acts once. + #[test] + fn the_eraser_end_selects_the_eraser_and_the_tip_brings_back_the_tool() { + let (mut app, feed, ctx) = pen_app(); + app.select_tool("paintbrush"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "paintbrush", "the tip hovers"); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser", "the eraser end hovers"); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "paintbrush", "the tip brings back the tool before"); + assert_eq!(app.ui.group_tool.get(vectorcraft_tools::catalog::group_of("paintbrush").unwrap()).map(String::as_str), Some("paintbrush")); + + // A turn acts once: a tool that becomes active while the eraser end stays in stays, and + // the tip doesn't replace a tool other than the Eraser. + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + app.session.select_tool("pencil", app.view_info()).unwrap(); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "pencil"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "pencil"); + } + + /// #764: the eraser end erases what it crosses, also when it comes in and presses in one + /// frame; the tool before it does not paint a stroke. + #[test] + fn the_eraser_end_erases() { + let (mut app, feed, ctx) = pen_app(); + app.session.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 100, "height": 100})).unwrap(); + app.select_tool("paintbrush"); + let steps = app.session.journal.len(); + 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 [a, b, c] = [280.0, 350.0, 420.0].map(|x| xf.to_screen(Point::new(x, 250.0))); + feed.set_sample(eraser_end(0.6)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), button(a, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c)]); + frame(&mut app, &ctx, vec![button(c, false)]); + let cmds: Vec<&str> = app.session.journal.since(steps).iter().map(|(c, _)| c.as_str()).collect(); + assert_eq!(cmds, ["path.eraseRegion"], "one Eraser step, no Paintbrush stroke"); + assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 2, "the rectangle cut in two"); + } + + /// #764: a turn of the pen while a press is held takes effect after the release. + #[test] + fn a_flip_during_a_press_waits_for_the_release() { + let (mut app, feed, ctx) = pen_app(); + app.select_tool("paintbrush"); + 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 [a, b, c] = [100.0, 150.0, 200.0].map(|x| xf.to_screen(Point::new(x, 100.0))); + feed.set(Some(0.5)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), button(a, true)]); + feed.set_sample(eraser_end(0.5)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b)]); + assert_eq!(app.session.tool_id(), "paintbrush"); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c), button(c, false)]); + assert_eq!(app.session.tool_id(), "paintbrush"); + assert_eq!(app.session.journal.last().unwrap().0, "path.freehand", "the stroke ends with the Paintbrush"); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser", "the frame after the release"); + } + + /// #764: a tool chosen while the eraser end stands in (the toolbar and shortcuts call + /// `select_tool`; menus and MCP run `tool.select`) stays when the tip comes back. + #[test] + fn a_tool_chosen_while_the_eraser_end_stands_in_stays() { + let (mut app, feed, ctx) = pen_app(); + app.select_tool("paintbrush"); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + app.select_tool("rectangle"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "rectangle"); + + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + app.run("tool.select", json!({"tool": "eraser"})).unwrap(); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser", "the Eraser chosen again stays too"); + } + + /// #764: a mouse moving while the eraser end stands in does not change the tool, and an Eraser + /// chosen before the eraser end comes in stays when the tip comes back. + #[test] + fn a_mouse_and_an_explicit_eraser_change_nothing() { + let (mut app, feed, ctx) = pen_app(); + app.select_tool("paintbrush"); + feed.set(None); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "paintbrush", "a mouse"); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + feed.set(None); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser", "a mouse between the turns"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "paintbrush"); + + app.select_tool("eraser"); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser", "chosen before the eraser end came in"); + } + + /// #764: the host reads the pen over every window. A turn made while the pointer is outside + /// this window takes effect when the pointer comes in, and a turn there and back does not + /// change the tool. + #[test] + fn a_turn_over_another_window_waits_for_the_pointer() { + let (mut app, feed, ctx) = pen_app(); + app.select_tool("paintbrush"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + let inside = egui::Event::PointerMoved(Pos2::new(400.0, 300.0)); + frame(&mut app, &ctx, vec![egui::Event::PointerGone]); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "paintbrush", "the pointer is in another window"); + frame(&mut app, &ctx, vec![inside.clone()]); + assert_eq!(app.session.tool_id(), "eraser", "the pointer came in"); + + frame(&mut app, &ctx, vec![egui::Event::PointerGone]); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + frame(&mut app, &ctx, vec![inside.clone()]); + assert_eq!(app.session.tool_id(), "paintbrush"); + + frame(&mut app, &ctx, vec![egui::Event::PointerGone]); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![inside]); + assert_eq!(app.session.tool_id(), "paintbrush", "turned there and back outside"); + } + + /// #764: the tip brings back the Perspective Grid tool as it was, with a grid hidden by Hide + /// Grid still hidden (choosing the tool afresh shows the grid). + #[test] + fn the_tip_brings_back_the_perspective_grid_tool_without_showing_the_grid() { + let (mut app, feed, ctx) = pen_app(); + let visible = + |app: &VectorcraftApp| vectorcraft_tools::distort::perspective::PerspectiveGrid::current(&app.session.active().unwrap().doc).visible; + app.select_tool("perspectiveGrid"); + assert!(visible(&app)); + app.session.execute("perspective.grid.show", &json!({"visible": false})).unwrap(); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "perspectiveGrid"); + assert!(!visible(&app), "the grid stays hidden"); + } + /// #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 6af59c2fb..c236a0835 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -322,9 +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`]). + /// 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, + /// and the end to switch to the Eraser tool and back. Without it, pen pressure comes from + /// touch events only ([`stylus`]). pub stylus: Option, } @@ -554,6 +555,9 @@ pub struct VectorcraftApp { /// This frame carries input the control channel injected ([`Self::raw_input_hook`]): it runs /// confined to [`Self::automation_roots`]. synthetic_frame: bool, + /// The pen end the canvas saw last and the tool its eraser end stands in for + /// ([`stylus::sync_eraser_tool`]). + pub(crate) pen_end: stylus::PenEnd, } /// A window capture asked for through the control channel (`ui.screenshot`), answered once a @@ -667,6 +671,7 @@ impl VectorcraftApp { scrub_scope: None, automation_roots: None, synthetic_frame: false, + pen_end: Default::default(), } } @@ -980,9 +985,24 @@ impl VectorcraftApp { /// Select a tool (also used by the toolbar and shortcuts). pub fn select_tool(&mut self, id: &str) { + // The tip of a pen whose eraser end selected the Eraser doesn't undo this choice. + self.pen_end.forget_tool(); + self.activate_tool(id, true); + } + + /// Go back to tool `id` after a tool that stood in for it (the pen's eraser end, [`stylus`]): + /// a perspective grid hidden meanwhile stays hidden. + pub(crate) fn return_to_tool(&mut self, id: &str) { + self.activate_tool(id, false); + } + + /// Make tool `id` active: chosen afresh (`fresh`, [`vectorcraft_engine::Session::select_tool`]), + /// or back from a tool that stood in for it ([`vectorcraft_engine::Session::switch_tool`]). + fn activate_tool(&mut self, id: &str, fresh: bool) { let v = self.view_info(); let before = self.session.tool_id(); - if let Err(e) = self.session.select_tool(id, v) { + let switched = if fresh { self.session.select_tool(id, v) } else { self.session.switch_tool(id, v) }; + if let Err(e) = switched { self.ui.status = e.to_string(); } // A newly chosen Artboard tool starts on the active artboard (the view's). diff --git a/crates/ui-egui/src/stylus.rs b/crates/ui-egui/src/stylus.rs index 1bc274321..fc415c4f8 100644 --- a/crates/ui-egui/src/stylus.rs +++ b/crates/ui-egui/src/stylus.rs @@ -5,23 +5,45 @@ //! (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. +//! +//! 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 +//! ([`sync_eraser_tool`]), as the reference app's help describes under "Erase parts of a path +//! using a stylus pen": +//! use std::sync::{Arc, Condvar, Mutex, MutexGuard}; use std::time::Duration; +use crate::VectorcraftApp; + +/// The tool the pen's eraser end selects. +const ERASER: &str = "eraser"; + /// How long a press waits for the touch of a pen that hovers ([`StylusFeed::press`]). const PRESS_WAIT: Duration = Duration::from_millis(5); +/// What a pen reports as it moves. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PenSample { + /// 0..1; 0 while the pen hovers. + pub pressure: f32, + /// The pen is turned to its eraser end. + pub eraser: bool, +} + /// 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 pen that moved last is turned to its eraser end. + eraser: bool, /// 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). + /// The last pressure above 0 set since a pen was the last pointer to move, with the same end + /// (0 when that end has not touched). touched: f32, } @@ -35,12 +57,12 @@ impl Pen { #[derive(Default)] struct Shared { pen: Mutex, - /// Signaled on every [`StylusFeed::set`]. + /// Signaled on every [`StylusFeed::set_sample`] ([`StylusFeed::set`] included). 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. +/// The pen pressure and pen end 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); @@ -49,12 +71,24 @@ impl StylusFeed { 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. + /// A pen turned to its tip moved with `pressure`, or `None`: another pointer moved + /// ([`Self::set_sample`]). pub fn set(&self, pressure: Option) { - let pressure = pressure.map(|p| if p.is_finite() { p.clamp(0.0, 1.0) } else { 1.0 }); + self.set_sample(pressure.map(|pressure| PenSample { pressure, eraser: false })); + } + + /// A pen moved with `sample`'s pressure (0..1; 0 while it hovers) and end, 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. A sample of the other end starts afresh: the touches of the end + /// before it don't count for its presses. + pub fn set_sample(&self, sample: Option) { + let pressure = sample.map(|s| if s.pressure.is_finite() { s.pressure.clamp(0.0, 1.0) } else { 1.0 }); + let eraser = sample.is_some_and(|s| s.eraser); let mut pen = self.lock(); + if eraser != pen.eraser { + *pen = Pen::default(); + } + pen.eraser = eraser; match pressure { Some(p) if p > 0.0 => { let touching = pen.latest.is_some_and(|l| l > 0.0); @@ -103,6 +137,13 @@ impl StylusFeed { pen.latest.map(|l| peak.unwrap_or(l)) } + /// Whether the pen is turned to its eraser end when a pen was the last pointer to move, + /// otherwise `None`. + pub fn eraser(&self) -> Option { + let pen = self.lock(); + pen.latest.map(|_| pen.eraser) + } + #[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()) { @@ -118,6 +159,63 @@ impl StylusFeed { } } +/// The end of the pen the canvas saw last, and the tool the eraser end stands in for +/// ([`sync_eraser_tool`]). +#[derive(Clone, Debug, Default)] +pub(crate) struct PenEnd { + /// `Some(true)`: the eraser end; `Some(false)`: the tip; `None`: no pen seen yet. + end: Option, + /// The tool that was active when the eraser end selected the Eraser. + before: Option<&'static str>, +} + +impl PenEnd { + /// A tool was chosen (the toolbar, a shortcut, a menu, `tool.select`): the tip no longer + /// brings back the tool from before the eraser end. + pub(crate) fn forget_tool(&mut self) { + self.before = None; + } + + /// The end the pen turned to since the last call (`Some(true)`: the eraser end), or `None` + /// when it has not turned, or when another pointer moved last. Each turn is reported once. + fn take_flip(&mut self, feed: Option<&StylusFeed>) -> Option { + let eraser = feed?.eraser()?; + let flipped = self.end.map_or(eraser, |e| e != eraser); + self.end = Some(eraser); + flipped.then_some(eraser) + } +} + +/// Select the Eraser tool when the pen turns to its eraser end, and select the tool active before +/// it when the pen turns back to its tip while the Eraser is still active. The Eraser chosen +/// before the eraser end came in stays when the tip comes back. Returns whether the tool changed. +/// +/// The canvas calls this once a frame while no press is held and the pointer is in the window, so +/// a turn during a press takes effect after the release, and a turn over another window takes +/// effect when the pointer comes in. +pub(crate) fn sync_eraser_tool(app: &mut VectorcraftApp) -> bool { + let Some(eraser) = app.pen_end.take_flip(app.services.stylus.as_ref()) else { return false }; + let tool = app.session.tool_id(); + if eraser { + if tool == ERASER { + return false; + } + app.select_tool(ERASER); + // After `select_tool`, which forgets the tool before. + let selected = app.session.tool_id() == ERASER; + app.pen_end.before = selected.then_some(tool); + selected + } else { + match app.pen_end.before.take() { + Some(before) if tool == ERASER => { + app.return_to_tool(before); + app.session.tool_id() == before + } + _ => false, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -140,6 +238,22 @@ mod tests { assert_eq!((feed.held(), feed.press()), (None, None), "a mouse moved"); } + #[test] + fn the_feed_keeps_the_eraser_end() { + let feed = StylusFeed::default(); + assert_eq!(feed.eraser(), None, "no pen until one moves"); + feed.set_sample(Some(PenSample { pressure: 1.5, eraser: true })); + assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(1.0)), "clamped, and the eraser end kept"); + feed.set_sample(Some(PenSample { pressure: f32::NAN, eraser: true })); + assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(1.0))); + feed.set(Some(0.25)); + assert_eq!((feed.eraser(), feed.held()), (Some(false), Some(0.25)), "the tip"); + feed.set_sample(Some(PenSample { pressure: 0.0, eraser: true })); + assert_eq!(feed.eraser(), Some(true), "the eraser end hovering"); + feed.set_sample(None); + assert_eq!(feed.eraser(), None, "a mouse moved"); + } + #[test] fn a_held_press_keeps_the_last_touch_when_the_lift_comes_first() { let feed = StylusFeed::default(); @@ -158,6 +272,22 @@ mod tests { assert_eq!(feed.held(), Some(0.0), "the pen's touch before the mouse doesn't count"); } + #[test] + fn a_turned_pen_presses_without_the_touches_of_the_other_end() { + let feed = StylusFeed::default(); + // The tip touches and lifts where the UI holds no press, so the UI does not read the feed. + for p in [0.0, 0.8, 0.0] { + feed.set(Some(p)); + } + feed.set_sample(Some(PenSample { pressure: 0.0, eraser: true })); + assert_eq!(feed.held(), Some(0.0), "the eraser end has not touched"); + assert_eq!(feed.press_within(Duration::ZERO), Some(0.0), "nor pressed"); + feed.set_sample(Some(PenSample { pressure: 0.4, eraser: true })); + feed.set_sample(Some(PenSample { pressure: 0.0, eraser: true })); + feed.set(Some(0.0)); + assert_eq!((feed.held(), feed.press_within(Duration::ZERO)), (Some(0.0), Some(0.0)), "back to the tip"); + } + #[test] fn a_press_takes_the_highest_pressure_since_the_last_read() { let feed = StylusFeed::default(); diff --git a/docs/development.md b/docs/development.md index f931b2266..b31185380 100644 --- a/docs/development.md +++ b/docs/development.md @@ -115,7 +115,7 @@ The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On - **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. 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. +- **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. A pen device whose name or XInput type contains `eraser` (in any case) is the pen's eraser end: while the pointer is over the window, the canvas selects the Eraser tool when the eraser end moves, and it selects the tool used before when the tip moves again (`crates/ui-egui/src/stylus.rs`). 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/gaps.md b/docs/gaps.md index a24212b92..c3d31c644 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -431,7 +431,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G14 Pen hardware beyond Windows pressure - **Missing:** pen pressure on macOS (#852) and Wayland (#491, #764) — winit 0.30 reports none there; tilt, - bearing and barrel rotation for brushes (6D Art Pen, #372); the eraser end. + bearing and barrel rotation for brushes (6D Art Pen, #372); the eraser end on macOS, Windows, native Wayland and + the web. - **Estimate:** 8–16 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 6cd0d94f4..58001ce7a 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -11,7 +11,8 @@ 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. Pen input is pressure-only and Windows-first. +on huge documents. VectorCraft reads pen pressure on Windows, on the web, and on Linux under X11 and XWayland, and it +reads the pen's eraser end on Linux under X11 and XWayland only. | Feature | Illustrator | VectorCraft macOS | Windows | Linux | Web | Status / hours | |---|---|---|---|---|---|---| @@ -21,7 +22,7 @@ on huge documents. Pen input is pressure-only and Windows-first. | 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`) | 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 | +| Pen eraser end | yes | no | 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 | | 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 42f5ab176..80c520462 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -956,6 +956,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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. +- **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. - **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.