diff --git a/crates/engine/src/cmd/vector_cmds.rs b/crates/engine/src/cmd/vector_cmds.rs index 5a53d5ae07..dde53c8f1e 100644 --- a/crates/engine/src/cmd/vector_cmds.rs +++ b/crates/engine/src/cmd/vector_cmds.rs @@ -73,8 +73,8 @@ pub(crate) use params::{layer_id, nums, pt}; pub use params::{parse_path, parse_stroke, path_json}; pub use paths::STROKE_PATH_TOOLS; use paths::{ - clipping_path_clear, clipping_path_set, path_delete, path_fill, path_rename, path_set, path_stroke, path_to_selection, path_transform, paths_list, - resolve_path, select_to_work_path, + clipping_path_clear, clipping_path_set, path_delete, path_fill, path_rename, path_set, path_stroke, path_to_selection, path_to_selection_with_mode, + path_transform, paths_list, resolve_path, select_to_work_path, }; pub(crate) use paths::{is_work, path_mut}; use shape::{ @@ -209,6 +209,27 @@ pub fn specs() -> Vec { run: path_to_selection, journal: true, }, + // ⌘⇧↩ / Ctrl+Shift+Enter adds the path to the selection, ⌘⌥↩ / Ctrl+Alt+Enter subtracts it. + CommandSpec { + id: "path.addToSelection", + label: "Add Path to Selection", + menu: &[], + shortcut: Some("Cmd+Shift+Enter"), + params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "add")"##, + enabled: has_doc, + run: |s, p| path_to_selection_with_mode(s, p, "add"), + journal: true, + }, + CommandSpec { + id: "path.subtractFromSelection", + label: "Subtract Path from Selection", + menu: &[], + shortcut: Some("Cmd+Alt+Enter"), + params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "subtract")"##, + enabled: has_doc, + run: |s, p| path_to_selection_with_mode(s, p, "subtract"), + journal: true, + }, spec!("select.toWorkPath", "Make Work Path", [], r##"{"tolerance":0.5..10 px=2} → {subpaths,knots}"##, has_selection, select_to_work_path), spec!( "path.fill", diff --git a/crates/engine/src/cmd/vector_cmds/paths.rs b/crates/engine/src/cmd/vector_cmds/paths.rs index 167cb9d2a6..700abe3564 100644 --- a/crates/engine/src/cmd/vector_cmds/paths.rs +++ b/crates/engine/src/cmd/vector_cmds/paths.rs @@ -365,6 +365,17 @@ pub(super) fn path_to_selection(s: &mut Session, p: &Value) -> Result { Ok(json!({ "selected": selected })) } +/// `path.toSelection` with the combine mode fixed by the command (⌘⇧↩ adds, ⌘⌥↩ subtracts); +/// a `mode` in the params is overridden, the rest is forwarded and validated as usual. +pub(super) fn path_to_selection_with_mode(s: &mut Session, p: &Value, mode: &str) -> Result { + let mut q = match p { + Value::Object(_) => p.clone(), + _ => json!({}), + }; + q["mode"] = json!(mode); + path_to_selection(s, &q) +} + pub(super) fn select_to_work_path(s: &mut Session, p: &Value) -> Result { let tol = f64p(p, "tolerance").unwrap_or(2.0).clamp(0.5, 10.0); let d = s.active().ok_or(EngineError::NoDocument)?; diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 14cce82bb8..24004d7749 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -218,6 +218,8 @@ impl PhotocraftApp { | "select.loadSelection" | "select.loadLuminosity" | "path.toSelection" + | "path.addToSelection" + | "path.subtractFromSelection" ) && self.session.active().is_some_and(|st| st.doc.selection.is_some()) { diff --git a/crates/ui-egui/src/input/shortcuts/tests.rs b/crates/ui-egui/src/input/shortcuts/tests.rs index c6b8694a6e..cfb3203f10 100644 --- a/crates/ui-egui/src/input/shortcuts/tests.rs +++ b/crates/ui-egui/src/input/shortcuts/tests.rs @@ -163,6 +163,91 @@ fn pen_custom_undo_binding_and_menu_retract_pending_points() { assert!(app.ui.pen.is_none()); } +/// A 200×200 document for the ⌘⇧↩ / ⌘⌥↩ tests. +fn selection_app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", serde_json::json!({"width": 200, "height": 200})).unwrap(); + app +} + +fn set_work_rect(app: &mut PhotocraftApp, x: i32, y: i32, w: i32, h: i32) { + let knots = serde_json::json!([[x, y], [x + w, y], [x + w, y + h], [x, y + h]]); + app.run("path.set", serde_json::json!({"path": {"subpaths": [{"closed": true, "op": "combine", "knots": knots}]}})).unwrap(); +} + +/// The selection's bounds (x0, y0, x1, y1), if any. +fn selection_bounds(app: &PhotocraftApp) -> Option<[i32; 4]> { + let r = app.session.active().unwrap().doc.selection.as_ref()?.content_bounds(); + Some([r.x0, r.y0, r.x1, r.y1]) +} + +fn press_key(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { + ctx.begin_pass(egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }); + handle(app, ctx); + ctx.end_pass().textures_delta.clear(); +} + +#[test] +fn ctrl_shift_enter_adds_the_path_to_the_selection() { + let mut app = selection_app(); + let ctx = egui::Context::default(); + app.run("select.rect", serde_json::json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); + let history = app.session.active().unwrap().history.past_len(); + + set_work_rect(&mut app, 100, 100, 40, 40); + press_key(&mut app, &ctx, Key::Enter, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(app.session.active().unwrap().history.past_len(), history + 2, "path.set + selection: {}", app.ui.status); + // Both rectangles are selected: the key added the path, it did not replace the selection. + assert_eq!(selection_bounds(&app), Some([10, 10, 140, 140])); +} + +#[test] +fn ctrl_alt_enter_subtracts_the_path_from_the_selection() { + let mut app = selection_app(); + let ctx = egui::Context::default(); + set_work_rect(&mut app, 10, 10, 40, 40); + app.run("path.toSelection", serde_json::json!({})).unwrap(); + assert!(selection_bounds(&app).is_some()); + + press_key(&mut app, &ctx, Key::Enter, Modifiers::COMMAND | Modifiers::ALT); + assert_eq!(selection_bounds(&app), None, "the only selected area was subtracted: {}", app.ui.status); +} + +#[test] +fn add_and_subtract_path_keys_are_ignored_while_a_text_field_has_focus() { + let mut app = selection_app(); + let ctx = egui::Context::default(); + set_work_rect(&mut app, 10, 10, 40, 40); + app.run("path.toSelection", serde_json::json!({})).unwrap(); + + // Give a text field the keyboard focus, as a panel's numeric field would have it. + let mut text = String::new(); + ctx.run_ui(Default::default(), |ui| { + ui.text_edit_singleline(&mut text).request_focus(); + }) + .textures_delta + .clear(); + + let history = app.session.active().unwrap().history.past_len(); + for modifiers in [Modifiers::COMMAND | Modifiers::ALT, Modifiers::COMMAND | Modifiers::SHIFT] { + let events = + vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers }]; + // The field is drawn every frame, so it keeps the focus. + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + assert!(ui.ctx().text_edit_focused()); + handle(&mut app, ui.ctx()); + ui.text_edit_singleline(&mut text); + }) + .textures_delta + .clear(); + } + assert_eq!(app.session.active().unwrap().history.past_len(), history); + assert!(selection_bounds(&app).is_some()); +} + #[test] fn parses_registry_shortcuts() { let sc = parse("Cmd+Shift+N").unwrap(); diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 850ac92fdd..3bb6d616e4 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -187,6 +187,12 @@ fn invoke_file_commands(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, // ⌘↩ / Ctrl+Enter (#306): load the path selected in the Paths panel (or the one being // drawn) as a selection. "path.toSelection" if params.get("name").is_none() => crate::vector_ui::path_to_selection(app, params.clone()), + // ⌘⇧↩ / ⌘⌥↩ load the same path, adding it to or subtracting it from the selection. + "path.addToSelection" | "path.subtractFromSelection" if params.get("name").is_none() => { + let mut p = params.as_object().cloned().unwrap_or_default(); + p.insert("mode".into(), json!(if id == "path.addToSelection" { "add" } else { "subtract" })); + crate::vector_ui::path_to_selection(app, Value::Object(p)) + } // Layer › Rename Layer from a menu starts in-place renaming in the Layers panel. "layer.renameLayer" if params.get("name").is_none() => { let st = app.session.active().ok_or("no document")?; diff --git a/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs b/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs index 8cab12747a..9f520025e2 100644 --- a/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs @@ -495,3 +495,38 @@ fn a_hidden_work_path_stays_hidden_when_another_tile_is_clicked() { assert!(right_outlines_on_switch(false) > 0, "a shown Work Path is drawn in that frame"); assert_eq!(right_outlines_on_switch(true), 0, "the hidden one isn't"); } + +/// ⌘⇧↩ / ⌘⌥↩ (#8015) take the same path as ⌘↩: a path being drawn with the Pen is finished first +/// and combined, not the old work path. +#[test] +fn cmd_shift_enter_while_drawing_finishes_the_path_and_adds_it() { + let mut h = harness(); + h.state_mut().run("path.set", json!({"name": "work", "path": rect_path(0.0, 0.0, 10.0, 10.0)})).unwrap(); + h.state_mut().run("select.rect", json!({"x": 150, "y": 60, "width": 20, "height": 20})).unwrap(); + h.state_mut().ui.tool = Tool::Pen; + let p = |x: f64, y: f64| [[x, y], [x, y], [x, y]]; + h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], ..Default::default() }); + h.run_steps(2); + press(&mut h, Key::Enter, Modifiers { shift: true, ..command() }); + assert!(h.state().ui.pen.is_none(), "the pen path was finished: {}", h.state().ui.status); + // The new triangle (20..120 × 20..80) joins the selected square; the old work path is not used. + assert_eq!(selection(&h), Some([20, 20, 150, 60])); + assert_eq!(h.state().ui.selected_path, None, "loaded path deselected, as with ⌘↩"); +} + +#[test] +fn cmd_shift_and_alt_enter_use_the_path_selected_in_the_paths_panel() { + let mut h = harness(); + h.state_mut().run("path.set", json!({"name": "work", "path": rect_path(0.0, 0.0, 30.0, 30.0)})).unwrap(); + h.state_mut().run("path.set", json!({"name": "Path 1", "path": rect_path(100.0, 40.0, 60.0, 50.0)})).unwrap(); + h.state_mut().run("select.rect", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap(); + h.state_mut().ui.tool = Tool::Brush; + h.state_mut().ui.selected_path = Some("Path 1".into()); + h.run_steps(2); + press(&mut h, Key::Enter, Modifiers { shift: true, ..command() }); + assert_eq!(selection(&h), Some([40, 40, 120, 50]), "{}", h.state().ui.status); + // ⌘⌥↩ with the saved path selected again takes it back out, leaving the square. + h.state_mut().ui.selected_path = Some("Path 1".into()); + press(&mut h, Key::Enter, Modifiers { alt: true, ..command() }); + assert_eq!(selection(&h), Some([40, 40, 20, 20]), "{}", h.state().ui.status); +}