This commit is contained in:
parent
1520d1c527
commit
352c849139
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue