mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-13 21:52:54 +00:00
Merge PR #8015: Added advanced selections keyboard shortcuts
This commit is contained in:
commit
cf66eea4c9
6 files changed
+162
-2
No files matched your search
@@ -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<CommandSpec> {
|
||||
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",
|
||||
|
||||
@@ -365,6 +365,17 @@ pub(super) fn path_to_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let tol = f64p(p, "tolerance").unwrap_or(2.0).clamp(0.5, 10.0);
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
|
||||
@@ -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())
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user