diff --git a/AGENTS.md b/AGENTS.md index 97b5ccdbc0..a003c661ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written crates/ geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles) psd codecs L0 standalone format crates (no workspace deps; publishable) - tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate + tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2, Windows barrel button); the one isolated unsafe crate doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data) ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format @@ -52,7 +52,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | t People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`. - **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`. -- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only its `src/macos.rs` allows `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else. +- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop) and on Windows a pointer message hook (`GetPointerPenInfo`). Only its `src/macos.rs` and `src/windows.rs` allow `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s and pointer messages); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else. - **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input. - **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic). - **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s. diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index b0b00a865c..11a4190dcf 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -71,8 +71,7 @@ mod services; // macOS gets one instance from LaunchServices (open-documents Apple events). #[cfg(any(not(target_os = "macos"), test))] mod single_instance; -// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener. -#[cfg(any(target_os = "macos", target_os = "linux", test))] +// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button). mod tablet; mod ui_state; mod window_frame; @@ -627,6 +626,11 @@ fn main() -> eframe::Result { let tablet_monitor = std::cell::OnceCell::new(); #[cfg(target_os = "macos")] let tablet_monitor = &tablet_monitor; + // The Windows pointer message hook; dropping it unhooks when the event loop returns. + #[cfg(target_os = "windows")] + let tablet_monitor_win = std::cell::OnceCell::new(); + #[cfg(target_os = "windows")] + let tablet_monitor_win = &tablet_monitor_win; // Read the displays' ICC profiles while the window opens (colour-managed canvas; `None` // where the platform has no reader). @@ -853,8 +857,13 @@ fn main() -> eframe::Result { if let Some(monitor) = tablet::install_macos(&app.stylus.feed) { let _ = tablet_monitor.set(monitor); } + #[cfg(target_os = "windows")] + if let Some(monitor) = tablet::install_windows() { + let _ = tablet_monitor_win.set(monitor); + } #[cfg(target_os = "linux")] tablet::spawn_x11(&app.stylus.feed, display); + app.services.right_button_down = tablet::right_button_service(); // Paths on the command line (Linux/Windows file associations, `photocraft a.psd`). app.open_paths(&files); if !unreadable_paths.is_empty() { diff --git a/apps/photocraft/src/tablet.rs b/apps/photocraft/src/tablet.rs index fc063d2fbc..bd9507f00b 100644 --- a/apps/photocraft/src/tablet.rs +++ b/apps/photocraft/src/tablet.rs @@ -1,10 +1,12 @@ -//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79). +//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79), and +//! barrel button on Windows (#5905). //! -//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit -//! local event monitor; XInput2 raw events on a second X connection) and this module writes each -//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's -//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards -//! `WM_POINTER` pressure as touch force. +//! winit 0.30 drops tablet data on both macOS and X11, so `photocraft-tablet` reads it beside winit +//! (an AppKit local event monitor; XInput2 raw events on a second X connection) and this module writes +//! each sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's +//! Pointer Events and automation's simulated pen. On Windows, winit forwards `WM_POINTER` pressure +//! as touch force, but drops `PEN_FLAG_BARREL` (`0x0001`); `photocraft-tablet` recovers the barrel +//! button through a thread-local message hook beside winit. //! //! Wayland compositors give a pen only to clients that bind the tablet protocol //! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs @@ -19,15 +21,37 @@ use photocraft_tablet::Sample; use photocraft_ui_egui::stylus::{PenSample, StylusFeed}; /// A tablet sample as the UI's pen sample. +#[allow(dead_code)] pub fn pen_sample(s: Sample) -> PenSample { PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized() } /// Writes tablet samples into `feed` (`None` = a mouse). +#[allow(dead_code)] pub fn sink(feed: StylusFeed) -> impl Fn(Option) + Send + 'static { move |s| feed.set(s.map(pen_sample)) } +/// Install the Windows pointer message hook (main thread; keep the result until the event loop +/// returns). On failure this logs why: the pen barrel button is then missed. +#[cfg(target_os = "windows")] +pub fn install_windows() -> Option { + photocraft_tablet::windows::Monitor::install().map_err(|e| log::warn!("{e}")).ok() +} + +/// Physical right mouse button or stylus barrel button down check (Windows). +#[cfg(target_os = "windows")] +pub fn right_button_service() -> Option { + Some(Box::new(|| { + photocraft_tablet::windows::is_barrel_button_down() || winsafe::GetAsyncKeyState(winsafe::co::VK::RBUTTON) + })) +} + +#[cfg(not(target_os = "windows"))] +pub fn right_button_service() -> Option { + None +} + /// Install the AppKit tablet monitor (main thread, once winit created the shared application: /// from eframe's app creation, not before the event loop). Keep the result until the event loop /// returns. On failure this logs why: pens then paint like a mouse. diff --git a/crates/tablet/Cargo.toml b/crates/tablet/Cargo.toml index d4602ec898..8f6ad08fca 100644 --- a/crates/tablet/Cargo.toml +++ b/crates/tablet/Cargo.toml @@ -3,11 +3,12 @@ name = "photocraft-tablet" version.workspace = true edition.workspace = true license.workspace = true -description = "Pen tablet pressure, tilt, rotation and eraser end on macOS (AppKit) and Linux X11 (XInput2)" +description = "Pen tablet pressure, tilt, rotation and eraser end on macOS (AppKit) and Linux X11 (XInput2), and barrel button on Windows" # The one isolated `unsafe` helper crate (craftrules never-crash, AGENTS.md): the AppKit event -# monitor needs Objective-C interop. Only `src/macos.rs` allows `unsafe`; everything else is -# denied, and every other crate keeps the workspace's `unsafe_code = "forbid"`. +# monitor on macOS and the WH_GETMESSAGE pointer hook on Windows need platform interop. Only +# `src/macos.rs` and `src/windows.rs` allow `unsafe`; everything else is denied, and every other +# crate keeps the workspace's `unsafe_code = "forbid"`. [lints.rust] unsafe_code = "deny" diff --git a/crates/tablet/src/lib.rs b/crates/tablet/src/lib.rs index 16214be149..06a5c92bfa 100644 --- a/crates/tablet/src/lib.rs +++ b/crates/tablet/src/lib.rs @@ -4,9 +4,13 @@ //! - **macOS** ([`macos::Monitor`]): an AppKit local event monitor reads the tablet fields of //! each `NSEvent` (`pressure`, `tilt`, `rotation`, `subtype`, and `pointingDeviceType` from //! proximity events) before winit sees the event, so every pointer event the UI handles already -//! has its pen sample. AppKit's block-based monitor API needs Objective-C interop, so -//! `macos.rs` is the only module in the workspace that allows `unsafe` (craftrules -//! never-crash: an isolated, tested helper crate). +//! has its pen sample. +//! - **Windows** ([`windows::Monitor`]): a thread-local message hook inspects `WM_POINTER*` +//! messages on the UI thread's message queue to read `PEN_FLAG_BARREL` (`GetPointerPenInfo`), +//! which winit 0.30 drops by mapping pen inputs to touch events. +//! AppKit's monitor API and Windows' thread hook need OS interop, so `macos.rs` and `windows.rs` +//! allow `unsafe` (craftrules never-crash: an isolated, tested helper crate with `SAFETY:` +//! comments on every block). //! - **Linux X11** ([`x11::spawn`]): a second, pure-Rust X connection (x11rb) selects XInput2 //! raw events on the root window and maps the tablet device's valuators ("Abs Pressure", //! "Abs Tilt X/Y", "Abs Rotary Z") to samples. Raw events reach every client that asks, also @@ -33,6 +37,8 @@ pub mod xi; pub mod macos; #[cfg(target_os = "linux")] pub mod x11; +#[cfg(target_os = "windows")] +pub mod windows; /// One pen reading. Tilt is in degrees (-90..90, W3C Pointer Events convention: +x tilts to the /// right, +y towards the user), rotation in degrees 0..360 (clockwise). diff --git a/crates/tablet/src/windows.rs b/crates/tablet/src/windows.rs new file mode 100644 index 0000000000..eb5b2b28c2 --- /dev/null +++ b/crates/tablet/src/windows.rs @@ -0,0 +1,195 @@ +//! Windows pointer hook for tablet data dropped by winit (such as the pen barrel button). +//! +//! winit 0.30 drops `POINTER_PEN_INFO::penFlags` (`PEN_FLAG_BARREL`) on Windows and turns pen +//! inputs into `WindowEvent::Touch`, mapping them to `PointerButton::Primary`. Windows Ink also +//! does not update `GetAsyncKeyState(VK_RBUTTON)` for stylus barrel buttons. This thread-local +//! `WH_GETMESSAGE` hook inspects `WM_POINTER*` messages on the UI thread's message queue to +//! track the physical barrel button state. + +#![allow(unsafe_code, clippy::upper_case_acronyms)] + +use std::sync::atomic::{AtomicBool, Ordering}; + +static BARREL_DOWN: AtomicBool = AtomicBool::new(false); + +#[repr(C)] +struct POINT { + x: i32, + y: i32, +} + +#[repr(C)] +struct MSG { + hwnd: *mut std::ffi::c_void, + message: u32, + w_param: usize, + l_param: isize, + time: u32, + pt: POINT, +} + +#[repr(C)] +struct POINTER_INFO { + pointer_type: u32, + pointer_id: u32, + frame_id: u32, + pointer_flags: u32, + source_device: *mut std::ffi::c_void, + hwnd_target: *mut std::ffi::c_void, + pt_pixel_location: POINT, + pt_himetric_location: POINT, + pt_pixel_location_raw: POINT, + pt_himetric_location_raw: POINT, + dw_time: u32, + history_count: u32, + input_data: i32, + dw_key_states: u32, + performance_count: u64, + button_change_type: i32, +} + +#[repr(C)] +struct POINTER_PEN_INFO { + pointer_info: POINTER_INFO, + pen_flags: u32, + pen_mask: u32, + pressure: u32, + rotation: u32, + tilt_x: i32, + tilt_y: i32, +} + +#[link(name = "user32")] +unsafe extern "system" { + fn SetWindowsHookExW( + id_hook: i32, + lpfn: unsafe extern "system" fn(i32, usize, isize) -> isize, + h_mod: *mut std::ffi::c_void, + dw_thread_id: u32, + ) -> *mut std::ffi::c_void; + fn UnhookWindowsHookEx(hhk: *mut std::ffi::c_void) -> i32; + fn CallNextHookEx(hhk: *mut std::ffi::c_void, n_code: i32, w_param: usize, l_param: isize) -> isize; + fn GetCurrentThreadId() -> u32; + fn GetPointerPenInfo(pointer_id: u32, pen_info: *mut POINTER_PEN_INFO) -> i32; +} + +const WH_GETMESSAGE: i32 = 3; +const WM_POINTERUPDATE: u32 = 0x0245; +const WM_POINTERDOWN: u32 = 0x0246; +const WM_POINTERUP: u32 = 0x0247; +const WM_POINTERLEAVE: u32 = 0x024A; + +/// `PEN_FLAG_BARREL` (winuser.h): the barrel button of the pen is pressed. +pub const PEN_FLAG_BARREL: u32 = 0x00000001; + +/// `POINTER_MESSAGE_FLAG_SECONDBUTTON` (winuser.h): the secondary action button is pressed. +pub const POINTER_FLAG_SECONDBUTTON: u32 = 0x00000020; + +/// Pure decode of whether the stylus barrel button is pressed. +/// +/// Plain tip contact sets `POINTER_FLAG_INCONTACT` (`0x0004`) and optionally `POINTER_FLAG_FIRSTBUTTON` +/// (`0x0010`), which must never be confused with the barrel button (`POINTER_FLAG_SECONDBUTTON`, `0x0020`). +pub fn is_barrel_pressed(pen_flags: Option, pointer_message_flags: u32) -> bool { + let second_button = (pointer_message_flags & POINTER_FLAG_SECONDBUTTON) != 0; + match pen_flags { + Some(pen) => (pen & PEN_FLAG_BARREL) != 0 || second_button, + None => second_button, + } +} + +unsafe extern "system" fn get_msg_proc(code: i32, w_param: usize, l_param: isize) -> isize { + if code >= 0 && l_param != 0 { + // SAFETY: Win32 guarantees that for code >= 0, l_param points to a valid MSG struct + // dispatched to this thread's message queue. + let msg = unsafe { &*(l_param as *const MSG) }; + match msg.message { + WM_POINTERDOWN | WM_POINTERUPDATE => { + let pointer_id = (msg.w_param & 0xFFFF) as u32; + let pointer_message_flags = (msg.w_param >> 16) as u32; + // SAFETY: `pen_info` is a stack-allocated buffer initialized to zero, and `pointer_id` + // is the pointer identifier from the valid WM_POINTER message. + let pen_flags = unsafe { + let mut info: POINTER_PEN_INFO = std::mem::zeroed(); + (GetPointerPenInfo(pointer_id, &mut info) != 0).then_some(info.pen_flags) + }; + let is_barrel = is_barrel_pressed(pen_flags, pointer_message_flags); + BARREL_DOWN.store(is_barrel, Ordering::Relaxed); + } + WM_POINTERUP | WM_POINTERLEAVE => { + BARREL_DOWN.store(false, Ordering::Relaxed); + } + _ => {} + } + } + // SAFETY: CallNextHookEx passes the parameters down the hook chain. MSDN notes the first + // parameter is ignored and can be null. + unsafe { CallNextHookEx(std::ptr::null_mut(), code, w_param, l_param) } +} + +/// Keeps the thread-local pointer message hook installed; dropping it removes the hook. +/// Main-thread only (not `Send`). +pub struct Monitor { + hook: *mut std::ffi::c_void, +} + +impl Monitor { + /// Installs the thread-local pointer message hook on the UI thread. + pub fn install() -> Result { + // SAFETY: GetCurrentThreadId has no preconditions. SetWindowsHookExW installs + // a thread-specific WH_GETMESSAGE hook on the calling thread's message queue + // using our extern "system" callback. + let hook = unsafe { + let thread_id = GetCurrentThreadId(); + SetWindowsHookExW(WH_GETMESSAGE, get_msg_proc, std::ptr::null_mut(), thread_id) + }; + if hook.is_null() { + return Err(crate::Error::Platform("SetWindowsHookExW returned null".into())); + } + Ok(Self { hook }) + } +} + +impl Drop for Monitor { + fn drop(&mut self) { + // SAFETY: `self.hook` is the non-null handle returned by `SetWindowsHookExW`, + // unhooked exactly once here on the thread that created it. + unsafe { + UnhookWindowsHookEx(self.hook); + } + BARREL_DOWN.store(false, Ordering::Relaxed); + } +} + +/// Returns true if the stylus barrel button is physically pressed. +pub fn is_barrel_button_down() -> bool { + BARREL_DOWN.load(Ordering::Relaxed) +} + +#[cfg(test)] +mod tests { + use super::*; + + const POINTER_FLAG_INCONTACT: u32 = 0x0004; + const POINTER_FLAG_FIRSTBUTTON: u32 = 0x0010; + + #[test] + fn plain_pen_contact_is_not_treated_as_barrel() { + // Plain tip touch on digitizer: INCONTACT is set, first button may be set, no barrel flag. + assert!(!is_barrel_pressed(Some(0), POINTER_FLAG_INCONTACT)); + assert!(!is_barrel_pressed(Some(0), POINTER_FLAG_INCONTACT | POINTER_FLAG_FIRSTBUTTON)); + assert!(!is_barrel_pressed(None, POINTER_FLAG_INCONTACT)); + } + + #[test] + fn pen_barrel_flag_is_detected() { + assert!(is_barrel_pressed(Some(PEN_FLAG_BARREL), 0)); + assert!(is_barrel_pressed(Some(PEN_FLAG_BARREL), POINTER_FLAG_INCONTACT)); + } + + #[test] + fn pointer_message_second_button_is_detected() { + assert!(is_barrel_pressed(None, POINTER_FLAG_SECONDBUTTON)); + assert!(is_barrel_pressed(Some(0), POINTER_FLAG_SECONDBUTTON)); + assert!(is_barrel_pressed(Some(0), POINTER_FLAG_INCONTACT | POINTER_FLAG_SECONDBUTTON)); + } +} diff --git a/crates/ui-egui/src/brush_resize.rs b/crates/ui-egui/src/brush_resize.rs index dfba8e5166..7d41aaa1ba 100644 --- a/crates/ui-egui/src/brush_resize.rs +++ b/crates/ui-egui/src/brush_resize.rs @@ -535,4 +535,33 @@ mod tests { app.session.prefs.edit(|p| p.cursors.brush_preview_color = "nonsense".into()); assert_eq!(preview_tint(&app), Color32::from_rgba_unmultiplied(255, 0, 0, 110), "unreadable values keep the red"); } + + #[test] + fn stylus_barrel_button_resizes_with_alt_and_never_samples_color() { + let mut a = app(Tool::Brush); + a.services.right_button_down = Some(Box::new(|| true)); + a.sync_views(); + let mut h = canvas(a); + let size0 = h.state().session.tools.brush.size; + let c = h.state().last_canvas_rect.center(); + h.event(egui::Event::ModifiersChanged(Modifiers::ALT)); + h.event(egui::Event::PointerMoved(c)); + h.run_steps(1); + button(&mut h, c, PointerButton::Primary, true, Modifiers::ALT); + let mut p = c; + for _ in 0..6 { + p += vec2(10.0, 8.0); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + } + let r = h.state().brush_resize.expect("resizing via stylus barrel button"); + assert!(r.secondary); + assert!(h.state().session.tools.brush.size > size0, "right grows the brush"); + assert!(h.state().session.tools.brush.hardness > 0.2, "down hardens the brush"); + assert!(!h.state().alt_sampling, "eyedropper never triggered"); + assert!(!h.state().session.journal.iter().any(|(id, _)| id == "paint.stroke"), "nothing painted"); + button(&mut h, p, PointerButton::Primary, false, Modifiers::ALT); + h.run_steps(2); + assert!(h.state().brush_resize.is_none()); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 184e9ccce3..46188458c2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3735,7 +3735,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Live painting tools start on the press, not once the pointer passes egui's click // distance: the first dab shows at once. The drag recognised later continues that stroke, // and a click (or a long press that never moved) just ends it. + let physical_right = app.services.right_button_down.as_ref().is_some_and(|f| f()); + let right_down = ui.input(|i| i.pointer.secondary_down()) || physical_right; if strokes_live(tool) + && !right_down && app.drag.is_none() && response.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) @@ -3785,7 +3788,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: }; let press_this_frame = events.iter().any(|e| matches!(e, egui::Event::PointerButton { button: b, pressed: true, .. } if *b == button)); let down_at_start = !press_this_frame && (gesture_active_before || buttons.started); - let mut positions = if freehand_tool(tool) { + let mut positions = if app.brush_resize.is_some() { + response.interact_pointer_pos().into_iter().collect() + } else if freehand_tool(tool) { pointer_moves(&events, button, down_at_start) } else if buttons.dragged { response.interact_pointer_pos().into_iter().collect() @@ -4099,7 +4104,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: t if resizing && crate::brush_resize::applies(t) => egui::CursorIcon::None, // ⌥ turns a painting tool into the Eyedropper (`alt_eyedropper`): its cursor too, // unless Preferences › Cursors › Other Cursors asks for the precise crosshair. - t if app.alt_sampling || (app.drag.is_none() && alt_samples(t, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))) => { + t if app.alt_sampling || (app.drag.is_none() && !app.services.right_button_down.as_ref().is_some_and(|f| f()) && alt_samples(t, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))) => { // While it samples (button held), the comparison ring replaces the cursor (#213). if let Some(icon) = eyedropper_ring(app, &painter, &xf, p, response.is_pointer_button_down_on()) { icon @@ -5183,7 +5188,8 @@ pub(crate) fn paint_bucket_cursor(ctx: &egui::Context, p: Pos2) -> egui::CursorI /// painting and sampling. fn alt_eyedropper(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { if matches!(ev, ToolEvent::Down { .. }) { - app.alt_sampling = alt_samples(app.active_tool(), mods); + let physical_right = app.services.right_button_down.as_ref().is_some_and(|f| f()); + app.alt_sampling = !physical_right && alt_samples(app.active_tool(), mods); } if !app.alt_sampling { return false; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 89f293a2cb..81c5251b5e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -454,6 +454,8 @@ pub type CapsLockFn = Box bool>; pub type WindowFrameFn = Box; /// The caption buttons' current style ([`Services::caption_style`]). pub type CaptionStyleFn = Box CaptionStyle>; +/// Physical right mouse button or stylus barrel button down (Windows; read for brush resize). +pub type RightButtonDownFn = Box bool + Send + Sync>; /// Platform services injected by the app binary (file dialogs, codecs), keeping this crate free of /// I/O dependencies. @@ -550,6 +552,8 @@ pub struct Services { /// style and button layout). Read every frame, with the context to wake when it changes; /// `None` keeps Windows' buttons at the right end. pub caption_style: Option, + /// Physical right mouse button or pen barrel button currently down. + pub right_button_down: 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 unless the shell attached a store before startup. diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index a45c016c59..6a19c9ff77 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -100,21 +100,32 @@ pub fn right_erases(app: &PhotocraftApp, tool: Tool) -> bool { /// brush with Alt held (#297), erases (Erase preference) or opens the Brush Preset picker. Arms /// `secondary_erase` or `brush_resize_armed` for this frame's `Down`. pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) -> Buttons { - let (mods, right_down) = response.ctx.input(|i| (i.modifiers, i.pointer.secondary_down())); + let physical_right = app.services.right_button_down.as_ref().is_some_and(|f| f()); + let (mods, mut right_down) = response.ctx.input(|i| (i.modifiers, i.pointer.secondary_down())); + right_down |= physical_right; + let right_started = response.drag_started_by(PointerButton::Secondary) || (physical_right && response.drag_started()); + let right_dragged = response.dragged_by(PointerButton::Secondary) || (physical_right && response.dragged()); + let resizing = app.brush_resize.is_some_and(|r| r.secondary); + let right_stopped = response.drag_stopped_by(PointerButton::Secondary) || (resizing && response.drag_stopped()); + + if physical_right && crate::brush_resize::is_right_gesture(crate::workspace_ui::sticky_mods(app, mods)) { + app.alt_sampling = false; + } + // Alt+right-drag resizes the brush (brush_resize.rs); its events reach `tool_event` like a // left drag's, and nothing paints. A resize whose release was missed ends here. - crate::brush_resize::release_stale(app, right_down || response.drag_stopped_by(PointerButton::Secondary)); + crate::brush_resize::release_stale(app, right_down || response.drag_stopped_by(PointerButton::Secondary) || response.drag_stopped()); let resize_start = crate::brush_resize::applies(tool) && app.drag.is_none() && crate::brush_resize::is_right_gesture(crate::workspace_ui::sticky_mods(app, mods)) - && response.drag_started_by(PointerButton::Secondary); + && (right_started || (physical_right && response.dragged() && app.brush_resize.is_none())); let resizing = app.brush_resize.is_some_and(|r| r.secondary); - app.brush_resize_armed = resize_start; + app.brush_resize_armed = resize_start || (physical_right && resizing); // ⌘/Ctrl+right-click lists the layers under the pointer instead (layer_pick_ui.rs, #307). let layer_menu = crate::layer_pick_ui::is_gesture(tool, mods); let erase = right_erases(app, tool) && !resize_start && !resizing && !layer_menu; let right_stroke = erase && app.drag.is_some(); - let right_start = erase && response.drag_started_by(PointerButton::Secondary); + let right_start = erase && right_started; let right_click = response.secondary_clicked(); if right_click && !erase @@ -129,8 +140,8 @@ pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) let right_drag = right_stroke || resizing; Buttons { started: response.drag_started_by(PointerButton::Primary) || right_start || resize_start, - dragged: response.dragged_by(PointerButton::Primary) || (right_drag && response.dragged_by(PointerButton::Secondary)), - stopped: response.drag_stopped_by(PointerButton::Primary) || (right_drag && response.drag_stopped_by(PointerButton::Secondary)), + dragged: response.dragged_by(PointerButton::Primary) || (right_drag && right_dragged), + stopped: response.drag_stopped_by(PointerButton::Primary) || (right_drag && right_stopped), clicked: response.clicked() || erase_click, } }