Cmd+Enter (Ctrl+Enter) loads the path as a selection (#306) (#329) (#329)

This commit is contained in:
Brandon Thomas 2026-10-06 10:47:11 -04:00 committed by GitHub
parent c046337ddf
commit b6e9ec97e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 173 additions and 8 deletions

View File

@ -1155,14 +1155,17 @@ pub fn specs() -> Vec<CommandSpec> {
),
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",

View File

@ -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. 選択範囲を作成するパスがありません。ペンツールで描くか、パスパネルでパスを選択してください。

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

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

View File

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

View File

@ -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<String> {
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<Value, String> {
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;