From b6e9ec97e5dc495df0cde4e299be00444f484f12 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 10:47:11 -0400 Subject: [PATCH] Cmd+Enter (Ctrl+Enter) loads the path as a selection (#306) (#329) (#329) --- crates/engine/src/vector_cmds.rs | 19 ++-- crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/menus.rs | 3 + crates/ui-egui/src/path_selection_tests.rs | 123 +++++++++++++++++++++ crates/ui-egui/src/vector_ui.rs | 35 ++++++ 5 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/path_selection_tests.rs diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 59e4cfe1..49f35b83 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1155,14 +1155,17 @@ pub fn specs() -> Vec { ), spec!("path.delete", "Delete Path", [], r##"{"name":str|"work"="work"}"##, has_doc, path_delete), spec!("path.rename", "Rename Path", [], r##"{"name":str|"work"="work","to":str} (renaming the work path saves it)"##, has_doc, path_rename), - spec!( - "path.toSelection", - "Make Selection from Path", - [], - r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##, - has_doc, - path_to_selection - ), + // ⌘↩ / Ctrl+Enter, as in Photoshop (the UI loads the path selected in the Paths panel, #306). + CommandSpec { + id: "path.toSelection", + label: "Make Selection from Path", + menu: &[], + shortcut: Some("Cmd+Enter"), + params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##, + enabled: has_doc, + run: path_to_selection, + 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/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9c293ba1..c4eb1ee0 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1964,3 +1964,4 @@ OpenGL OpenGL Use tablet pressure タブレットの筆圧を使用 Vulkan Vulkan + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. 選択範囲を作成するパスがありません。ペンツールで描くか、パスパネルでパスを選択してください。 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8061ff9e..5daf8e4c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -198,6 +198,9 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: return Ok(json!({"workspace": app.ui.workspace})); } match id { + // ⌘↩ / 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), // 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/path_selection_tests.rs b/crates/ui-egui/src/path_selection_tests.rs new file mode 100644 index 00000000..d7fa0d07 --- /dev/null +++ b/crates/ui-egui/src/path_selection_tests.rs @@ -0,0 +1,123 @@ +//! #306: ⌘↩ (Ctrl+Enter off the Mac) loads a path as a selection, as in Photoshop: the path +//! being drawn with the Pen, the path selected in the Paths panel, or the work path. The key is +//! rebindable in Edit › Keyboard Shortcuts, and with no path it says why instead of doing nothing. + +use egui::{Event, Key, Modifiers, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; +use crate::vector_ui::PenPath; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + h.run_steps(6); + h +} + +/// ⌘ on the Mac, Ctrl elsewhere, as egui-winit reports them. +fn command() -> Modifiers { + if cfg!(target_os = "macos") { + Modifiers { command: true, mac_cmd: true, ..Default::default() } + } else { + Modifiers { command: true, ctrl: true, ..Default::default() } + } +} + +fn press(h: &mut Harness<'_, PhotocraftApp>, key: Key, m: Modifiers) { + h.event(Event::ModifiersChanged(m)); + h.event(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }); + h.event(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m }); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); +} + +fn rect_path(x: f64, y: f64, w: f64, h: f64) -> serde_json::Value { + let k = |x: f64, y: f64| json!({"anchor": [x, y]}); + json!({"subpaths": [{"closed": true, "knots": [k(x, y), k(x + w, y), k(x + w, y + h), k(x, y + h)]}]}) +} + +/// The selection's bounds (x, y, width, height), if any. +fn selection(h: &Harness<'_, PhotocraftApp>) -> Option<[i64; 4]> { + let r = h.state().session.active()?.doc.selection.as_ref()?.content_bounds(); + Some([r.x0, r.y0, r.x1 - r.x0, r.y1 - r.y0].map(i64::from)) +} + +#[test] +fn cmd_enter_with_the_pen_loads_the_work_path() { + let mut h = harness(); + h.state_mut().run("path.set", json!({"name": "work", "path": rect_path(10.0, 10.0, 50.0, 40.0)})).unwrap(); + h.state_mut().ui.tool = Tool::Pen; + h.run_steps(2); + assert!(h.state().session.active().unwrap().doc.selection.is_none()); + press(&mut h, Key::Enter, command()); + assert!(h.state().session.active().unwrap().doc.selection.is_some(), "⌘↩ made a selection: {}", h.state().ui.status); + assert_eq!(selection(&h), Some([10, 10, 50, 40])); +} + +#[test] +fn cmd_enter_while_drawing_finishes_the_path_and_loads_it() { + let mut h = harness(); + 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)], dragging: false }); + h.run_steps(2); + press(&mut h, Key::Enter, command()); + let app = h.state(); + assert!(app.ui.pen.is_none(), "the pen path was finished"); + assert!(app.session.active().unwrap().doc.work_path.is_some(), "into the work path"); + assert!(app.session.active().unwrap().doc.selection.is_some(), "and loaded as a selection: {}", app.ui.status); + // Plain ↩ still only finishes a path. + let mut h = harness(); + h.state_mut().ui.tool = Tool::Pen; + h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], dragging: false }); + h.run_steps(2); + press(&mut h, Key::Enter, Modifiers::NONE); + assert!(h.state().session.active().unwrap().doc.work_path.is_some()); + assert!(h.state().session.active().unwrap().doc.selection.is_none()); +} + +#[test] +fn cmd_enter_loads_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(); + // Any tool, with a path selected in the Paths panel (as in Photoshop). + 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, command()); + assert_eq!(selection(&h), Some([100, 40, 60, 50]), "{}", h.state().ui.status); + // A selected path that went away falls back to the work path. + h.state_mut().run("path.delete", json!({"name": "Path 1"})).unwrap(); + h.state_mut().run("select.deselect", json!({})).unwrap(); + press(&mut h, Key::Enter, command()); + assert_eq!(selection(&h), Some([0, 0, 30, 30])); +} + +#[test] +fn without_a_path_it_says_why_and_the_key_can_be_rebound() { + let mut h = harness(); + h.state_mut().ui.tool = Tool::Pen; + press(&mut h, Key::Enter, command()); + let app = h.state(); + assert!(app.session.active().unwrap().doc.selection.is_none()); + assert!(app.ui.status_error && app.ui.status.contains("No path"), "status: {}", app.ui.status); + // It's a listed, rebindable shortcut. + let cmd_enter = crate::shortcuts::parse("Cmd+Enter").unwrap(); + assert!(crate::shortcut_dispatch::bindings(app).iter().any(|(id, sc)| id == "path.toSelection" && *sc == cmd_enter)); + h.state_mut().run("edit.keyboardShortcuts", json!({"set": {"path.toSelection": "Cmd+Shift+Y"}})).unwrap(); + h.state_mut().run("path.set", json!({"name": "work", "path": rect_path(10.0, 10.0, 20.0, 20.0)})).unwrap(); + press(&mut h, Key::Enter, command()); + assert!(h.state().session.active().unwrap().doc.selection.is_none(), "⌘↩ was moved"); + let mut m = command(); + m.shift = true; + press(&mut h, Key::Y, m); + assert_eq!(selection(&h), Some([10, 10, 20, 20]), "{}", h.state().ui.status); +} diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index d35e9a58..1fc80139 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -182,6 +182,37 @@ pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) { } } +/// The path that path commands act on without a name: the one selected in the Paths panel, else +/// the work path, else the selected layer's shape path or vector mask; `None` when there's none. +pub fn active_path_name(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + let rows = path_rows(&st.doc, st.active_layer); + let key = |r: &PathEntry| match r.kind { + PathRow::Work => "work".to_string(), + PathRow::Layer => "layer".to_string(), + PathRow::Saved => r.name.clone(), + }; + let selected = app.ui.selected_path.as_deref().filter(|s| rows.iter().any(|r| key(r) == *s)).map(str::to_string); + selected.or_else(|| rows.iter().find(|r| r.kind == PathRow::Work).map(key)).or_else(|| rows.iter().find(|r| r.kind == PathRow::Layer).map(key)) +} + +/// ⌘↩ / Ctrl+Enter (#306): load a path as a selection, like Photoshop with a Pen or Path +/// Selection tool or a path selected in the Paths panel. A path being drawn with the Pen is +/// finished first and loads; otherwise [`active_path_name`]. `params` may set feather, mode… +pub fn path_to_selection(app: &mut PhotocraftApp, params: Value) -> Result { + if app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2) { + // The finished path becomes the work path, or a shape layer's path or a vector mask. + let on_layer = app.ui.tool_options.vector_mode == "shape" || targeted_vector_mask(app).is_some(); + pen_commit(app, false); + app.ui.selected_path = Some(if on_layer { "layer" } else { "work" }.into()); + } + let name = active_path_name(app) + .ok_or_else(|| tl!("No path to make a selection from: draw one with the Pen tool or select one in the Paths panel.").to_string())?; + let mut p = params.as_object().cloned().unwrap_or_default(); + p.insert("name".into(), json!(name)); + app.run("path.toSelection", Value::Object(p)) +} + // --------------------------------------------------------------------------------------------- // Path Selection @@ -816,3 +847,7 @@ mod tests { assert_eq!((wp.subpaths[0].knots[0].anchor.x, wp.subpaths[0].knots[0].anchor.y), (30.0, 25.0)); } } + +#[cfg(test)] +#[path = "path_selection_tests.rs"] +mod path_selection_tests;