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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gert Pellin - GPE 2026-10-10 01:25:36 +02:00 committed by GitHub
parent 5630e112b4
commit 0e011a69dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 287 additions and 25 deletions

View File

@ -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.

View File

@ -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<CapsLockFn> {
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<CapsLockFn> {
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<CapsLockFn> {
// 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<std::time::Instant> = 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<bool, String> {
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<CapsLockFn> {
None
}
}

View File

@ -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:

View File

@ -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)
}
}
}

View File

@ -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<egui::epaint::ClippedShape>,
}
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);
}
}

View File

@ -251,6 +251,9 @@ pub type OsEventsFn = Box<dyn FnMut() -> Vec<OsEvent>>;
pub type QuitFn = Box<dyn FnMut()>;
/// Where the OS pointer is now, in egui points within the window; `None` when unknown.
pub type CursorPosFn = Box<dyn FnMut(&egui::Context) -> Option<egui::Pos2>>;
/// 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<dyn FnMut() -> 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<CursorPosFn>,
/// 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<CapsLockFn>,
/// 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<Receiver<ControlRequest>>,
@ -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;