mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
On Windows with Windows Ink (WM_POINTER), winit drops penFlags (PEN_FLAG_BARREL) and reports stylus contact as WindowEvent::Touch, which egui-winit maps exclusively to PointerButton::Primary. Additionally, Windows Ink does not update GetAsyncKeyState(VK_RBUTTON) when pressing the pen barrel button. Consequently, Alt + Barrel Button drag was recognized as Alt + Left Drag (triggering the eyedropper and painting strokes instead of brush resize), and right-clicks with the stylus were treated as left-clicks. This commit resolves the issue by: 1. Adding a native Windows message hook (WH_GETMESSAGE) in crates/tablet/src/windows.rs calling GetPointerPenInfo to intercept PEN_FLAG_BARREL (0x00000001) and POINTER_FLAG_SECONDBUTTON. 2. Exposing ight_button_down in Services combining the pointer hook and GetAsyncKeyState(VK_RBUTTON). 3. Updating canvas.rs and paint_mouse.rs to prevent strokes_live and alt_eyedropper from hijacking the stylus gesture when the barrel button is held, and correctly routing resize events. 4. Adding tests asserting that Alt + stylus barrel drag resizes diameter horizontally and adjusts hardness vertically without triggering eyedropper or painting.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<Sample>) + 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> {
|
||||
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<photocraft_ui_egui::RightButtonDownFn> {
|
||||
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<photocraft_ui_egui::RightButtonDownFn> {
|
||||
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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<u32>, 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<Self, crate::Error> {
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -454,6 +454,8 @@ pub type CapsLockFn = Box<dyn FnMut() -> bool>;
|
||||
pub type WindowFrameFn = Box<dyn FnMut()>;
|
||||
/// The caption buttons' current style ([`Services::caption_style`]).
|
||||
pub type CaptionStyleFn = Box<dyn Fn(&egui::Context) -> CaptionStyle>;
|
||||
/// Physical right mouse button or stylus barrel button down (Windows; read for brush resize).
|
||||
pub type RightButtonDownFn = Box<dyn Fn() -> 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<CaptionStyleFn>,
|
||||
/// Physical right mouse button or pen barrel button currently down.
|
||||
pub right_button_down: Option<RightButtonDownFn>,
|
||||
/// 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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user