From dfd07552c8173a6a7c0afe64aa92f14f2063aa92 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 09:33:42 -0700 Subject: [PATCH] Cursors: Caps Lock gives the drawing tools the crosshair While Caps Lock is on, the tools whose pointer General > Use Precise Cursors changes show the crosshair, and the saved preference does not change. The desktop app reads the lock state on macOS, Windows and Linux X11 and passes each change to the new view.capsLock command. A change to view.capsLock or usePreciseCursors through app.run refreshes the canvas pointer. The view.preciseCursors command is labeled Use Precise Cursors, so command search finds it by the preference's name. --- apps/vectorcraft/Cargo.toml | 9 +- apps/vectorcraft/src/app.rs | 3 + apps/vectorcraft/src/caps_lock.rs | 216 ++++++++++++++++++++++++ apps/vectorcraft/src/main.rs | 3 + crates/engine/src/cmd/views.rs | 8 + crates/engine/src/lib.rs | 2 + crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests/prefs.rs | 25 +++ crates/engine/src/tooling.rs | 6 +- crates/ui-egui/src/app.rs | 10 ++ crates/ui-egui/src/app/commands.rs | 4 + crates/ui-egui/src/canvas/tests.rs | 20 +++ crates/ui-egui/src/menus/tests.rs | 2 +- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/palette.rs | 18 ++ docs/ui-parity.md | 2 +- 16 files changed, 321 insertions(+), 10 deletions(-) create mode 100644 apps/vectorcraft/src/caps_lock.rs diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 7af8c51ef..47d26eb7b 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -55,8 +55,9 @@ pollster = "1" # read a pen's barrel button from its pointer messages. Win32_UI_Input_Pointer has # `GetPointerPenInfo`, which it uses to read the pen's pressure and which end of the pen is down. # Win32_System_Threading has `GetProcessAffinityMask`, which src/windows_cores.rs uses to count the -# cores the process may run on. -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } +# cores the process may run on. Win32_UI_Input_KeyboardAndMouse has `GetKeyState`, which +# src/caps_lock.rs uses to read whether Caps Lock is on. +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } # The renderer's worker count follows those cores (src/windows_cores.rs). vectorcraft-render = { workspace = true } @@ -90,8 +91,8 @@ pollster = "1" 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"] } +# through winit with the `xinput` feature. XKB reads Caps Lock's locked modifier state (src/caps_lock.rs). +x11rb = { version = "0.13", default-features = false, features = ["xinput", "xkb"] } # 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/app.rs b/apps/vectorcraft/src/app.rs index affb23466..423fb53fc 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -68,6 +68,9 @@ impl eframe::App for App { // macOS converts the frame from sRGB to the display's profile (#2249). #[cfg(all(target_os = "macos", feature = "wgpu"))] super::mac_color_space::tag(ui); + if let Some(on) = self.host_caps_lock.read_transition() { + self.app.set_caps_lock(on); + } file_access::unconfined(|| self.app.ui(ui)); // Preferences are written when confirmed, not only at exit: a session ended any other way // (the system shutting down, the process ended or crashing) used to lose them (#1747). diff --git a/apps/vectorcraft/src/caps_lock.rs b/apps/vectorcraft/src/caps_lock.rs new file mode 100644 index 000000000..231a37e03 --- /dev/null +++ b/apps/vectorcraft/src/caps_lock.rs @@ -0,0 +1,216 @@ +//! Read the host's Caps Lock state, which gives the drawing tools the precise crosshair while it is on. + +/// Only host transitions replace a value set by a script through `view.capsLock`. +#[derive(Default)] +pub struct HostCapsLock { + previous: Option, + #[cfg(target_os = "linux")] + x11: Option, +} + +#[cfg(target_os = "linux")] +struct X11CapsLock { + connection: x11rb::rust_connection::RustConnection, + locked: bool, + initial_sequence: u16, +} + +impl HostCapsLock { + pub fn new(window: Option<&winit::window::Window>) -> Self { + #[cfg(target_os = "linux")] + let x11 = window.and_then(x11_connection); + #[cfg(not(target_os = "linux"))] + let _ = window; + Self { + previous: None, + #[cfg(target_os = "linux")] + x11, + } + } + + pub fn read_transition(&mut self) -> Option { + #[cfg(target_os = "linux")] + let current = match self.x11.as_mut() { + Some(state) => match state.poll() { + Some(locked) => Some(locked), + None => { + self.x11 = None; + None + } + }, + None => { + // Native Wayland does not expose the seat's XKB lock bit through winit or egui. + None + } + }; + #[cfg(any(target_os = "macos", windows))] + let current = Some(read()); + // This platform has no lock-state reader. + #[cfg(not(any(target_os = "macos", windows, target_os = "linux")))] + let current = None; + self.transition(current) + } + + fn transition(&mut self, reading: Option) -> Option { + reading.and_then(|current| self.observe(current)) + } + + fn observe(&mut self, current: bool) -> Option { + if self.previous.replace(current) == Some(current) { None } else { Some(current) } + } +} + +#[cfg(target_os = "linux")] +fn x11_connection(window: &winit::window::Window) -> Option { + use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; + use x11rb::protocol::xkb; + if !matches!(window.window_handle().ok()?.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)) { + return None; + } + let (connection, _) = x11rb::connect(None).ok()?; + let supported = x11rb::protocol::xkb::use_extension(&connection, 1, 0).ok()?.reply().ok()?.supported; + if !supported { + return None; + } + // Subscribe before the initial read so changes during setup remain queued. + xkb::select_events( + &connection, + xkb::ID::USE_CORE_KBD.into(), + xkb::EventType::default(), + xkb::EventType::default(), + xkb::MapPart::default(), + xkb::MapPart::default(), + &xkb::SelectEventsAux { + state_notify: Some(xkb::SelectEventsAuxStateNotify { + affect_state: xkb::StatePart::MODIFIER_LOCK, + state_details: xkb::StatePart::MODIFIER_LOCK, + }), + ..Default::default() + }, + ) + .ok()? + .check() + .ok()?; + let (locked, initial_sequence) = read_x11(&connection)?; + Some(X11CapsLock { connection, locked, initial_sequence }) +} + +#[cfg(target_os = "linux")] +impl X11CapsLock { + fn poll(&mut self) -> Option { + use x11rb::connection::Connection as _; + use x11rb::protocol::Event; + while let Some(event) = self.connection.poll_for_event().ok()? { + if let Event::XkbStateNotify(state) = event + // Events queued before the initial GetState reply must not replace that newer snapshot. + && state.sequence.wrapping_sub(self.initial_sequence) < 0x8000 + { + self.locked = lock_modifier_is_set(state.locked_mods); + } + } + Some(self.locked) + } +} + +#[cfg(target_os = "linux")] +fn read_x11(connection: &x11rb::rust_connection::RustConnection) -> Option<(bool, u16)> { + use x11rb::protocol::xkb; + let state = xkb::get_state(connection, xkb::ID::USE_CORE_KBD.into()).ok()?.reply().ok()?; + Some((lock_modifier_is_set(state.locked_mods), state.sequence)) +} + +#[cfg(target_os = "linux")] +fn lock_modifier_is_set(modifiers: x11rb::protocol::xproto::ModMask) -> bool { + use x11rb::protocol::xproto::ModMask; + u16::from(modifiers) & u16::from(ModMask::LOCK) != 0 +} + +#[cfg(target_os = "macos")] +const MAC_CAPS_LOCK: usize = 1 << 16; + +#[cfg(target_os = "macos")] +fn mac_flags_have_caps_lock(flags: usize) -> bool { + flags & MAC_CAPS_LOCK != 0 +} + +#[cfg(target_os = "macos")] +pub fn read() -> bool { + mac_flags_have_caps_lock(objc2_app_kit::NSEvent::modifierFlags_class().bits()) +} + +#[cfg(windows)] +fn windows_state_has_caps_lock(state: i16) -> bool { + state & 1 != 0 +} + +#[cfg(windows)] +#[allow(unsafe_code)] +pub fn read() -> bool { + // GetKeyState reports the toggle bit for the calling UI thread's keyboard state. + use windows::Win32::UI::Input::KeyboardAndMouse::{GetKeyState, VK_CAPITAL}; + // SAFETY: `GetKeyState` takes a virtual-key code by value and reads no caller memory. + windows_state_has_caps_lock(unsafe { GetKeyState(i32::from(VK_CAPITAL.0)) }) +} + +// Querying XWayland for a native Wayland window would read a different keyboard. + +#[cfg(test)] +mod tests { + #[test] + fn no_host_reader_preserves_scripted_value_and_next_reading_transitions() { + let mut host = super::HostCapsLock::default(); + let mut session = vectorcraft_engine::Session::new(); + session.execute("view.capsLock", &serde_json::json!({"on": true})).unwrap(); + if let Some(on) = host.transition(None) { + session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap(); + } + assert!(session.caps_lock); + assert_eq!(host.transition(Some(false)), Some(false)); + } + + #[cfg(target_os = "linux")] + #[test] + fn no_x11_reader_has_no_transition() { + assert_eq!(super::HostCapsLock::default().read_transition(), None); + } + + #[test] + fn scripted_value_survives_unchanged_host_until_transition() { + let mut host = super::HostCapsLock::default(); + let mut session = vectorcraft_engine::Session::new(); + if let Some(on) = host.observe(false) { + session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap(); + } + session.execute("view.capsLock", &serde_json::json!({"on": true})).unwrap(); + if let Some(on) = host.observe(false) { + session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap(); + } + assert!(session.caps_lock); + session.execute("view.capsLock", &serde_json::json!({"on": false})).unwrap(); + if let Some(on) = host.observe(true) { + session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap(); + } + assert!(session.caps_lock); + if let Some(on) = host.observe(false) { + session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap(); + } + assert!(!session.caps_lock); + } + + #[cfg(target_os = "macos")] + #[test] + fn mac_modifier_flags_map_caps_lock() { + assert!(!super::mac_flags_have_caps_lock(0)); + assert!(!super::mac_flags_have_caps_lock(1 << 17)); + assert!(super::mac_flags_have_caps_lock(1 << 16)); + assert!(super::mac_flags_have_caps_lock((1 << 16) | (1 << 17))); + } + + #[cfg(windows)] + #[test] + fn windows_key_state_maps_toggle_bit() { + assert!(!super::windows_state_has_caps_lock(0)); + assert!(!super::windows_state_has_caps_lock(i16::MIN)); + assert!(super::windows_state_has_caps_lock(1)); + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 33d27ab35..a7d86d201 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -19,6 +19,7 @@ #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] mod automation_args; +mod caps_lock; mod clipboard; #[cfg(feature = "wgpu")] mod compositor_gpu; @@ -94,6 +95,7 @@ use titlebar::{custom_titlebar, native_options}; struct App { app: VectorcraftApp, + host_caps_lock: caps_lock::HostCapsLock, /// Reported by wgpu when the window's graphics device is lost (a driver reset). graphics_loss: GraphicsLoss, /// The graphics device was lost and the unsaved changes are kept for Data Recovery. @@ -487,6 +489,7 @@ fn main() -> std::process::ExitCode { // The content and the pointer stay aligned on Windows (#1758). let window_frame = window_frame::service(cc, custom_titlebar); Ok(Box::new(App { + host_caps_lock: caps_lock::HostCapsLock::new(winit_window.as_deref()), app, graphics_loss, graphics_lost: false, diff --git a/crates/engine/src/cmd/views.rs b/crates/engine/src/cmd/views.rs index 281b99230..8597a0963 100644 --- a/crates/engine/src/cmd/views.rs +++ b/crates/engine/src/cmd/views.rs @@ -9,6 +9,7 @@ use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, f64_req, fi pub fn specs() -> Vec { vec![ + cmd!(query "view.capsLock", "Caps Lock Cursor State", [], None, "{on?: bool} set or read the Caps Lock state the desktop app reports; while it is on, the drawing tools show the precise crosshair, and Use Precise Cursors keeps its saved value → {on}", always, caps_lock), cmd!( "view.saved.new", "New View…", @@ -54,6 +55,13 @@ fn glyph_snap_options(s: &mut Session, p: &Value) -> Result { Ok(json!(s.prefs.glyph_snap)) } +fn caps_lock(s: &mut Session, p: &Value) -> Result { + if let Some(on) = p.get("on") { + s.caps_lock = on.as_bool().ok_or_else(|| bad("view.capsLock", "on must be true or false"))?; + } + Ok(json!({ "on": s.caps_lock })) +} + fn transparency_grid(s: &mut Session, p: &Value) -> Result { let st = s.doc_mut()?; st.transparency_grid = p.get("on").and_then(Value::as_bool).unwrap_or(!st.transparency_grid); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 595b22743..d5b4bdb03 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -48,6 +48,8 @@ pub struct Session { docs: Vec, active: Option, pub prefs: Prefs, + /// Whether Caps Lock is on, as the desktop host reports it; never saved with the preferences. + pub caps_lock: bool, /// Fill/stroke for new art (the toolbar proxy). pub paint: PaintDefaults, /// Which proxy is in front (true = Fill, false = Stroke) — the X key toggles. diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index a331144d5..b7e7f51fe 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -28,6 +28,7 @@ impl Session { docs: vec![], active: None, prefs: Prefs::default(), + caps_lock: false, paint: PaintDefaults::default(), fill_active: true, clipboard: Clipboard::default(), diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index d2123ef87..feaba57a1 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -389,6 +389,31 @@ fn use_precise_cursors_makes_drawing_cursors_crosshairs() { assert_eq!(cursor(&mut s, "eyedropper"), Cursor::Eyedropper); } +/// Caps Lock gives the drawing tools the precise crosshair while it is on, and leaves the saved +/// Use Precise Cursors preference as it was. +#[test] +fn caps_lock_gives_drawing_tools_the_crosshair() { + use vectorcraft_tools::{Cursor, Mods}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let cursor = |s: &mut Session, tool: &str| { + s.select_tool(tool, ViewInfo::default()).unwrap(); + s.cursor(vectorcraft_geom::Point::new(300.0, 300.0), Mods::default(), ViewInfo::default()) + }; + assert_eq!(s.execute("view.capsLock", &json!({})).unwrap(), json!({"on": false})); + assert_eq!(s.execute("view.capsLock", &json!({"on": true})).unwrap(), json!({"on": true})); + assert!(!s.prefs.use_precise_cursors, "Caps Lock leaves the preference off"); + assert_eq!(cursor(&mut s, "pen"), Cursor::Crosshair); + assert_eq!(cursor(&mut s, "lasso"), Cursor::Crosshair); + assert_eq!(cursor(&mut s, "eyedropper"), Cursor::Crosshair); + assert_eq!(cursor(&mut s, "selection"), Cursor::Arrow); + assert_eq!(s.execute("view.capsLock", &json!({"on": false})).unwrap(), json!({"on": false})); + assert_eq!(cursor(&mut s, "pen"), Cursor::Pen); + assert_eq!(cursor(&mut s, "eyedropper"), Cursor::Eyedropper); + assert!(s.execute("view.capsLock", &json!({"on": 1})).is_err()); + assert_eq!(s.execute("view.capsLock", &json!({})).unwrap(), json!({"on": false})); +} + /// Use Precise Cursors changes the pointer of the tools that have one of their own: off, each shows its own /// pointer; on, a crosshair. Every other drawing tool shows a crosshair either way, and the Type tool and the /// Scissors over no path keep theirs. diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 90f4ff61c..642d9a034 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -604,8 +604,8 @@ impl Session { v } - /// The pointer the active tool shows at `p` (General › Use Precise Cursors makes the drawing - /// tools' a crosshair, [`Cursor::precise`]). With Cmd held, the pointer of the selection tool a + /// The pointer the active tool shows at `p` (General › Use Precise Cursors, or Caps Lock while + /// it is on, makes the drawing tools' a crosshair, [`Cursor::precise`]). With Cmd held, the pointer of the selection tool a /// press would borrow ([`Self::lend_selection_tool`]). pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor { let lent = if mods.cmd && self.lender.is_none() { self.lent_tool(self.tool.id(), &mods) } else { None }; @@ -619,7 +619,7 @@ impl Session { } None => self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods)), }; - if self.prefs.use_precise_cursors { c.precise() } else { c } + if self.prefs.use_precise_cursors || self.caps_lock { c.precise() } else { c } } pub fn tool_options(&self) -> Value { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 13d5a00d8..355322a78 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -31,6 +31,16 @@ mod view; pub use fonts::MAX_RECENT_FONTS; impl VectorcraftApp { + /// Takes the host's Caps Lock state, which gives the drawing tools the crosshair while it is on + /// without changing the saved Use Precise Cursors preference. + pub fn set_caps_lock(&mut self, on: bool) { + if self.session.caps_lock != on + && let Err(err) = self.run("view.capsLock", json!({ "on": on })) + { + log::error!("Could not update Caps Lock cursor state: {err}"); + } + } + /// The app around `session`, reaching the platform through `services` (file pickers, the /// clipboard, files and the like). It starts cataloguing the installed fonts at once, gives /// Data Recovery the host's store and makes a view for each open document. diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 2cbbf3eb1..8ca150101 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -92,7 +92,11 @@ impl VectorcraftApp { let ime_view = self.view_info(); let ime_target = crate::ime::target(&mut self.session, ime_view); let ime_before = crate::ime::stamp(&mut self.session, ime_view); + let cursor_settings = (self.session.caps_lock, self.session.prefs.use_precise_cursors); let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); + if r.is_ok() && cursor_settings != (self.session.caps_lock, self.session.prefs.use_precise_cursors) { + self.canvas.hover_cursor = None; + } if r.is_ok() && let Some(ctx) = self.ui_ctx.clone() { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index f7361da0c..1bcc164a3 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -75,6 +75,26 @@ fn system_cursors_show_the_same_crosshair_with_precise_cursors_on_or_off() { } } +/// A change to Caps Lock or to Use Precise Cursors through `app.run` (the host, the control channel, +/// MCP) refreshes the pointer over the canvas without the pointer moving. +#[test] +fn command_cursor_settings_refresh_hover_cursor_while_view_moves() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.session.select_tool("pen", vectorcraft_engine::ViewInfo::default()).unwrap(); + app.hover_doc = Some(Point::new(200.0, 150.0)); + let mods = Mods::default(); + let view = vectorcraft_engine::ViewInfo::default(); + assert_eq!(hover_cursor(&mut app, mods, view, false), Some(Cursor::Pen)); + + app.run("view.capsLock", json!({"on": true})).unwrap(); + assert_eq!(hover_cursor(&mut app, mods, view, false), Some(Cursor::Crosshair)); + app.run("view.capsLock", json!({"on": false})).unwrap(); + assert_eq!(hover_cursor(&mut app, mods, view, false), Some(Cursor::Pen)); + app.run("prefs.set", json!({"key": "usePreciseCursors", "value": true})).unwrap(); + assert_eq!(hover_cursor(&mut app, mods, view, false), Some(Cursor::Crosshair)); +} + /// #826: in a turned view each ruler still has ticks along its whole length; at 0° the top one /// reads x where the canvas has it. #[test] diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 17a8864c1..046639b94 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -524,7 +524,7 @@ fn precise_cursors_command_toggles_the_preference() { fn precise_cursors_command_is_listed_and_assignable_in_the_shortcut_editor() { use crate::shortcut_editor as ed; let e = ed::entry("view.preciseCursors").expect("listed in the editor"); - assert_eq!((e.label.as_str(), e.group.as_str(), e.is_tool), ("Precise Cursors", "View", false)); + assert_eq!((e.label.as_str(), e.group.as_str(), e.is_tool), ("Use Precise Cursors", "View", false)); assert_eq!(ed::default_of("view.preciseCursors"), None, "no default key"); let mut overrides = craft_keymap::Overrides::new(); assert_eq!(ed::effective_in(&overrides, "view.preciseCursors"), None); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index bd633da60..355da568f 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -187,7 +187,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ), ( "view.preciseCursors", - "Precise Cursors", + "Use Precise Cursors", "", "{} toggle General › Use Precise Cursors: the drawing tools' pointers become a crosshair → true when on", ), diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index ec205e4f5..7cad17d0c 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -176,6 +176,24 @@ mod tests { } } + /// Searching for the preference's name, Use Precise Cursors, finds the command that toggles it, + /// and choosing the first result changes the preference. + #[test] + fn use_precise_cursors_palette_result_toggles_the_preference() { + let ctx = egui::Context::default(); + let query = "use precise cursors"; + let entries = entries(&ctx, Lang::EN); + let matches: Vec<&Entry> = entries.iter().filter(|e| query.split_whitespace().all(|word| e.haystack.contains(word))).take(14).collect(); + assert!(matches.iter().any(|e| e.id == "view.preciseCursors"), "preference toggle appears for its name"); + assert!(entries.iter().filter(|e| e.id == "view.capsLock").all(|e| e.shown != "Use Precise Cursors")); + + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + let before = app.session.prefs.use_precise_cursors; + let first = matches.first().expect("query has a result"); + menus::invoke(&mut app, &first.id, json!({})); + assert_ne!(app.session.prefs.use_precise_cursors, before, "first result toggles the preference"); + } + /// The palette lists `select.key` once, as Cancel Key Object (#541): a pick runs it with no id, /// which cancels the key object. `engine.commands` gives agents the same label. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f7a7c1e16..a135d587c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -240,7 +240,7 @@ tools, never checked side by side) isn't captured row by row. | Polygon/Star exact-size placement remembers the chosen sides/points for later drags and dialog openings; Cancel preserves the previous count | done | Session tool options; existing dialog-opening gestures retained; regression in `toolbar.rs` | | Tool double-click opens its options | done | | | Per-platform mapping (Cmd ↔ Ctrl, Option ↔ Alt) | done | | -| Eyedropper cursor; Use Precise Cursors | partial | original pipette at the sampling tip, distinct from the precise crosshair at five DPI scales (#1269); OS cursor display and Caps Lock unverified; a Precise Cursors command toggles the preference and can take a shortcut (#1663); cursors are painted into the window on Wayland (#1652, not run on Linux) | +| Eyedropper cursor; Use Precise Cursors | partial | original pipette at the sampling tip, distinct from the precise crosshair at five DPI scales (#1269); OS cursor display unverified; while Caps Lock is on, the tools that Use Precise Cursors changes show the crosshair, and the saved preference does not change (the desktop app reads the lock state on macOS, Windows and Linux X11, not on native Wayland; `view.capsLock` reads or sets it); a Use Precise Cursors command toggles the preference and can take a shortcut (#1663); cursors are painted into the window on Wayland (#1652, not run on Linux) | | Every tool's modifiers checked against the shortcut reference | missing | this is the fidelity pass | ## Keyboard shortcuts