diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index ed55926d..eca3f18d 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -539,6 +539,7 @@ fn routed(id: &str) -> bool { || id == "plugin.run" || id.starts_with("image.adjustments.") || matches!(id, "paint.stroke" | "paint.pencil" | "paint.bucket" | "paint.gradient" | "paint.mixerBrush" | "edit.fill" | "image.applyImage") + || crate::fill_key_cmds::IDS.contains(&id) || matches!( id, "paint.cloneStamp" diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 5ea8638e..c5c90032 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -272,7 +272,7 @@ fn build() -> Vec { "Fill…", ["Edit"], Some("Shift+F5"), - r##"{"contents":"color|pattern"="color","color":"#rrggbb|[r,g,b,a]"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":%=100,"target":"pixels"|{"channel":i}|"quickMask"?}"##, + r##"{"contents":"color|pattern"="color","color":"#rrggbb|[r,g,b,a]"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":%=100,"preserveTransparency":bool=false,"target":"pixels"|{"channel":i}|"quickMask"?}"##, has_pixel_or_channel, |s, p| { if p.get("contents").and_then(Value::as_str) == Some("pattern") { @@ -280,10 +280,13 @@ fn build() -> Vec { } let color = color_param(p, "color", s.tools.foreground); let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_param(s, p)?) }; + // Fill › Preserve Transparency (⇧⌥⌫ / ⇧⌘⌫): only where the layer already has pixels. + let preserve = p.get("preserveTransparency").and_then(Value::as_bool).unwrap_or(false); s.edit("Fill", |doc, _| { let sel = doc.selection.clone(); let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc.bounds()); let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; + let lock = lock || (preserve && !crate::channel_cmds::is_channel_target(p)); pixels::fill_surface(surf, area, color, sel.as_ref(), lock); Ok(()) })?; @@ -972,6 +975,7 @@ fn build() -> Vec { v.extend(crate::layer_multi_cmds::specs()); v.extend(crate::prefs::specs()); v.extend(crate::edit_menu_cmds::specs()); + v.extend(crate::fill_key_cmds::specs()); v.extend(crate::align_cmds::specs()); v.extend(crate::photo_cmds::specs()); v.extend(crate::lens_cmds::specs()); diff --git a/crates/engine/src/fill_key_cmds.rs b/crates/engine/src/fill_key_cmds.rs new file mode 100644 index 00000000..ed775e3f --- /dev/null +++ b/crates/engine/src/fill_key_cmds.rs @@ -0,0 +1,215 @@ +//! Photoshop's fill keys (#249): ⌥⌫ fills the selection (or the whole layer) with the foreground +//! colour, ⌘⌫ with the background colour, and with ⇧ added they preserve transparency. Photoshop +//! has no menu item for them; here they are ordinary commands with default shortcuts, so they +//! appear in Edit › Keyboard Shortcuts (Tools) and can be rebound, recorded and driven by agents. +//! +//! Each one is `edit.fill` with the colour and Preserve Transparency fixed: one "Fill" history +//! step, the Channels panel target (alpha channel, Quick Mask) honoured, locked layers refused. + +use serde_json::{Map, Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +/// The fill-key command ids (routed to the Channels panel target like `edit.fill`). +pub const IDS: [&str; 4] = ["edit.fillForeground", "edit.fillBackground", "edit.fillForegroundPreserve", "edit.fillBackgroundPreserve"]; + +const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|{"channel":i}|"quickMask"?}"##; + +fn enabled(s: &Session) -> std::result::Result<(), String> { + let edit_fill = crate::commands::find("edit.fill").ok_or_else(|| "Fill is unavailable".to_string())?; + (edit_fill.enabled)(s) +} + +/// Run `edit.fill` with `color` (the foreground or background colour) and Preserve Transparency. +fn fill(s: &mut Session, p: &Value, cmd: &str, background: bool, preserve: bool) -> Result { + let mut q: Map = match p { + Value::Object(m) => m.clone(), + Value::Null => Map::new(), + _ => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "expected an object".into() }), + }; + // The colour and transparency handling are what the key means; don't let params override them. + for k in ["color", "contents", "pattern", "preserveTransparency", "opacity"] { + q.remove(k); + } + let c = if background { s.tools.background } else { s.tools.foreground }; + q.insert("color".into(), json!([c[0], c[1], c[2], 1.0])); + q.insert("preserveTransparency".into(), json!(preserve)); + let edit_fill = crate::commands::find("edit.fill").ok_or_else(|| EngineError::Other("Fill is unavailable".into()))?; + (edit_fill.run)(s, &Value::Object(q)) +} + +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "edit.fillForeground", + label: "Fill with Foreground Color", + menu: &[], + shortcut: Some("Alt+Backspace"), + params: PARAMS, + enabled, + run: |s, p| fill(s, p, "edit.fillForeground", false, false), + journal: true, + }, + CommandSpec { + id: "edit.fillBackground", + label: "Fill with Background Color", + menu: &[], + shortcut: Some("Cmd+Backspace"), + params: PARAMS, + enabled, + run: |s, p| fill(s, p, "edit.fillBackground", true, false), + journal: true, + }, + CommandSpec { + id: "edit.fillForegroundPreserve", + label: "Fill with Foreground Color, Preserve Transparency", + menu: &[], + shortcut: Some("Alt+Shift+Backspace"), + params: PARAMS, + enabled, + run: |s, p| fill(s, p, "edit.fillForegroundPreserve", false, true), + journal: true, + }, + CommandSpec { + id: "edit.fillBackgroundPreserve", + label: "Fill with Background Color, Preserve Transparency", + menu: &[], + shortcut: Some("Cmd+Shift+Backspace"), + params: PARAMS, + enabled, + run: |s, p| fill(s, p, "edit.fillBackgroundPreserve", true, true), + journal: true, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session() -> Session { + session_at(8) + } + + fn session_at(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6, "background": "transparent", "depth": depth})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#ff0000", "background": "#0000ff"})).unwrap(); + s + } + + fn px(s: &Session, x: i32, y: i32) -> [f32; 4] { + let st = s.active().unwrap(); + let l = st.doc.layers.last().unwrap(); + let surf = l.surface().unwrap(); + let fmt = surf.format(); + let v = surf.read_region(photocraft_geom::Rect::new(x, y, x + 1, y + 1)); + photocraft_raster::to_rgba(&fmt, &v) + } + + #[test] + fn foreground_fills_the_layer_without_a_selection() { + let mut s = session(); + s.execute("edit.fillForeground", json!({})).unwrap(); + assert_eq!(px(&s, 0, 0), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(px(&s, 7, 5), [1.0, 0.0, 0.0, 1.0]); + // One undoable "Fill" step. + assert!(s.execute("edit.undo", json!({})).is_ok()); + assert_eq!(px(&s, 0, 0)[3], 0.0); + } + + #[test] + fn fills_at_every_bit_depth() { + for depth in [8, 16, 32] { + let mut s = session_at(depth); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 6})).unwrap(); + s.execute("edit.fillForeground", json!({})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("edit.fillBackgroundPreserve", json!({})).unwrap(); + let [r, g, b, a] = px(&s, 1, 1); + assert!(r.abs() < 1e-3 && g.abs() < 1e-3 && (b - 1.0).abs() < 1e-3 && (a - 1.0).abs() < 1e-3, "{depth}-bit: {:?}", px(&s, 1, 1)); + assert_eq!(px(&s, 6, 1)[3], 0.0, "{depth}-bit"); + } + } + + #[test] + fn background_fills_only_the_selection() { + let mut s = session(); + s.execute("select.rect", json!({"x": 2, "y": 1, "width": 3, "height": 2})).unwrap(); + s.execute("edit.fillBackground", json!({})).unwrap(); + assert_eq!(px(&s, 3, 2), [0.0, 0.0, 1.0, 1.0]); + assert_eq!(px(&s, 0, 0)[3], 0.0); + assert_eq!(px(&s, 5, 1)[3], 0.0); + } + + #[test] + fn preserve_transparency_recolours_only_existing_pixels() { + let mut s = session(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap(); + s.execute("edit.fillBackground", json!({})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("edit.fillForegroundPreserve", json!({})).unwrap(); + assert_eq!(px(&s, 1, 1), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(px(&s, 4, 4)[3], 0.0, "transparent pixels stay transparent"); + s.execute("edit.fillBackgroundPreserve", json!({})).unwrap(); + assert_eq!(px(&s, 0, 0), [0.0, 0.0, 1.0, 1.0]); + assert_eq!(px(&s, 4, 4)[3], 0.0); + // Without ⇧ the whole layer fills. + s.execute("edit.fillForeground", json!({})).unwrap(); + assert_eq!(px(&s, 4, 4), [1.0, 0.0, 0.0, 1.0]); + } + + #[test] + fn params_cannot_change_the_colour_and_bad_params_fail_gracefully() { + let mut s = session(); + s.execute("edit.fillForeground", json!({"color": "#00ff00", "opacity": 10})).unwrap(); + assert_eq!(px(&s, 0, 0), [1.0, 0.0, 0.0, 1.0]); + for id in IDS { + assert!(s.execute(id, json!([1, 2])).is_err(), "{id}"); + assert!(s.execute(id, json!("x")).is_err(), "{id}"); + assert!(s.execute(id, json!({"layer": 99_999})).is_err(), "{id}"); + assert!(s.execute(id, json!({"target": {"channel": 42}})).is_err(), "{id}"); + } + } + + #[test] + fn locked_layer_and_no_document_are_errors() { + let mut s = Session::new(); + for id in IDS { + assert!(s.execute(id, json!({})).is_err(), "{id} without a document"); + } + let mut s = session(); + s.execute("layer.lockLayers", json!({"pixels": true})).unwrap(); + for id in IDS { + let e = s.execute(id, json!({})).unwrap_err().to_string(); + assert!(e.contains("locked"), "{id}: {e}"); + } + assert_eq!(px(&s, 0, 0)[3], 0.0); + } + + /// `cargo test --release -p photocraft-engine --lib fill_keys_24mp -- --ignored --nocapture` + #[test] + #[ignore] + fn fill_keys_24mp() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 6000, "height": 4000})).unwrap(); + for (id, sel) in [("edit.fillForeground", false), ("edit.fillBackgroundPreserve", false), ("edit.fillForeground", true)] { + if sel { + s.execute("select.rect", json!({"x": 500, "y": 500, "width": 4000, "height": 3000})).unwrap(); + } + let t = std::time::Instant::now(); + s.execute(id, json!({})).unwrap(); + println!("{id} 24 MP (selection: {sel}): {:.0} ms", t.elapsed().as_secs_f64() * 1e3); + } + } + + #[test] + fn registered_with_photoshop_default_shortcuts() { + let sc = |id| crate::commands::find(id).and_then(|c| c.shortcut); + assert_eq!(sc("edit.fillForeground"), Some("Alt+Backspace")); + assert_eq!(sc("edit.fillBackground"), Some("Cmd+Backspace")); + assert_eq!(sc("edit.fillForegroundPreserve"), Some("Alt+Shift+Backspace")); + assert_eq!(sc("edit.fillBackgroundPreserve"), Some("Cmd+Shift+Backspace")); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7a1d93fb..294f0b8f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,6 +28,7 @@ pub mod edit_menu_cmds; pub mod eraser_cmds; pub mod extra_cmds; pub mod file_cmds; +pub mod fill_key_cmds; pub mod filters; pub mod filters_ext; mod frame_cmds; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 8a2a8b3e..8cc95350 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1132,6 +1132,22 @@ fn capitalize(s: &str) -> String { } } +/// Hold-and-release gestures (#249): temporary tools that last while their key is held and give +/// the previous tool back on release. They are bound like shortcuts (Edit › Keyboard Shortcuts › +/// Tools › Temporary, `edit.keyboardShortcuts`) but held rather than pressed, so they are not +/// commands; the UI reads their keys through [`Preferences::shortcut`]. While a selection or +/// shape is being dragged, the Hand key repositions it instead of panning (Photoshop's Space). +pub const TEMPORARY_TOOLS: &[(&str, &str, &str)] = &[ + ("tools.temporary.hand", "Hand Tool (hold)", "Space"), + ("tools.temporary.zoomIn", "Zoom In (hold, drag to scrub)", "Cmd+Space"), + ("tools.temporary.zoomOut", "Zoom Out (hold)", "Cmd+Alt+Space"), +]; + +/// Every bindable id with its default: commands, then the temporary tools. +fn bindable() -> impl Iterator)> { + crate::command_specs().iter().map(|c| (c.id, c.shortcut)).chain(TEMPORARY_TOOLS.iter().map(|t| (t.0, Some(t.2)))) +} + /// Shortcuts bound to more than one command: (shortcut, ids). `bindings` are (id, shortcut). pub fn conflicts<'a>(bindings: impl IntoIterator) -> Vec<(String, Vec)> { let mut by: BTreeMap> = BTreeMap::new(); @@ -1362,7 +1378,7 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { if let Some(m) = p.get("set").and_then(Value::as_object) { let mut next = s.prefs().clone(); for (id, v) in m { - if crate::commands::find(id).is_none() && !p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false) { + if !bindable().any(|(b, _)| b == id) && !p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false) { return Err(bad(cmd, format!("unknown command `{id}`"))); } next.set(&format!("shortcuts.{id}"), v.clone()).map_err(|e| bad(cmd, e))?; @@ -1371,9 +1387,9 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { if p.get("removeConflicts").and_then(Value::as_bool).unwrap_or(true) { for (id, v) in m { let Some(sc) = v.as_str().and_then(normalize_shortcut) else { continue }; - for c in crate::command_specs() { - if c.id != id && next.shortcut(c.id, c.shortcut).and_then(normalize_shortcut).as_deref() == Some(sc.as_str()) { - next.shortcuts.insert(c.id.to_string(), String::new()); + for (c, def) in bindable() { + if c != id && next.shortcut(c, def).and_then(normalize_shortcut).as_deref() == Some(sc.as_str()) { + next.shortcuts.insert(c.to_string(), String::new()); } } } @@ -1382,13 +1398,22 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { } let prefs = s.prefs(); let filter = p.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase); + let temporary = TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, &["Tools", "Temporary"][..], Some(t.2), true)); let list: Vec = crate::command_specs() .iter() - .filter(|c| filter.as_ref().is_none_or(|f| c.id.to_ascii_lowercase().contains(f) || c.label.to_ascii_lowercase().contains(f))) - .filter(|c| p.get("list").and_then(Value::as_bool).unwrap_or(false) || filter.is_some() || prefs.shortcuts.contains_key(c.id)) - .map(|c| json!({"id": c.id, "label": c.label, "menu": c.menu, "default": c.shortcut, "shortcut": prefs.shortcut(c.id, c.shortcut)})) + .map(|c| (c.id, c.label, c.menu, c.shortcut, false)) + .chain(temporary) + .filter(|c| filter.as_ref().is_none_or(|f| c.0.to_ascii_lowercase().contains(f) || c.1.to_ascii_lowercase().contains(f))) + .filter(|c| p.get("list").and_then(Value::as_bool).unwrap_or(false) || filter.is_some() || prefs.shortcuts.contains_key(c.0)) + .map(|(id, label, menu, def, hold)| { + let mut v = json!({"id": id, "label": label, "menu": menu, "default": def, "shortcut": prefs.shortcut(id, def)}); + if hold { + v["hold"] = json!(true); + } + v + }) .collect(); - let bindings: Vec<(&str, &str)> = crate::command_specs().iter().filter_map(|c| Some((c.id, prefs.shortcut(c.id, c.shortcut)?))).collect(); + let bindings: Vec<(&str, &str)> = bindable().filter_map(|(id, def)| Some((id, prefs.shortcut(id, def)?))).collect(); let conflicts: Vec = conflicts(bindings).into_iter().map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})).collect(); Ok(json!({"overrides": prefs.shortcuts, "commands": list, "conflicts": conflicts})) } @@ -1511,7 +1536,7 @@ pub fn specs() -> Vec { "Keyboard Shortcuts…", ["Edit"], Some("Cmd+Alt+Shift+K"), - r##"{"set":{"":"Cmd+Shift+X"|""(remove)|null(default)}?,"reset":true|["",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false}"##, + r##"{"set":{"|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"reset":true|["",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false}"##, keyboard_shortcuts, true ), diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 76d75e59..eb21c827 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -143,6 +143,32 @@ fn keyboard_shortcuts_reassign_and_reset() { assert!(r["commands"].as_array().unwrap().iter().any(|c| c["id"] == "filter.blur.gaussianBlur")); } +/// #249: temporary tools (held keys) and the fill / colour keys are bindable like commands. +#[test] +fn temporary_tools_and_fill_keys_are_rebindable() { + let mut s = session(); + let r = s.execute("edit.keyboardShortcuts", json!({"filter": "tools.temporary"})).unwrap(); + let ids: Vec<&str> = r["commands"].as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); + assert!(ids.contains(&"tools.temporary.hand") && ids.contains(&"tools.temporary.zoomIn") && ids.contains(&"tools.temporary.zoomOut"), "{ids:?}"); + assert!(r["commands"].as_array().unwrap().iter().all(|c| c["hold"] == true)); + // Rebind the temporary zoom; an unknown id still fails. + s.execute("edit.keyboardShortcuts", json!({"set": {"tools.temporary.zoomIn": "Ctrl+Space"}})).unwrap(); + assert_eq!(s.prefs().shortcut("tools.temporary.zoomIn", Some("Cmd+Space")), Some("Ctrl+Space")); + assert!(s.execute("edit.keyboardShortcuts", json!({"set": {"tools.temporary.unknown": "Space"}})).is_err()); + // Taking a temporary tool's key for a command removes it from the temporary tool. + s.execute("edit.keyboardShortcuts", json!({"set": {"edit.fillForeground": "Space"}})).unwrap(); + assert_eq!(s.prefs().shortcut("tools.temporary.hand", Some("Space")), None); + assert_eq!(s.prefs().shortcut("edit.fillForeground", Some("Alt+Backspace")), Some("Space")); + // D / X and the fill keys are listed with their Photoshop defaults. + let r = s.execute("edit.keyboardShortcuts", json!({"list": true})).unwrap(); + let def = |id: &str| r["commands"].as_array().unwrap().iter().find(|c| c["id"] == id).map(|c| c["default"].clone()); + assert_eq!(def("tools.swapColors"), Some(json!("X"))); + assert_eq!(def("tools.defaultColors"), Some(json!("D"))); + assert_eq!(def("edit.fillBackground"), Some(json!("Cmd+Backspace"))); + // Bad values fail. + assert!(s.execute("edit.keyboardShortcuts", json!({"set": {"tools.temporary.hand": 7}})).is_err()); +} + #[test] fn menus_and_toolbar_customisation_persist() { let mut s = session(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 892436a6..4286c9f5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -46,11 +46,31 @@ pub struct Drag { pub live: egui::Modifiers, /// Which of the modifiers held at the press have been released since. pub released: egui::Modifiers, + /// The reposition key (Space) is held: pointer moves move the whole gesture instead of + /// sizing it (marquees, lasso, shapes; `hold_keys`). + pub reposition: bool, } impl Drag { pub fn new(tool: Tool, start: [f64; 2], points: Vec<[f64; 3]>, modifiers: egui::Modifiers, erase: bool) -> Self { - Self { tool, start, points, modifiers, erase, constrain: None, live: modifiers, released: egui::Modifiers::NONE } + Self { tool, start, points, modifiers, erase, constrain: None, live: modifiers, released: egui::Modifiers::NONE, reposition: false } + } + + /// Reposition: move the start and every point so the last one lands on `to` (same size). + pub fn shift_to(&mut self, to: [f64; 2]) { + let last = self.points.last().map_or(self.start, |p| [p[0], p[1]]); + let (dx, dy) = (to[0] - last[0], to[1] - last[1]); + if !(dx.is_finite() && dy.is_finite()) { + return; + } + self.start = [self.start[0] + dx, self.start[1] + dy]; + for p in &mut self.points { + p[0] += dx; + p[1] += dy; + } + if self.points.is_empty() { + self.points.push([to[0], to[1], 1.0]); + } } /// Record the modifiers of a pointer event. @@ -1314,12 +1334,29 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: app.stylus.use_pressure = app.session.prefs().tools.use_tablet_pressure; app.stylus.update(&ui.input(|i| i.events.clone())); crate::stylus::Stylus::sync_eraser_tool(app); - let space_down = ui.input(|i| i.key_down(egui::Key::Space)); - // Space while drawing a crop frame moves it instead of panning. - crate::crop_ui::set_space(app, space_down); - let space_pan = space_down && !crate::crop_ui::active(app); + // Held keys (hold_keys.rs): Space repositions a crop frame, marquee, lasso or shape being + // drawn; otherwise Space is the Hand and ⌘Space / ⌘⌥Space the Zoom tool while held. + let reposition = crate::hold_keys::reposition_held(app, &ctx); + crate::crop_ui::set_space(app, reposition); + let mut drawing = crate::crop_ui::active(app); + if let Some(d) = app.drag.as_mut().filter(|d| crate::hold_keys::repositions(d.tool)) { + d.reposition = reposition; + drawing = true; + } + let temporary = crate::hold_keys::for_frame(app, &ctx, drawing); + let space_pan = temporary == Some(crate::hold_keys::Temporary::Hand); let middle = ui.input(|i| i.pointer.middle_down()); - let tool = if space_pan || middle { Tool::Hand } else { app.ui.tool }; + let tool = match temporary { + Some(t) => t.tool(), + None if middle => Tool::Hand, + None => app.ui.tool, + }; + // Zoom direction: the temporary zoom key decides, else ⌥ (Zoom tool). + let zoom_out = |alt: bool| match temporary { + Some(crate::hold_keys::Temporary::ZoomOut) => true, + Some(crate::hold_keys::Temporary::ZoomIn) => false, + _ => alt, + }; if under_dialog { if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, app.ui.tool == Tool::Hand) { @@ -1340,6 +1377,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // (Preferences › Tools, `paint_mouse`). crate::paint_mouse::sync_tool_smoothing(app); let mut buttons = crate::paint_mouse::canvas_buttons(app, &response, tool); + // The (temporary) Hand pans above; its gestures never reach the tool underneath. + if tool == Tool::Hand { + (buttons.started, buttons.dragged, buttons.stopped) = (false, false, false); + } // Zoom tool drags: scrubby zoom or a zoom rectangle (zoom_tool.rs); clicks step below. if tool == Tool::Zoom && crate::zoom_tool::drag(app, &ctx, &mut view, &xf, &buttons, response.interact_pointer_pos()) { (buttons.started, buttons.dragged, buttons.stopped) = (false, false, false); @@ -1373,9 +1414,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let d = xf.to_doc(p); match tool { Tool::Zoom => { - let nz = zoom_step(view.zoom, if mods.alt { -1 } else { 1 }); + let nz = zoom_step(view.zoom, if zoom_out(mods.alt) { -1 } else { 1 }); zoom_about(&mut view, &xf, p, nz); } + // A click with the (temporary) Hand does nothing, never the tool underneath. + Tool::Hand => {} _ => { if tool == Tool::Move && app.ui.transform.is_none() { begin_transform_controls_at(app, &ctx, &xf, p); @@ -1480,7 +1523,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } } Tool::Zoom => { - if alt { + if zoom_out(alt) { egui::CursorIcon::ZoomOut } else { egui::CursorIcon::ZoomIn @@ -1899,7 +1942,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) crate::vector_ui::pen_move(app, x, y); } let zoom = app.current_zoom(); - if let Some(d) = &mut app.drag { + if let Some(d) = app.drag.as_mut().filter(|d| d.reposition) { + d.track(mods); + d.shift_to([x, y]); + } else if let Some(d) = &mut app.drag { d.track(mods); // ⇧: straight 0/45/90° strokes, 45° gradient angles (stroke_constraint.rs). let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); @@ -1923,6 +1969,9 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) let zoom = app.current_zoom(); let Some(mut d) = app.drag.take() else { return }; d.track(mods); + if d.reposition { + d.shift_to([x, y]); + } let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, [x, y], mods.shift, zoom); // A Move-tool click (released where it was pressed) selects, it never moves: snapping diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7af2f152..1ab0d45a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -288,8 +288,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest if let Some(t) = s("tool").and_then(Tool::from_name) { app.ui.tool = t; } - // Space held: the Crop tool moves the frame being drawn. - crate::crop_ui::set_space(app, flag("space")); + // Space held: the Crop tool moves the frame being drawn, a marquee, lasso or shape + // being drawn moves instead of growing (hold_keys.rs). + let space = flag("space"); + crate::crop_ui::set_space(app, space); for e in events { let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0); let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0); @@ -309,6 +311,9 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest let (tilt_x, tilt_y, rotation) = (pen.0.unwrap_or(0.0), pen.1.unwrap_or(0.0), pen.2.unwrap_or(0.0)); app.stylus.feed.set(Some(crate::stylus::PenSample { pressure: pr, tilt_x, tilt_y, rotation, eraser: false })); } + if let Some(d) = app.drag.as_mut().filter(|d| crate::hold_keys::repositions(d.tool)) { + d.reposition = space; + } tool_event(app, ev, mods); } app.stylus.feed.set(None); diff --git a/crates/ui-egui/src/hold_keys.rs b/crates/ui-egui/src/hold_keys.rs new file mode 100644 index 00000000..7ad62d44 --- /dev/null +++ b/crates/ui-egui/src/hold_keys.rs @@ -0,0 +1,128 @@ +//! Held keys: Photoshop's temporary tools (#249). +//! +//! - Space: the Hand tool while held. +//! - ⌘Space (Ctrl+Space off the Mac): Zoom In while held; a drag is a scrubby zoom. +//! - ⌘⌥Space: Zoom Out while held. +//! - Space while a marquee, lasso or shape is being dragged repositions it; releasing Space +//! goes back to sizing it (the Crop tool does the same for its frame, `crop_ui`). +//! +//! The current tool is never changed, so releasing the key gives the previous tool back. A +//! temporary tool that started a drag lasts until the button is released, as in Photoshop. +//! +//! The keys are bindings, not commands: they come from +//! [`photocraft_engine::prefs::TEMPORARY_TOOLS`] with Edit › Keyboard Shortcuts overrides on top +//! (Tools › Temporary), and the reposition key is whatever the Hand key is. + +use egui::{InputState, KeyboardShortcut}; + +use crate::PhotocraftApp; +use crate::shortcuts::parse; +use crate::state::Tool; + +/// A tool in effect only while its key is held. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Temporary { + Hand, + ZoomIn, + ZoomOut, +} + +impl Temporary { + pub fn id(self) -> &'static str { + match self { + Temporary::Hand => "tools.temporary.hand", + Temporary::ZoomIn => "tools.temporary.zoomIn", + Temporary::ZoomOut => "tools.temporary.zoomOut", + } + } + + pub fn tool(self) -> Tool { + match self { + Temporary::Hand => Tool::Hand, + Temporary::ZoomIn | Temporary::ZoomOut => Tool::Zoom, + } + } + + const ALL: [Temporary; 3] = [Temporary::Hand, Temporary::ZoomIn, Temporary::ZoomOut]; +} + +/// Is `id` a held temporary tool rather than a command? +pub fn is_temporary(id: &str) -> bool { + photocraft_engine::prefs::TEMPORARY_TOOLS.iter().any(|t| t.0 == id) +} + +/// The key binding of a temporary tool in effect (`None` when removed or unparsable). +pub fn binding(app: &PhotocraftApp, t: Temporary) -> Option { + let def = photocraft_engine::prefs::TEMPORARY_TOOLS.iter().find(|x| x.0 == t.id()).map(|x| x.2); + parse(app.session.prefs().shortcut(t.id(), def)?) +} + +/// Is `sc` held: its key down with at least its modifiers (more may be held, so ⇧ still squares +/// a marquee while Space is down). +fn held(i: &InputState, sc: &KeyboardShortcut) -> bool { + let (want, m) = (sc.modifiers, i.modifiers); + i.key_down(sc.logical_key) + && (!want.alt || m.alt) + && (!want.shift || m.shift) + && (!want.command || m.command) + && (!want.ctrl || m.ctrl) + && (!want.mac_cmd || m.mac_cmd) +} + +/// The temporary tool whose key is held now, the most specific binding first (⌘⌥Space before +/// ⌘Space before Space). Never while a text field has the keyboard. +pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option { + if ctx.text_edit_focused() { + return None; + } + let count = |sc: &KeyboardShortcut| { + let m = sc.modifiers; + m.alt as u8 + m.shift as u8 + (m.command || m.mac_cmd) as u8 + (m.ctrl && !m.command) as u8 + }; + let mut best: Option<(u8, Temporary)> = None; + ctx.input(|i| { + for t in Temporary::ALL { + if let Some(sc) = binding(app, t) + && held(i, &sc) + && best.is_none_or(|(n, _)| count(&sc) > n) + { + best = Some((count(&sc), t)); + } + } + }); + best.map(|(_, t)| t) +} + +/// Is the reposition key (the Hand key, any modifiers) down? +pub fn reposition_held(app: &PhotocraftApp, ctx: &egui::Context) -> bool { + !ctx.text_edit_focused() && binding(app, Temporary::Hand).is_some_and(|sc| ctx.input(|i| i.key_down(sc.logical_key))) +} + +/// Tools whose in-progress drag the reposition key moves. +pub fn repositions(t: Tool) -> bool { + matches!(t, Tool::RectMarquee | Tool::EllipseMarquee | Tool::Lasso | Tool::ObjectSelection) || crate::vector_ui::is_shape_tool(t) +} + +fn latch_id() -> egui::Id { + egui::Id::new("pc-temporary-tool") +} + +/// The temporary tool for this canvas frame: the held one, except while a selection is being +/// drawn (the key repositions it instead); a temporary tool that was in effect when the button +/// went down stays until the button comes up. +pub fn for_frame(app: &PhotocraftApp, ctx: &egui::Context, drawing: bool) -> Option { + let down = ctx.input(|i| i.pointer.primary_down()); + let latched = ctx.data(|d| d.get_temp::>(latch_id())).flatten(); + let t = if down && latched.is_some() { + latched + } else if drawing { + None + } else { + held_tool(app, ctx) + }; + ctx.data_mut(|d| d.insert_temp(latch_id(), if down { t } else { None })); + t +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/hold_keys/tests.rs b/crates/ui-egui/src/hold_keys/tests.rs new file mode 100644 index 00000000..aed70a41 --- /dev/null +++ b/crates/ui-egui/src/hold_keys/tests.rs @@ -0,0 +1,343 @@ +//! #249 through the real canvas and key handling: Space repositions a marquee being drawn, Space +//! and ⌘Space / ⌘⌥Space are temporary Hand / Zoom tools that give the tool back, D / X and the +//! fill keys are commands whose Keyboard Shortcuts overrides replace the defaults. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use photocraft_geom::Rect; +use serde_json::json; + +use super::*; +use crate::canvas::ViewXform; + +fn harness(tool: Tool) -> 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.sync_views(); + app.ui.extras.rulers = false; + app.ui.tool = tool; + let mut h = Harness::builder().with_size(vec2(1000.0, 700.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; + } + crate::shortcuts::handle(app, &ctx); + 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); + 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); + h +} + +fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { + let app = h.state(); + let v = &app.ui.views[0]; + let xf = ViewXform { rect: crate::rulers::content_rect(app, app.last_canvas_rect), zoom: v.zoom, center: v.center, flip: app.ui.view.flip_horizontal }; + xf.to_screen(x, y) +} + +/// The platform's modifiers for `Cmd` (⌘ on the Mac, Ctrl elsewhere), as egui-winit reports them. +fn platform(m: Modifiers) -> Modifiers { + let mut out = Modifiers { alt: m.alt, shift: m.shift, ctrl: m.ctrl, ..Default::default() }; + if m.command { + out.command = true; + if cfg!(target_os = "macos") { + out.mac_cmd = true; + } else { + out.ctrl = true; + } + } + out +} + +fn key(h: &mut Harness<'static, PhotocraftApp>, k: Key, pressed: bool, m: Modifiers) { + let m = platform(m); + h.event(Event::ModifiersChanged(m)); + h.event(Event::Key { key: k, physical_key: None, pressed, repeat: false, modifiers: m }); + h.run_steps(1); +} + +fn tap(h: &mut Harness<'static, PhotocraftApp>, k: Key, m: Modifiers) { + key(h, k, true, m); + key(h, k, false, m); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(1); +} + +fn button(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, down: bool, m: Modifiers) { + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: platform(m) }); + h.run_steps(1); +} + +fn move_to(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32) { + let p = screen(h, x, y); + h.event(Event::PointerMoved(p)); + h.run_steps(2); +} + +fn press_at(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32) { + let p = screen(h, x, y); + h.event(Event::PointerMoved(p)); + h.run_steps(1); + button(h, p, true, Modifiers::NONE); + move_to(h, x + 8.0, y + 6.0); +} + +fn selection(h: &Harness<'static, PhotocraftApp>) -> Rect { + h.state().session.active().unwrap().doc.selection.as_ref().map_or(Rect::EMPTY, |s| s.content_bounds()) +} + +fn click(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32, m: Modifiers) { + let p = screen(h, x, y); + h.event(Event::PointerMoved(p)); + h.run_steps(1); + button(h, p, true, m); + button(h, p, false, m); + h.run_steps(1); +} + +#[test] +fn space_repositions_a_marquee_being_drawn_then_sizing_resumes() { + let mut h = harness(Tool::RectMarquee); + press_at(&mut h, 50.0, 50.0); + move_to(&mut h, 150.0, 110.0); + key(&mut h, Key::Space, true, Modifiers::NONE); + move_to(&mut h, 200.0, 150.0); + move_to(&mut h, 230.0, 170.0); + let center = h.state().ui.views[0].center; + // Released with Space still down: the 100 × 60 rectangle, moved by (80, 60). + release(&mut h, 230.0, 170.0, Modifiers::NONE); + key(&mut h, Key::Space, false, Modifiers::NONE); + h.run_steps(2); + assert_eq!(selection(&h), Rect::new(130, 110, 230, 170), "offset, same size"); + assert_eq!(h.state().ui.views[0].center, center, "Space didn't pan"); + assert_eq!(h.state().ui.tool, Tool::RectMarquee); + + // Release Space mid-drag: sizing continues from the moved corner. + let mut h = harness(Tool::EllipseMarquee); + press_at(&mut h, 50.0, 50.0); + move_to(&mut h, 150.0, 110.0); + key(&mut h, Key::Space, true, Modifiers::NONE); + move_to(&mut h, 170.0, 120.0); + key(&mut h, Key::Space, false, Modifiers::NONE); + move_to(&mut h, 200.0, 140.0); + release(&mut h, 200.0, 140.0, Modifiers::NONE); + h.run_steps(2); + let r = selection(&h); + assert!(r.x0.abs_diff(70) <= 1 && r.y0.abs_diff(60) <= 1 && r.x1.abs_diff(200) <= 1 && r.y1.abs_diff(140) <= 1, "{r:?}"); +} + +#[test] +fn space_moves_a_lasso_being_drawn() { + let mut h = harness(Tool::Lasso); + press_at(&mut h, 50.0, 50.0); + move_to(&mut h, 120.0, 50.0); + move_to(&mut h, 120.0, 100.0); + key(&mut h, Key::Space, true, Modifiers::NONE); + move_to(&mut h, 170.0, 130.0); + release(&mut h, 170.0, 130.0, Modifiers::NONE); + key(&mut h, Key::Space, false, Modifiers::NONE); + h.run_steps(2); + let r = selection(&h); + assert!(r.x0.abs_diff(100) <= 1 && r.y0.abs_diff(80) <= 1 && r.x1.abs_diff(170) <= 1 && r.y1.abs_diff(130) <= 1, "{r:?}"); +} + +#[test] +fn space_is_a_temporary_hand_and_gives_the_tool_back() { + let mut h = harness(Tool::Brush); + key(&mut h, Key::Space, true, Modifiers::NONE); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::Hand)); + let before = h.state().ui.views[0].center; + press_at(&mut h, 100.0, 100.0); + move_to(&mut h, 160.0, 140.0); + release(&mut h, 160.0, 140.0, Modifiers::NONE); + key(&mut h, Key::Space, false, Modifiers::NONE); + h.run_steps(2); + let st = h.state(); + assert_ne!(st.ui.views[0].center, before, "the view panned"); + assert_eq!(st.ui.tool, Tool::Brush, "the brush is back"); + assert!(!st.session.active().unwrap().history.can_undo(), "nothing was painted"); +} + +#[test] +fn temporary_zoom_in_and_out_restore_the_tool() { + let mut h = harness(Tool::RectMarquee); + let z0 = h.state().ui.views[0].zoom; + key(&mut h, Key::Space, true, Modifiers::COMMAND); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomIn)); + click(&mut h, 200.0, 150.0, Modifiers::COMMAND); + key(&mut h, Key::Space, false, Modifiers::COMMAND); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + let z1 = h.state().ui.views[0].zoom; + assert!(z1 > z0, "{z0} -> {z1}"); + assert_eq!(h.state().ui.tool, Tool::RectMarquee); + assert!(h.state().session.active().unwrap().doc.selection.is_none(), "the click didn't reach the marquee"); + + key(&mut h, Key::Space, true, Modifiers::COMMAND | Modifiers::ALT); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomOut)); + click(&mut h, 200.0, 150.0, Modifiers::COMMAND | Modifiers::ALT); + key(&mut h, Key::Space, false, Modifiers::COMMAND | Modifiers::ALT); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert!(h.state().ui.views[0].zoom < z1); + assert_eq!(h.state().ui.tool, Tool::RectMarquee); + assert_eq!(held_tool(h.state(), &h.ctx), None); +} + +#[test] +fn temporary_zoom_drag_is_a_scrubby_zoom() { + let mut h = harness(Tool::Brush); + let z0 = h.state().ui.views[0].zoom; + key(&mut h, Key::Space, true, Modifiers::COMMAND); + press_at(&mut h, 200.0, 150.0); + move_to(&mut h, 300.0, 150.0); + // Releasing Space mid-drag keeps zooming until the button comes up. + key(&mut h, Key::Space, false, Modifiers::COMMAND); + move_to(&mut h, 320.0, 150.0); + release(&mut h, 320.0, 150.0, Modifiers::COMMAND); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert!(h.state().ui.views[0].zoom > z0 * 1.5); + assert_eq!(h.state().ui.tool, Tool::Brush); + assert!(!h.state().session.active().unwrap().history.can_undo(), "nothing was painted"); +} + +#[test] +fn rebound_temporary_zoom_uses_the_new_key_only() { + let mut h = harness(Tool::RectMarquee); + h.state_mut().run("edit.keyboardShortcuts", json!({"set": {"tools.temporary.zoomIn": "Alt+Z"}})).unwrap(); + key(&mut h, Key::Space, true, Modifiers::COMMAND); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::Hand), "⌘Space is no longer zoom; Space still pans"); + key(&mut h, Key::Space, false, Modifiers::COMMAND); + key(&mut h, Key::Z, true, Modifiers::ALT); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomIn)); + key(&mut h, Key::Z, false, Modifiers::ALT); + assert_eq!(held_tool(h.state(), &h.ctx), None); + // Removed: no temporary hand at all. + h.state_mut().run("edit.keyboardShortcuts", json!({"set": {"tools.temporary.hand": ""}})).unwrap(); + key(&mut h, Key::Space, true, Modifiers::NONE); + assert_eq!(held_tool(h.state(), &h.ctx), None); + assert!(!reposition_held(h.state(), &h.ctx)); +} + +fn fg_bg(h: &Harness<'static, PhotocraftApp>) -> ([f32; 4], [f32; 4]) { + let t = &h.state().session.tools; + (t.foreground, t.background) +} + +#[test] +fn d_and_x_are_rebindable_commands() { + let mut h = harness(Tool::Brush); + h.state_mut().run("tools.setColors", json!({"foreground": "#ff0000", "background": "#00ff00"})).unwrap(); + tap(&mut h, Key::X, Modifiers::NONE); + assert_eq!(fg_bg(&h), ([0.0, 1.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0]), "X swaps"); + tap(&mut h, Key::D, Modifiers::NONE); + assert_eq!(fg_bg(&h), ([0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0]), "D resets"); + // Rebound: the old key no longer fires, the new one does. + h.state_mut().run("edit.keyboardShortcuts", json!({"set": {"tools.swapColors": "Alt+Shift+X", "tools.defaultColors": ""}})).unwrap(); + h.state_mut().run("tools.setColors", json!({"foreground": "#ff0000", "background": "#00ff00"})).unwrap(); + tap(&mut h, Key::X, Modifiers::NONE); + tap(&mut h, Key::D, Modifiers::NONE); + assert_eq!(fg_bg(&h), ([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]), "plain X and D do nothing now"); + tap(&mut h, Key::X, Modifiers::ALT | Modifiers::SHIFT); + assert_eq!(fg_bg(&h), ([0.0, 1.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0])); +} + +fn rgba(h: &Harness<'static, PhotocraftApp>, x: i32, y: i32) -> [f32; 4] { + h.state().session.active().unwrap().doc.layers.last().unwrap().surface().unwrap().rgba(x, y) +} + +#[test] +fn fill_keys_fill_and_follow_overrides() { + let mut h = harness(Tool::Brush); + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.state_mut().run("tools.setColors", json!({"foreground": "#ff0000", "background": "#0000ff"})).unwrap(); + h.state_mut().run("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + // ⌥⌫: foreground into the selection. + tap(&mut h, Key::Backspace, Modifiers::ALT); + assert_eq!(rgba(&h, 15, 15), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(rgba(&h, 50, 50)[3], 0.0); + // ⇧⌘⌫ with no selection: background into the existing pixels only. + h.state_mut().run("select.deselect", json!({})).unwrap(); + tap(&mut h, Key::Backspace, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(rgba(&h, 15, 15), [0.0, 0.0, 1.0, 1.0]); + assert_eq!(rgba(&h, 50, 50)[3], 0.0, "transparency preserved"); + // ⇧⌥Delete (Windows' Delete key) does the same with the foreground. + tap(&mut h, Key::Delete, Modifiers::ALT | Modifiers::SHIFT); + assert_eq!(rgba(&h, 15, 15), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(rgba(&h, 50, 50)[3], 0.0); + // ⌘⌫: background over the whole layer. + tap(&mut h, Key::Backspace, Modifiers::COMMAND); + assert_eq!(rgba(&h, 50, 50), [0.0, 0.0, 1.0, 1.0]); + // Rebind Fill with Foreground to F9: ⌥⌫ no longer fills. + h.state_mut().run("edit.keyboardShortcuts", json!({"set": {"edit.fillForeground": "F9"}})).unwrap(); + tap(&mut h, Key::Backspace, Modifiers::ALT); + assert_eq!(rgba(&h, 50, 50), [0.0, 0.0, 1.0, 1.0], "the default no longer fires"); + tap(&mut h, Key::F9, Modifiers::NONE); + assert_eq!(rgba(&h, 50, 50), [1.0, 0.0, 0.0, 1.0]); +} + +#[test] +fn fill_key_on_a_locked_layer_reports_instead_of_crashing() { + let mut h = harness(Tool::Brush); + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.state_mut().run("layer.lockLayers", json!({"pixels": true})).unwrap(); + tap(&mut h, Key::Backspace, Modifiers::ALT); + let st = h.state(); + assert!(st.ui.status_error && st.ui.status.contains("locked"), "{}", st.ui.status); + assert_eq!(rgba(&h, 5, 5)[3], 0.0); +} + +#[test] +fn keyboard_shortcuts_dialog_lists_the_keys_under_tools() { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let items = crate::prefs_ui::shortcut_items(&app); + let find = |id: &str| items.iter().find(|i| i.0 == id).map(|i| (i.2.clone(), i.3.clone())); + let tools = || vec!["Tools".to_string()]; + assert_eq!(find("tools.swapColors"), Some((tools(), Some("X".into())))); + assert_eq!(find("tools.defaultColors"), Some((tools(), Some("D".into())))); + assert_eq!(find("edit.fillForeground"), Some((tools(), Some("Alt+Backspace".into())))); + assert_eq!(find("edit.fillBackgroundPreserve"), Some((tools(), Some("Cmd+Shift+Backspace".into())))); + let temp = vec!["Tools".to_string(), "Temporary".to_string()]; + assert_eq!(find("tools.temporary.hand"), Some((temp.clone(), Some("Space".into())))); + assert_eq!(find("tools.temporary.zoomOut"), Some((temp, Some("Cmd+Alt+Space".into())))); + assert_eq!(crate::prefs_ui::default_shortcut("tools.temporary.zoomIn").as_deref(), Some("Cmd+Space")); + // One contiguous Tools section (the dialog prints a header where the top level changes). + let first = items.iter().position(|i| i.2.first().map(String::as_str) == Some("Tools")).unwrap(); + assert!(items[first..].iter().all(|i| i.2.first().map(String::as_str) == Some("Tools"))); + // Temporary tools never dispatch as pressed commands, even when overridden. + let mut app = app; + app.run("edit.keyboardShortcuts", json!({"set": {"tools.temporary.hand": "F10"}})).unwrap(); + assert!(!crate::shortcut_dispatch::bindings(&app).iter().any(|(id, _)| is_temporary(id))); +} + +#[test] +fn agents_reposition_a_marquee_with_space() { + use crate::control::{ControlRequest, Outcome, handle}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let mut call = |p: serde_json::Value| { + let (req, _rx) = ControlRequest::new("ui.pointer", p); + assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(_))); + }; + call(json!({"tool": "rectMarquee", "events": [{"kind": "down", "x": 20, "y": 20}, {"kind": "move", "x": 120, "y": 80}]})); + call(json!({"space": true, "events": [{"kind": "move", "x": 170, "y": 110}]})); + call(json!({"space": true, "events": [{"kind": "up", "x": 170, "y": 110}]})); + let sel = app.session.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(); + assert_eq!(sel, Rect::new(70, 50, 170, 110)); +} + +fn release(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32, m: Modifiers) { + let p = screen(h, x, y); + button(h, p, false, m); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dc92c207..bddadde6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -46,6 +46,7 @@ pub mod gallery_ui; pub mod gpu_canvas; pub mod gpu_status; pub mod gradient_ui; +pub mod hold_keys; mod icon_data; pub mod icons; pub mod layer_menu_ui; diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index dbc9dbf2..7b1454f8 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -324,9 +324,25 @@ pub fn shortcut_items(app: &PhotocraftApp) -> Vec<(String, String, Vec, let default = default_shortcut(&it.id).or(it.shortcut.clone().filter(|_| !app.session.prefs().shortcuts.contains_key(&it.id))); out.push((it.id, it.label, it.path, default)); } + // Commands without a menu item: the colour and fill keys (D, X, ⌥⌫…) sit under Tools, as + // Photoshop lists its colour keys in the Tools shortcut set, after the rest; then the held + // temporary tools (#249). + let tools_key = |id: &str| id.starts_with("tools.") || photocraft_engine::fill_key_cmds::IDS.contains(&id); + let mut tools = Vec::new(); for c in photocraft_engine::command_specs() { if seen.insert(c.id.to_string()) && c.shortcut.is_some() { - out.push((c.id.into(), c.label.into(), c.menu.iter().map(|s| s.to_string()).collect(), c.shortcut.map(Into::into))); + let item = (c.id.to_string(), c.label.to_string(), c.menu.iter().map(|s| s.to_string()).collect::>(), c.shortcut.map(Into::into)); + if c.menu.is_empty() && tools_key(c.id) { + tools.push((item.0, item.1, vec!["Tools".to_string()], item.3)); + } else { + out.push(item); + } + } + } + out.extend(tools); + for (id, label, def) in prefs::TEMPORARY_TOOLS { + if seen.insert(id.to_string()) { + out.push((id.to_string(), label.to_string(), vec!["Tools".into(), "Temporary".into()], Some(def.to_string()))); } } out diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index 4eef9605..fa2c162f 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -7,7 +7,7 @@ //! is disabled in the current state reports why on the status bar instead of doing nothing //! silently, as the greyed menu item shows. -use egui::{Key, KeyboardShortcut, Modifiers}; +use egui::{Key, KeyboardShortcut}; use serde_json::json; use crate::PhotocraftApp; @@ -41,7 +41,9 @@ pub fn take_log(ctx: &egui::Context) -> Vec<(String, Outcome)> { /// Every key binding, most modifiers first (so ⇧⌘Z wins over ⌘Z), each key owned by one command: /// command and shell shortcuts, then the menu catalogue's for live items without their own, -/// then Edit › Keyboard Shortcuts assignments to any other menu item. +/// then Edit › Keyboard Shortcuts assignments to any other menu item. D and X +/// (`tools.defaultColors` / `tools.swapColors`) and the fill keys are commands like any other, so +/// their overrides apply. Held temporary tools (Space…) are not here: see [`crate::hold_keys`]. pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> { let prefs = app.session.prefs(); let ui = crate::menus::UI_COMMANDS.iter().map(|(id, _, _, sc)| (*id, prefs.shortcut(id, *sc))); @@ -58,15 +60,16 @@ pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> { let overrides = prefs .shortcuts .iter() - .filter(|(id, sc)| !sc.is_empty() && photocraft_engine::commands::find(id).is_none() && !crate::menus::UI_COMMANDS.iter().any(|c| c.0 == id.as_str())) + .filter(|(id, sc)| { + !sc.is_empty() + && photocraft_engine::commands::find(id).is_none() + && !crate::menus::UI_COMMANDS.iter().any(|c| c.0 == id.as_str()) + && !crate::hold_keys::is_temporary(id) + }) .map(|(id, sc)| (id.as_str(), Some(sc.as_str()))); let mut all: Vec<(String, KeyboardShortcut)> = Vec::new(); for (id, sc) in ui.chain(engine).chain(catalog).chain(overrides) { let Some(sc) = sc.and_then(parse) else { continue }; - // Plain X and D are the colour keys (handled with the tool keys). - if sc.modifiers == Modifiers::NONE && matches!(sc.logical_key, Key::X | Key::D) { - continue; - } if !all.iter().any(|(_, b)| *b == sc) { all.push((id.to_string(), sc)); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 9af1cd7b..2e2a8ee2 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -83,6 +83,7 @@ pub fn default_shortcut(id: &str) -> Option { .and_then(|c| c.3) .or_else(|| photocraft_engine::commands::find(id).and_then(|c| c.shortcut)) .or_else(|| crate::menu_catalog::CATALOG.iter().find(|c| c.3 == id).and_then(|c| c.2)) + .or_else(|| photocraft_engine::prefs::TEMPORARY_TOOLS.iter().find(|t| t.0 == id).map(|t| t.2)) .map(str::to_string) } @@ -105,11 +106,12 @@ fn shifted(k: Key) -> Option { /// - Modifiers match exactly (⌘⌥I is not ⌘I), with ⌘ = Ctrl off the Mac. /// - `=` also matches `+` (⌘+ with ⇧ on a US layout, the numpad `+`, the `+` key of Nordic and /// German layouts): Photoshop zooms in on both. -/// - Delete also matches Backspace (the Mac's "delete" key). +/// - Delete also matches Backspace (the Mac's "delete" key), and Backspace matches Delete (⌥⌫ +/// and ⌥Delete both fill on Windows). /// - A ⇧ shortcut on a punctuation key matches the shifted character (⌘⇧; arrives as ⌘`:`). pub fn key_matches(sc: &KeyboardShortcut, key: Key, mods: Modifiers) -> bool { let k = sc.logical_key; - let direct = key == k || (k == Key::Delete && key == Key::Backspace); + let direct = key == k || (k == Key::Delete && key == Key::Backspace) || (k == Key::Backspace && key == Key::Delete); let plus = k == Key::Equals && key == Key::Plus; let via_shift = sc.modifiers.shift && shifted(k) == Some(key); if !(direct || plus || via_shift) { @@ -263,7 +265,8 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if editing { return; } - // Single-key tools and colours (no modifiers). + // Single-key tools (no modifiers). D and X are commands (`tools.defaultColors` / + // `tools.swapColors`), dispatched above with any Keyboard Shortcuts override. let pressed = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); // Enter / Escape commit or cancel in-progress tool state (polygonal lasso, crop). if !app.ui.polygon.is_empty() || app.ui.crop_rect.is_some() { @@ -299,12 +302,6 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } } - if pressed(Key::X) { - let _ = app.run("tools.swapColors", json!({})); - } - if pressed(Key::D) { - let _ = app.run("tools.defaultColors", json!({})); - } // [ and ] resize the brush through `tools.setBrush` (journaled, drivable). let size = app.session.tools.brush.size; let next = if pressed(Key::OpenBracket) { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 824cc084..1da352d9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -39,7 +39,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.menu.invoke {id}` / `ui.menu.list`: activate a menu item by id; list the menu tree - `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog}` / `ui.dialog.cancel {dialog}` - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows -- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen); `space: true` holds Space (the Crop tool then moves the frame being drawn) +- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen); `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing) - `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}` - `ui.type {text}`: type text (goes to the focused widget, or to the canvas while the Type tool is editing) - `ui.resize {width, height}`: resize the main window @@ -87,7 +87,7 @@ the same commands work in the app, the CLI and headless MCP. Paths are dotted ca | `prefs.set` | `{"path":"cursors.painting","value":"precise"}` or `{"values":{"unitsAndRulers.rulers":"cm","guidesGridAndSlices.gridColor":"#ff8800"}}`. Values are validated (choices, ranges, `#rrggbb` colours, shortcut syntax); a batch applies all or nothing. A section path takes an object and merges it key by key | | `prefs.reset` | `{"path":"performance"}` (a section or key); no path resets everything | | `edit.preferences.
` | the section's values; in the app (no params) it opens the Preferences dialog on that section | -| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. In the app it opens Keyboard Shortcuts and Menus | +| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Cmd+Alt+Space); they list with `"hold": true`. In the app it opens Keyboard Shortcuts and Menus | | `edit.menus` / `edit.toolbar` | `{"hide":["edit.fade"],"show":[…],"color":{"edit.fill":"red"},"reset":false}` / `{"hidden":["Sponge"],"order":[…]}` | | `edit.colorSettings` | `{"workingRgb":"display-p3","workingCmyk":"coated-cmyk","workingGray":"sgray","policyRgb":"preserve\|convert\|off",…,"askOnMismatch":true,"intent":"perceptual","bpc":true}`; honoured when opening files (`file.openAs`, the app's File › Open) and by Image › Mode and "working" profile specs. `color.profileMismatch {"action":"preserve\|convert\|discard\|assignWorking"}` answers the mismatch prompt |