From 0e011a69dc75cc03af7ffa4d6b68d08d782594db Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Sat, 10 Oct 2026 01:25:36 +0200 Subject: [PATCH] feat(canvas): Caps Lock forces the precise crosshair for painting tools (#1758) (#2111) * feat(canvas): Caps Lock forces the precise crosshair for painting tools (#1758) * Caps Lock poll: back off for 5 s after an X error instead of reconnecting and warning every frame Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/photocraft/Cargo.toml | 5 +- apps/photocraft/src/caps_lock.rs | 121 ++++++++++++++++++++++++++ apps/photocraft/src/main.rs | 4 + crates/ui-egui/src/canvas.rs | 51 ++++++----- crates/ui-egui/src/caps_lock_tests.rs | 115 ++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 16 ++++ 6 files changed, 287 insertions(+), 25 deletions(-) create mode 100644 apps/photocraft/src/caps_lock.rs create mode 100644 crates/ui-egui/src/caps_lock_tests.rs diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index b1293f6a..d2683149 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -43,7 +43,8 @@ ron = "0.12" [target.'cfg(target_os = "macos")'.dependencies] # Safe CoreGraphics bindings, compatible with the existing macOS 11 deployment target. -core-graphics2 = { version = "=0.6.1", default-features = false, features = ["display", "window", "link"] } +# `event` (CGEventSource) is used for the Caps Lock poll (#1758). +core-graphics2 = { version = "=0.6.1", default-features = false, features = ["display", "window", "link", "event"] } # Finder double-click / Open With / Dock drops arrive as Apple events; this small audited crate # registers the handler (Objective-C interop needs unsafe, which our workspace forbids) behind a # safe API. Pinned exactly: review the diff before bumping. @@ -63,7 +64,7 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] } # Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly. [target.'cfg(target_os = "linux")'.dependencies] -x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr"] } +x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] } # Wayland desktop color selection through the existing XDG portal transport. zbus = "=5.19.0" # Already used by the portal stack; bounded waits let cancelling close the OS request. diff --git a/apps/photocraft/src/caps_lock.rs b/apps/photocraft/src/caps_lock.rs new file mode 100644 index 00000000..b9fdb4f7 --- /dev/null +++ b/apps/photocraft/src/caps_lock.rs @@ -0,0 +1,121 @@ +//! Caps Lock state, read from the OS once per frame so the canvas can show the precise crosshair +//! for painting tools whatever the cursor preference (Photoshop's ⇪ behaviour, #1758). +//! +//! winit and egui have no Caps Lock key or modifier — `egui::Key` has no variant, `Modifiers` no +//! field, and egui-winit's `key_from_named_key` drops the `NamedKey::CapsLock` key event before it +//! reaches the app. So the state is polled here instead, from the platform: +//! +//! - **X11 (and XWayland)**: XKB's Lock modifier state, via the pure-Rust x11rb client. The app +//! already opens on X11 when the `Preferences › Performance › Linux display server` preference +//! asks for it, and on Wayland through XWayland for pen support, so this is the common path. +//! - **Windows**: `GetKeyState(VK_CAPITAL)` through the safe `winsafe` binding. +//! - **macOS**: `CGEventSourceFlagsState` (`kCGEventFlagAlphaShift`) through `core-graphics2`. +//! - **Native Wayland**: there is no global keyboard-state query (no equivalent of XKB GetState or +//! VK_CAPITAL); the service is `None` there and Caps Lock leaves the cursor preference alone. +//! +//! No `unsafe`: every OS call goes through a safe binding. + +pub use platform::service; + +#[cfg(target_os = "macos")] +mod platform { + use core_graphics2::event_source::CGEventSource; + use core_graphics2::event_types::{CGEventFlags, CGEventSourceStateID}; + use photocraft_ui_egui::CapsLockFn; + + pub fn service(_cc: &eframe::CreationContext<'_>) -> Option { + Some(Box::new(|| { + let flags = CGEventSource::flags_state(CGEventSourceStateID::CombinedSessionState); + flags.contains(CGEventFlags::MaskAlphaShift) + })) + } +} + +#[cfg(target_os = "windows")] +mod platform { + use photocraft_ui_egui::CapsLockFn; + + pub fn service(_cc: &eframe::CreationContext<'_>) -> Option { + Some(Box::new(|| { + // The second element of the pair is "key toggled on", which is Caps Lock's state. + let (_, toggled) = winsafe::GetKeyState(winsafe::co::VK::CAPITAL); + toggled + })) + } +} + +#[cfg(target_os = "linux")] +mod platform { + use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle}; + use photocraft_ui_egui::CapsLockFn; + use x11rb::protocol::xkb::{self, ConnectionExt as _}; + use x11rb::protocol::xproto::ModMask; + use x11rb::rust_connection::RustConnection; + + pub fn service(cc: &eframe::CreationContext<'_>) -> Option { + // XKB keyboard state lives on the X server. The window is our window: whether it is X11 + // (or XWayland) is whether there is an X server to ask. Native Wayland has no such + // connection and degrades to the cursor preference (no service). + let is_x11 = matches!(cc.window_handle().ok()?.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)); + if !is_x11 { + return None; + } + // Lazily opened on first use and kept: the Lock-modifier mask is stable per keymap, so it + // is queried once and only the toggle state is re-read every frame. + let mut conn: Option<(RustConnection, ModMask)> = None; + // After a failure, wait before reconnecting (and warn once per failure), so a broken X + // connection doesn't reconnect and log on every frame. + let mut retry_at: Option = None; + Some(Box::new(move || { + if retry_at.is_some_and(|t| std::time::Instant::now() < t) { + return false; + } + match caps_lock_on(&mut conn) { + Ok(on) => { + retry_at = None; + on + } + Err(error) => { + log::warn!("couldn't read the Caps Lock state: {error}"); + conn = None; + retry_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(5)); + false + } + } + })) + } + + /// Caps Lock is on when the Lock modifier is both engaged (`locked_mods`) and present in the + /// keyboard's indicator map (so a re-mapped lock key is still recognised). + fn caps_lock_on(conn: &mut Option<(RustConnection, ModMask)>) -> Result { + if conn.is_none() { + let (c, _) = RustConnection::connect(None).map_err(|e| e.to_string())?; + // XKB requests need the extension active first (it is, on every modern server). + let enabled = c.xkb_use_extension(1, 0).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?; + if !enabled.supported { + return Err("the X server has no XKB extension".into()); + } + let device = u16::from(xkb::ID::USE_CORE_KBD); + // Indicator 0 is the Caps Lock indicator; its `.mods` is the Lock-modifier mask. + let map = c.xkb_get_indicator_map(device, 1).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?; + let lock = map.maps.first().map_or(ModMask::LOCK, |m| m.mods); + *conn = Some((c, lock)); + } + let (conn, lock) = match conn.as_ref() { + Some((conn, lock)) => (conn, *lock), + None => return Err("no X connection".into()), + }; + let device = u16::from(xkb::ID::USE_CORE_KBD); + let state = conn.xkb_get_state(device).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?; + Ok(u16::from(state.locked_mods) & u16::from(lock) != 0) + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +mod platform { + use photocraft_ui_egui::CapsLockFn; + + pub fn service(_cc: &eframe::CreationContext<'_>) -> Option { + None + } +} diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 439edaaa..f5d7c7b8 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -24,6 +24,7 @@ mod app_dirs; mod app_icon; #[cfg(target_os = "macos")] mod apple_events; +mod caps_lock; mod control_server; mod crash_guard; mod cursor; @@ -444,6 +445,9 @@ fn main() -> eframe::Result { let _ = in_window_menus; // Where file drags and drops are (winit 0.30 doesn't say). app.services.cursor_pos = cursor::service(cc); + // Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the + // precise crosshair for painting tools while it is toggled on (#1758). + app.services.caps_lock = caps_lock::service(cc); app.services.screen_pick = screen_color::service(cc, app.services.is_wayland); // Tablet pressure/tilt/eraser (winit drops them): the macOS monitor and the X11 reader // write into the stylus feed. The monitor goes in here, not before the event loop: diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 88d72b1a..6a75c7b3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2865,30 +2865,35 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // full size), precise crosshair, or the standard pointer. use photocraft_engine::prefs::PaintingCursor; let cur = app.session.prefs().cursors.clone(); - let painting = app.drag.is_some(); - let brush = &app.session.tools.brush; - let full = (brush.size / 2.0 * xf.zoom).max(1.0); - let r = if cur.painting == PaintingCursor::NormalTip { (full * (0.5 + 0.5 * brush.hardness.clamp(0.0, 1.0))).max(1.0) } else { full }; - match cur.painting { - PaintingCursor::Standard => egui::CursorIcon::Default, - PaintingCursor::Precise => crate::tool_cursor::crosshair(&painter, p, 6.0, 0.0), - _ if painting && cur.show_only_crosshair_while_painting => crate::tool_cursor::crosshair(&painter, p, 5.0, 0.0), - // The Pencil: the square of whole pixels its dab fills, on the pixel grid. - _ if tool == Tool::Pencil => { - let ppp = painter.ctx().pixels_per_point(); - let sq = pencil_cursor_rect(&xf, xf.to_doc(p), brush.size, ppp); - let px = 1.0 / ppp; - painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_black_alpha(160)), egui::StrokeKind::Outside); - painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_white_alpha(230)), egui::StrokeKind::Inside); - // Too small to see where it is: the hotspot as well. - if cur.show_crosshair_in_brush_tip || sq.width() < 6.0 { - crate::tool_cursor::crosshair(&painter, p, 4.0, 0.0); + // ⇪ forces the precise crosshair, whatever the cursor preference (#1758). + if app.caps_lock { + crate::tool_cursor::crosshair(&painter, p, 6.0, 0.0) + } else { + let painting = app.drag.is_some(); + let brush = &app.session.tools.brush; + let full = (brush.size / 2.0 * xf.zoom).max(1.0); + let r = if cur.painting == PaintingCursor::NormalTip { (full * (0.5 + 0.5 * brush.hardness.clamp(0.0, 1.0))).max(1.0) } else { full }; + match cur.painting { + PaintingCursor::Standard => egui::CursorIcon::Default, + PaintingCursor::Precise => crate::tool_cursor::crosshair(&painter, p, 6.0, 0.0), + _ if painting && cur.show_only_crosshair_while_painting => crate::tool_cursor::crosshair(&painter, p, 5.0, 0.0), + // The Pencil: the square of whole pixels its dab fills, on the pixel grid. + _ if tool == Tool::Pencil => { + let ppp = painter.ctx().pixels_per_point(); + let sq = pencil_cursor_rect(&xf, xf.to_doc(p), brush.size, ppp); + let px = 1.0 / ppp; + painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_black_alpha(160)), egui::StrokeKind::Outside); + painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_white_alpha(230)), egui::StrokeKind::Inside); + // Too small to see where it is: the hotspot as well. + if cur.show_crosshair_in_brush_tip || sq.width() < 6.0 { + crate::tool_cursor::crosshair(&painter, p, 4.0, 0.0); + } + egui::CursorIcon::None + } + _ => { + let centre = brush_tip_centre(tool, alt || app.ui.shell.sticky_alt, cur.show_crosshair_in_brush_tip, r); + crate::tool_cursor::circle(&painter, p, r, centre) } - egui::CursorIcon::None - } - _ => { - let centre = brush_tip_centre(tool, alt || app.ui.shell.sticky_alt, cur.show_crosshair_in_brush_tip, r); - crate::tool_cursor::circle(&painter, p, r, centre) } } } diff --git a/crates/ui-egui/src/caps_lock_tests.rs b/crates/ui-egui/src/caps_lock_tests.rs new file mode 100644 index 00000000..f9edc587 --- /dev/null +++ b/crates/ui-egui/src/caps_lock_tests.rs @@ -0,0 +1,115 @@ +//! Caps Lock swaps the painting tools' cursor to the precise crosshair, exactly like Photoshop, +//! whatever the Cursors › Painting preference is (#1758). The override changes only painting +//! tools (Brush, Pencil, Clone Stamp, Healing, Eraser, …): Move, Marquee, Crop and Zoom keep +//! their own cursors. Tests drive the real canvas (`document_area`) and compare the painted +//! cursor that caps lock produces with the one the Precise preference draws — they must match, +//! and the Standard preference (off) must not. + +use egui::CursorIcon; +use egui_kittest::Harness; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +fn capslock_harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("tools.setBrush", json!({"brush": {"size": 40, "hardness": 1.0}})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.tool = Tool::Brush; + let mut h = Harness::builder().with_size(egui::vec2(900.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + // 100 %: one document pixel per point; the pointer hovers the canvas centre. + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [200.0, 150.0]; + v.fit_pending = false; + h.run_steps(2); + let p = h.state().last_canvas_rect.center(); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(3); + h +} + +/// Everything the cursor path can change in one frame: the cursor icon, the OS cursor bitmap +/// (Windows) and the shapes painted on the canvas (the tool-cursor path paints the crosshair +/// and the brush tip outline). Comparison across two preference/caps-lock states of the same +/// harness is platform-independent. +#[derive(Debug, PartialEq)] +struct CursorFp { + icon: CursorIcon, + image: Option<[u16; 2]>, + shapes: Vec, +} + +fn fp(h: &Harness<'_, PhotocraftApp>) -> CursorFp { + let out = h.output(); + CursorFp { icon: out.platform_output.cursor_icon, image: out.platform_output.cursor_image.as_ref().map(|i| i.size), shapes: out.shapes.clone() } +} + +fn painting(h: &mut Harness<'_, PhotocraftApp>, value: &str) { + h.state_mut().run("prefs.set", json!({"path": "cursors.painting", "value": value})).unwrap(); + h.run_steps(2); +} + +/// Caps Lock is read from the OS by the app shell; tests toggle the per-frame flag directly. +fn caps(h: &mut Harness<'_, PhotocraftApp>, on: bool) { + h.state_mut().caps_lock = on; + h.run_steps(2); +} + +#[test] +fn caps_lock_forces_the_precise_crosshair_for_painting_tools_whatever_the_preference() { + for tool in [ + Tool::Brush, + Tool::Pencil, + Tool::CloneStamp, + Tool::Healing, + Tool::Eraser, + // The block that honours the painting preference also covers the Quick Selection. + Tool::QuickSelection, + ] { + let mut h = capslock_harness(); + // Each tool keeps its own brush (#218), so switch the tool before painting. + h.state_mut().ui.tool = tool; + h.run_steps(2); + painting(&mut h, "standard"); + let off = fp(&h); + painting(&mut h, "precise"); + let precise = fp(&h); + painting(&mut h, "standard"); + caps(&mut h, true); + let on = fp(&h); + assert_ne!(off, precise, "{tool:?}: the Standard and Precise cursors must differ"); + assert_eq!(on, precise, "{tool:?}: caps lock must draw the same crosshair as the Precise preference"); + assert_ne!(on, off, "{tool:?}: caps lock must not keep the Standard cursor"); + caps(&mut h, false); + assert_eq!(fp(&h), off, "{tool:?}: releasing caps lock restores the preference cursor"); + } +} + +#[test] +fn caps_lock_leaves_non_painting_tools_cursors_alone() { + for tool in [Tool::Move, Tool::RectMarquee, Tool::EllipseMarquee, Tool::Crop, Tool::Zoom, Tool::Hand] { + let mut h = capslock_harness(); + h.state_mut().ui.tool = tool; + h.run_steps(2); + let off = fp(&h); + caps(&mut h, true); + let on = fp(&h); + assert_eq!(on, off, "{tool:?}: caps lock must not change a non-painting cursor"); + caps(&mut h, false); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 09c5ee0b..51d18202 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -251,6 +251,9 @@ pub type OsEventsFn = Box Vec>; pub type QuitFn = Box; /// Where the OS pointer is now, in egui points within the window; `None` when unknown. pub type CursorPosFn = Box Option>; +/// Whether Caps Lock is toggled on, read from the OS. `None` where the platform cannot +/// report it (native Wayland): the cursor then follows the cursor preference (#1758). +pub type CapsLockFn = Box bool>; /// Platform services injected by the app binary (file dialogs, codecs), keeping this crate free of /// I/O dependencies. @@ -307,6 +310,9 @@ pub struct Services { /// The pointer position read from the OS (desktop): winit 0.30's file drops carry none, and /// the window gets no pointer events during an OS drag (see `file_open::DropTarget`). pub cursor_pos: Option, + /// Caps Lock toggled on (desktop; `None` on Wayland and the web). Read once per frame, so + /// the canvas can show the precise crosshair for painting tools, whatever the preference. + pub caps_lock: Option, /// The persistent brush preset store, loading in the background (desktop; see /// `photocraft_engine::preset_store`). Attached to the session once it arrives; without /// one, brush presets are session-only (web, tests). @@ -375,6 +381,9 @@ pub struct PhotocraftApp { /// This press began with ⌥ (Alt) held on a painting tool, so it samples colours instead of /// painting until it is released (`canvas::alt_eyedropper`, #417). pub(crate) alt_sampling: bool, + /// Caps Lock toggled on, read from the OS each frame (`services.caps_lock`): painting tools + /// show the precise crosshair whatever the cursor preference (#1758). + pub caps_lock: bool, /// The first digit of a two-digit opacity typed on the number keys (`opacity_keys`, #352). pub(crate) opacity_keys: opacity_keys::Pending, control_rx: Option>, @@ -544,6 +553,7 @@ impl PhotocraftApp { quick_pick: false, brush_resize_armed: false, alt_sampling: false, + caps_lock: false, opacity_keys: None, control_rx: None, pending_screenshots: Vec::new(), @@ -1073,6 +1083,9 @@ impl PhotocraftApp { impl eframe::App for PhotocraftApp { fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { i18n::set_current(i18n::Lang::from_pref(&self.session.prefs().interface.language)); + // Caps Lock state read from the OS once per frame (see `Services::caps_lock`): the canvas + // cursor block needs it before the frame renders. `None` (Wayland/web) keeps `false`. + self.caps_lock = self.services.caps_lock.as_mut().is_some_and(|f| f()); if !self.styled { Self::setup_context(ctx, self.ui.theme); self.styled = true; @@ -1717,6 +1730,9 @@ mod blend_dropdown_wheel_tests; #[cfg(test)] mod marquee_tests; +#[cfg(test)] +mod caps_lock_tests; + #[cfg(test)] mod stamp_tests;