mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 06:27:11 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user