Text: duplicate ends the open text edit and selects the copy (#2226)

A deselected layer never keeps an open text edit: duplicate now commits any open
edit through the same route as text.endEdit before the selection moves, from every
route (menu, shortcut, palette, MCP, script). The editor-held keyboard matches the
chord against each command's current binding, like Deselect All already did, so
rebound shortcuts keep working and keys a focused editor must never surrender stay
typing.
This commit is contained in:
Christopher Linnett
2026-10-24 14:35:45 +08:00
parent eb5108786b
commit 048d6c4a25
4 changed files with 166 additions and 8 deletions
+5
View File
@@ -126,6 +126,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
return super::prop_groups::duplicate_contents(s, &items);
}
}
// A deselected layer never keeps an open text edit: the copies take the selection, so commit
// an open edit first, exactly as `text.endEdit` (typed text kept, an empty Type-tool layer
// discarded) and as Deselect All does — one call on the command path so every route (menu,
// shortcut, palette, MCP, script) ends the edit and leaves the same final state.
super::text_edit::end_edit(s, &Value::Null)?;
let (cid, ids) = layers_p(s, p)?;
let new = s.edit("Duplicate", None, |proj, st| {
let mut next = proj.next_id;
+66
View File
@@ -427,3 +427,69 @@ fn deselect_all_discards_an_empty_type_tool_layer() {
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit");
}
#[test]
fn duplicate_ends_an_edit_on_already_committed_text() {
// The engine route (MCP, control channel, script) with text that was committed before the
// edit opened and nothing typed inside it: the edit still ends and the copy is selected.
let (mut s, t) = session("Hello");
s.execute("text.edit", json!({"layer": t, "caret": 5})).unwrap();
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "the open edit ends on a direct edit.duplicate");
assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "and the copy is selected");
assert_eq!(doc(&s, t).text, "Hello", "the committed text is kept on the original");
assert_eq!(doc(&s, copy).text, "Hello", "and carried by the copy");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_comp().map_or(0, |c| c.layers.len()), 1, "undo removes the copy");
assert!(s.state.text_edit.is_none(), "undo does not reopen the edit");
}
#[test]
fn duplicate_ends_the_open_edit_and_selects_the_copy() {
let (mut s, t) = session("Hello");
s.execute("text.edit", json!({"layer": t, "caret": 5})).unwrap();
s.execute("text.insert", json!({"text": "!"})).unwrap();
assert_eq!(doc(&s, t).text, "Hello!");
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
// A deselected layer never keeps an open text edit: Duplicate commits the edit through the
// same route as text.endEdit before the selection moves, so every route (menu, shortcut,
// palette, MCP, script) leaves one final state.
assert!(s.state.text_edit.is_none(), "Duplicate ends the open text edit, as Deselect All does");
assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "and selects the copy");
assert_eq!(doc(&s, t).text, "Hello!", "the original keeps the typed text");
assert_eq!(doc(&s, copy).text, "Hello!", "the copy carries it too");
// The edit ending is no undoable step of its own (as with text.endEdit): undo takes the
// duplicate, it does not reopen the edit or undo the typing.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_comp().map_or(0, |c| c.layers.len()), 1, "undo removed the copy");
assert_eq!(doc(&s, t).text, "Hello!", "the typed text stays committed");
assert!(s.state.text_edit.is_none(), "undo does not reopen the edit");
// With no open edit Duplicate is untouched by all of this: same behavior, no extra steps.
s.execute("edit.duplicate", json!({"layers": [t]})).unwrap();
assert_eq!(s.active_comp().map_or(0, |c| c.layers.len()), 2, "a plain duplicate still duplicates");
assert!(s.state.text_edit.is_none());
}
#[test]
fn duplicate_of_an_empty_type_tool_layer_discards_it_as_endedit_does() {
let (mut s, _keep) = session("Hello");
let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert!(
s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == effectcraft_project::LayerId(new)),
"the Type tool opens the edit on its new, empty layer"
);
let count = |s: &Session| s.active_comp().map_or(0, |c| c.layers.len());
let before = count(&s);
s.execute("edit.duplicate", json!({})).unwrap();
assert!(s.state.text_edit.is_none(), "the edit ends the way text.endEdit ends it");
assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is discarded, not duplicated");
assert!(s.state.selected_layers.is_empty(), "nothing is selected: the layer that would be copied is gone");
// The edit is committed before Duplicate resolves its layers, so naming the very layer
// the empty edit was on addresses nothing: the commit still happened, the duplicate errors.
let new2 = s.execute("layer.newText", json!({"text": "", "position": [90.0, 90.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute("edit.duplicate", json!({"layers": [new2]})).is_err(), "the named layer is gone once the edit commits");
assert!(s.state.text_edit.is_none(), "and the edit ended all the same");
assert_eq!(count(&s), before - 1, "with the empty layer discarded, nothing left to duplicate");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit");
}
+13 -8
View File
@@ -504,14 +504,19 @@ fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) {
exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "size": ns}));
}
// The app-wide shortcut dispatcher is skipped while this editor holds the
// keyboard, so the Deselect All chord (Cmd/Ctrl+Shift+A or F2 by default)
// must be matched here and run through the command dispatcher like undo
// above. Matching the command's current binding, not a fixed key, keeps
// rebound shortcuts working and gives every route one final state; the
// forwarding test also refuses chords a focused editor must never surrender
// (see `forwardable_during_text_edit`).
_ if crate::menus::chord_matches(&app.session, "edit.deselectAll", key, m) && crate::menus::forwardable_during_text_edit(m, key) => {
let _ = crate::menus::invoke(app, &ctx, "edit.deselectAll", json!({}));
// keyboard, so commands that end the edit by moving the selection — Deselect
// All (Cmd/Ctrl+Shift+A or F2 by default) and Duplicate (Cmd/Ctrl+D) — must be
// matched here and run through the command dispatcher like undo above.
// Matching each command's current binding, not a fixed key, keeps rebound
// shortcuts working and gives every route one final state; a chord bound to
// both commands resolves in list order. The forwarding test also refuses
// chords a focused editor must never surrender (see
// `forwardable_during_text_edit`).
_ if crate::menus::forwardable_during_text_edit(m, key)
&& let Some(cmd_id) =
["edit.deselectAll", "edit.duplicate"].into_iter().find(|id| crate::menus::chord_matches(&app.session, id, key, m)) =>
{
let _ = crate::menus::invoke(app, &ctx, cmd_id, json!({}));
}
_ => {}
}
+82
View File
@@ -724,3 +724,85 @@ fn deselect_all_menu_item_follows_the_edit_while_the_project_panel_is_focused()
// With no edit, the item deselects Project items again: nothing selected there, nothing to do.
assert!(!enabled(&h), "Deselect All follows the empty Project selection once the edit is over");
}
/// Layer ids of the active comp, bottom to top.
fn layer_ids(h: &Harness<'_, EffectcraftApp>) -> Vec<u64> {
h.state().session.active_comp().map_or(vec![], |c| c.layers.iter().map(|l| l.id.0).collect())
}
#[test]
fn duplicate_chord_ends_a_type_tool_edit_and_selects_the_copy() {
let (mut h, lid) = type_tool_edit(app(), "Hello");
key(&mut h, Key::D, Modifiers::COMMAND);
let s = &h.state().session;
assert!(s.state.text_edit.is_none(), "Duplicate ends the open edit, as Deselect All does");
let copy = *s.state.selected_layers.first().expect("the copy is selected");
assert_ne!(copy.0, lid, "the copy (not the edited original) holds the selection");
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hello", "the original keeps its text");
assert_eq!(layer_doc(s, copy).unwrap().text, "Hello", "the copy carries it too");
}
#[test]
fn duplicate_chord_ends_an_edit_started_while_the_project_panel_is_focused() {
let (mut h, lid) = project_focused_text_edit("Hi");
key(&mut h, Key::D, Modifiers::COMMAND);
let s = &h.state().session;
assert!(s.state.text_edit.is_none(), "Cmd+D ends the edit even with Project focused");
let copy = s.state.selected_layers.first().expect("the copy is selected");
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hi", "the typed text is kept");
assert_eq!(layer_doc(s, *copy).unwrap().text, "Hi", "on the copy as well");
}
#[test]
fn rebound_duplicate_chord_forwards_from_inside_the_edit() {
let mut a = app();
a.session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": ["Cmd+Alt+D"]})).unwrap();
let (mut h, lid) = type_tool_edit(a, "Hello");
// The default key no longer belongs to Duplicate: while the editor holds the keyboard a
// chord that matches no command does nothing, and the current binding is what forwards.
let n = layer_ids(&h).len();
key(&mut h, Key::D, Modifiers::COMMAND);
assert!(h.state().session.state.text_edit.is_some(), "Cmd+D was rebound away: the edit stays open");
assert_eq!(layer_ids(&h).len(), n, "and nothing was duplicated");
key(&mut h, Key::D, Modifiers { alt: true, ..Modifiers::COMMAND });
let s = &h.state().session;
assert!(s.state.text_edit.is_none(), "the rebound chord ends the edit");
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hello", "the text is kept");
assert_eq!(layer_ids(&h).len(), n + 1, "and the copy exists");
}
#[test]
fn duplicate_rebound_onto_a_bare_letter_stays_typing() {
let mut a = app();
a.session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": ["D"]})).unwrap();
let (mut h, lid) = type_tool_edit(a, "Hello");
let n = layer_ids(&h).len();
// A binding without a command modifier is text as far as the editor is concerned: the editor
// must not surrender a key that types (same invariant as the app-wide dispatcher).
key(&mut h, Key::D, Modifiers::NONE);
assert!(h.state().session.state.text_edit.is_some(), "a bare-letter Duplicate binding does not end the edit");
assert_eq!(layer_ids(&h).len(), n, "and does not duplicate");
type_text(&mut h, "D");
let (lid2, text, _, _) = edited(&h);
assert_eq!(lid2, lid, "the same edit on the same layer continues");
assert_eq!(text, "HelloD", "and the letter typed into the text");
}
#[test]
fn a_chord_bound_to_both_forwarded_commands_resolves_in_list_order() {
let mut a = app();
// Shortcuts may hold one chord for several commands (the conflict is a toast, not a
// refusal), so the editor's forwarding resolves a shared chord in list order — Deselect
// All before Duplicate. This pins that documented contract: the shared chord must end the
// edit like Deselect All, without running Duplicate behind it.
a.session.execute("shortcuts.set", json!({"command": "edit.deselectAll", "keys": ["Cmd+Alt+G"]})).unwrap();
a.session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": ["Cmd+Alt+G"]})).unwrap();
let (mut h, lid) = type_tool_edit(a, "Hello");
let n = layer_ids(&h).len();
key(&mut h, Key::G, Modifiers { alt: true, ..Modifiers::COMMAND });
let s = &h.state().session;
assert!(s.state.text_edit.is_none(), "the shared chord still ends the edit");
assert!(s.state.selected_layers.is_empty(), "Deselect All wins the shared chord: nothing is selected");
assert_eq!(layer_ids(&h).len(), n, "so the chord did not duplicate");
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hello", "and the text is kept");
}