Shortcuts: rebindable fill keys, D/X and held temporary tools (#249) (#275) (#275)

This commit is contained in:
Brandon Thomas 2026-10-05 18:50:47 -04:00 committed by GitHub
parent 1520d1c527
commit 352c849139
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 854 additions and 40 deletions

View File

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

View File

@ -272,7 +272,7 @@ fn build() -> Vec<CommandSpec> {
"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<CommandSpec> {
}
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<CommandSpec> {
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());

View File

@ -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<Value> {
let mut q: Map<String, Value> = 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<CommandSpec> {
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"));
}
}

View File

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

View File

@ -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<Item = (&'static str, Option<&'static str>)> {
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<Item = (&'a str, &'a str)>) -> Vec<(String, Vec<String>)> {
let mut by: BTreeMap<String, Vec<String>> = BTreeMap::new();
@ -1362,7 +1378,7 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
}
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<Value> = 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<Value> = 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<CommandSpec> {
"Keyboard Shortcuts…",
["Edit"],
Some("Cmd+Alt+Shift+K"),
r##"{"set":{"<command id>":"Cmd+Shift+X"|""(remove)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false}"##,
r##"{"set":{"<command id>|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false}"##,
keyboard_shortcuts,
true
),

View File

@ -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();

View File

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

View File

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

View File

@ -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<KeyboardShortcut> {
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<Temporary> {
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<Temporary> {
let down = ctx.input(|i| i.pointer.primary_down());
let latched = ctx.data(|d| d.get_temp::<Option<Temporary>>(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;

View File

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

View File

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

View File

@ -324,9 +324,25 @@ pub fn shortcut_items(app: &PhotocraftApp) -> Vec<(String, String, Vec<String>,
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::<Vec<_>>(), 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

View File

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

View File

@ -83,6 +83,7 @@ pub fn default_shortcut(id: &str) -> Option<String> {
.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<Key> {
/// - 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) {

View File

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