Merge #2228: End an open text edit when Deselect All runs (#2096) (@ChristopherLinnett)

This commit is contained in:
aiongg
2026-10-24 02:10:16 +00:00
6 changed files with 250 additions and 17 deletions
+4
View File
@@ -74,6 +74,10 @@ fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!(s.state.selected_layers.len()))
}
fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
// A deselected layer never keeps an open text edit: commit the edit first, exactly as
// `text.endEdit` (an empty Type-tool layer included), so every route — menu, shortcut,
// script, MCP — leaves one final state.
super::text_edit::end_edit(s, &Value::Null)?;
s.state.selected_layers.clear();
s.state.selected_props.clear();
s.state.selected_keys.clear();
+1 -1
View File
@@ -159,7 +159,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
}
/// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted).
fn end_edit(s: &mut Session, _: &Value) -> Result<Value> {
pub(crate) fn end_edit(s: &mut Session, _: &Value) -> Result<Value> {
let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) };
if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
+35
View File
@@ -392,3 +392,38 @@ fn set_text_schema_accepts_every_opentype_key_and_requires_none() {
s.execute_checked("layer.setText", json!({"layer": t, "discretionaryLigatures": true, "ss02": true, "ss20": true})).unwrap();
assert!(doc(&s, t).style_at(0).opentype.stylistic_set(20));
}
#[test]
fn deselect_all_ends_the_open_edit() {
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!");
s.execute("edit.deselectAll", json!({})).unwrap();
assert!(s.state.text_edit.is_none(), "Deselect All ends the open text edit on every route");
assert!(s.state.selected_layers.is_empty(), "and leaves nothing selected");
assert_eq!(doc(&s, t).text, "Hello!", "the text stays committed");
// Ending an edit is no undoable step of its own (as with text.endEdit): undo takes
// the typed text, it does not reopen the edit.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc(&s, t).text, "Hello", "undo reverts the typed text, not the deselection");
assert!(s.state.text_edit.is_none(), "undo does not reopen the edit");
}
#[test]
fn deselect_all_discards_an_empty_type_tool_layer() {
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.deselectAll", json!({})).unwrap();
assert!(s.state.text_edit.is_none());
assert!(s.state.selected_layers.is_empty());
assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit");
}
+28 -2
View File
@@ -330,7 +330,11 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
}
return r;
}
if no_params(&params) && app.ui.focused == PanelKind::Project {
// The Project-panel focus redirects apply only while no text edit is open: creating a text
// layer leaves focus on Project while the insertion point lives in the comp, so during an
// edit these commands address the text context — redirected, Deselect All would clear a
// project selection the user is not looking at and silently swallow the edit's own end.
if no_params(&params) && app.ui.focused == PanelKind::Project && app.session.state.text_edit.is_none() {
match id {
"edit.duplicate" => return run_engine(app, ctx, "project.duplicate", json!({})),
"edit.selectAll" => {
@@ -1622,7 +1626,10 @@ pub(crate) fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option<bool>
}
pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool {
if app.ui.focused == PanelKind::Project {
// The Project-panel enabled rules mirror the invoke redirects, so they stand down for the
// same reason (see the redirect block): while a text edit is open the menu items must be
// enabled by what their routed command can do, or the menu grays out commands that work.
if app.ui.focused == PanelKind::Project && app.session.state.text_edit.is_none() {
match e.command.as_str() {
"edit.duplicate" => {
return !app.session.state.project_selection.is_empty()
@@ -1790,6 +1797,25 @@ fn mods_match(want: egui::Modifiers, got: egui::Modifiers) -> bool {
want.command == got.command && want.shift == got.shift && want.alt == got.alt && (want.ctrl == got.ctrl || got.command && cfg!(not(target_os = "macos")))
}
/// Whether the `key`+`mods` chord is the current binding of `command` in the active preset
/// (so callers that must match a shortcut themselves — like the viewer's text editor, which
/// holds the keyboard while the shortcut dispatcher is skipped — follow rebinds).
/// `Physical+`-only bindings never match here, exactly as for the app-wide dispatcher.
pub fn chord_matches(session: &effectcraft_engine::Session, command: &str, key: egui::Key, mods: egui::Modifiers) -> bool {
bindings(session).into_iter().any(|(m, k, id, _)| id == command && k == key && mods_match(m, mods))
}
/// Whether a Deselect All chord matched by [`chord_matches`] may run while a text editor holds
/// the keyboard. Chords carrying Cmd/Ctrl/Alt are command chords (the editor treats them as
/// such for undo and the rest), and function keys never type, so those forward from the editor;
/// a chord that could instead produce a character — a bare or Shift-only letter, digit or
/// punctuation key, as from a pathological bare-letter rebinding — stays with the editor so
/// typing keeps working, the same invariant the app-wide dispatcher honors by skipping
/// focused text fields.
pub fn forwardable_during_text_edit(mods: egui::Modifiers, key: egui::Key) -> bool {
mods.command || mods.ctrl || mods.alt || (egui::Key::F1..=egui::Key::F35).contains(&key)
}
/// Set (egui temp data) from a plain Spacebar press until its release while a tap would still
/// run the Spacebar shortcut.
/// The system clipboard's text of this frame's paste event, for the Edit ▸ Paste it runs.
+16 -2
View File
@@ -486,10 +486,14 @@ fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) {
Key::Escape => {
exec(app, "text.endEdit", json!({}));
}
Key::A if cmd => {
// The editor owns exactly Cmd/Ctrl+A (Select All) and Cmd/Ctrl+Z with or
// without Shift (Undo, Redo). Other modifiers on those keys aren't editing
// chords, so they fall through to the binding match below: Shift+A is
// Deselect All's default, and Alt variants are free for a rebound one.
Key::A if cmd && !m.shift && !m.alt => {
exec(app, "text.setSelection", json!({"select": "all"}));
}
Key::Z if cmd => {
Key::Z if cmd && !m.alt => {
let _ = crate::menus::invoke(app, &ctx, if m.shift { "edit.redo" } else { "edit.undo" }, json!({}));
}
Key::Comma | Key::Period if cmd && m.shift => {
@@ -499,6 +503,16 @@ fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) {
let ns = (size + if key == Key::Period { 2.0 } else { -2.0 }).max(1.0);
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!({}));
}
_ => {}
}
if !matches!(key, Key::Backspace | Key::Delete | Key::Enter | Key::Tab) {
+166 -12
View File
@@ -16,6 +16,11 @@ fn app() -> EffectcraftApp {
EffectcraftApp::new(s)
}
/// A headless window at the size and frame step the viewer tests here are written for.
fn harness(a: EffectcraftApp) -> Harness<'static, EffectcraftApp> {
Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| a)
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
@@ -103,7 +108,7 @@ fn modal_blocks_background_text_events() {
#[test]
fn type_tool_click_type_edit_and_commit() {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app());
let mut h = harness(app());
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
@@ -172,7 +177,7 @@ fn clicking_another_panel_stops_typing_into_text() {
let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
let title =
s.execute("layer.newText", json!({"text": "Title", "size": 80, "position": [100, 120], "justify": "left"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
let mut h = harness(EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
let layers = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.len();
@@ -231,7 +236,7 @@ fn opening_a_formatting_tab_keeps_the_text_selection() {
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
let lid =
s.execute("layer.newText", json!({"text": "Hello world", "size": 80, "position": [60, 200], "justify": "left"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
let mut h = harness(EffectcraftApp::new(s));
h.run_steps(3);
let ctx = h.ctx.clone();
// Effects & Presets shown, Properties, Character and Paragraph behind it.
@@ -261,7 +266,7 @@ fn keys_after_a_click_away_in_the_same_frame_skip_the_text() {
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
let lid = s.execute("layer.newText", json!({"text": "Hello", "size": 80, "position": [60, 200], "justify": "left"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
let mut h = harness(EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
h.state_mut().session.execute("text.edit", json!({"layer": lid, "caret": 5})).unwrap();
@@ -293,7 +298,7 @@ fn keys_after_a_click_away_in_the_same_frame_skip_the_text() {
#[test]
fn switching_from_type_to_selection_commits_and_moves_text_layer() {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app());
let mut h = harness(app());
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
@@ -327,7 +332,7 @@ fn double_click_enters_editing_and_selects_words() {
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
let lid =
s.execute("layer.newText", json!({"text": "alpha beta", "size": 80, "position": [100, 200], "justify": "left"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
let mut h = harness(EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
// A point inside "beta" (comp space → window).
@@ -368,7 +373,7 @@ fn double_click_enters_editing_and_selects_words() {
#[test]
fn type_tool_drag_makes_paragraph_text_with_box_handles() {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app());
let mut h = harness(app());
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
@@ -418,7 +423,7 @@ fn layer_new_text_menu_and_shortcut_start_typing_into_source_text() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
let mut h = harness(EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
if via_shortcut {
@@ -447,7 +452,7 @@ fn timeline_double_click_on_a_text_layer_edits_its_text() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
let lid = s.execute("layer.newText", json!({"text": "alpha beta"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
let mut h = harness(EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
let p = rect(&h, &format!("timeline.layer.{lid}.{target}")).center();
@@ -474,7 +479,7 @@ fn duplicated_text_boxes_both_remain_editable() {
.unwrap()["layer"]
.as_u64()
.unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| a);
let mut h = harness(a);
h.run_steps(3);
h.state_mut().ui.focused = PanelKind::Composition;
key(&mut h, Key::D, Modifiers::COMMAND);
@@ -500,7 +505,7 @@ fn duplicated_text_boxes_both_remain_editable() {
fn overlapping_duplicate_text_remains_editable_by_double_click_and_selected_type_tool() {
let mut a = app();
let original = a.session.execute("layer.newText", json!({"text": "alpha beta", "size": 40, "box": [40, 60, 250, 100]})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| a);
let mut h = harness(a);
h.run_steps(3);
h.state_mut().ui.focused = PanelKind::Composition;
key(&mut h, Key::D, Modifiers::COMMAND);
@@ -562,7 +567,7 @@ fn type_tool_selected_text_preference_skips_ineligible_layers_and_falls_back_to_
}
a.session.state.selected_layers = if exclusion == "unselected" { vec![] } else { vec![LayerId(original)] };
a.ui.tool = effectcraft_ui_egui::state::Tool::Type;
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| a);
let mut h = harness(a);
h.run_steps(3);
let comp = rect(&h, "viewer.comp");
let p = comp.min + egui::vec2(80.0, 85.0) * (comp.width() / 640.0);
@@ -570,3 +575,152 @@ fn type_tool_selected_text_preference_skips_ineligible_layers_and_falls_back_to_
assert_eq!(edited(&h).0, topmost, "{exclusion} selected text must not override the eligible hit");
}
}
/// Opens a fresh Type-tool edit holding `text` and returns the harness with the new layer id,
/// so the Deselect All tests start from the same state.
fn type_tool_edit(a: EffectcraftApp, text: &str) -> (Harness<'static, EffectcraftApp>, u64) {
let mut h = harness(a);
h.run_steps(3);
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
h.run_steps(1);
let comp = rect(&h, "viewer.comp");
let p = comp.min + egui::vec2(180.0, 160.0) * (comp.width() / 640.0);
click(&mut h, p, Modifiers::NONE);
type_text(&mut h, text);
let (lid, _, _, _) = edited(&h);
(h, lid)
}
#[test]
fn deselect_all_chords_end_a_type_tool_edit() {
for (name, k, mods) in [("Cmd/Ctrl+Shift+A", Key::A, Modifiers { shift: true, ..Modifiers::COMMAND }), ("F2", Key::F2, Modifiers::NONE)] {
let (mut h, lid) = type_tool_edit(app(), "Hello");
let (_, text, _, _) = edited(&h);
assert_eq!(text, "Hello", "{name}: the Type tool click and typing made a layer");
key(&mut h, k, mods);
assert!(h.state().session.state.text_edit.is_none(), "{name}: Deselect All ends the edit");
assert!(h.state().session.state.selected_layers.is_empty(), "{name}: and deselects the layer");
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Hello", "{name}: the typed text is kept");
}
}
#[test]
fn cmd_a_in_a_text_edit_still_selects_all_characters() {
let (mut h, _lid) = type_tool_edit(app(), "Hello");
key(&mut h, Key::A, Modifiers::COMMAND);
let (_, _, a, c) = edited(&h);
assert_eq!((a, c), (0, 5), "Select All keeps selecting every character");
assert!(h.state().session.state.text_edit.is_some(), "the edit stays open");
assert_eq!(h.state().session.state.selected_layers.len(), 1, "and the layer stays selected");
}
#[test]
fn rebound_deselect_all_chord_forwards_from_inside_the_edit() {
let mut a = app();
a.session.execute("shortcuts.set", json!({"command": "edit.deselectAll", "keys": ["Cmd+Alt+D"]})).unwrap();
let (mut h, lid) = type_tool_edit(a, "Hello");
// The chord belongs to the shortcut, not to a hard-coded key: the old defaults do
// nothing special while the editor holds the keyboard…
key(&mut h, Key::A, Modifiers { shift: true, ..Modifiers::COMMAND });
assert!(h.state().session.state.text_edit.is_some(), "Cmd+Shift+A was rebound away");
let (_, _, a, c) = edited(&h);
assert_eq!((a, c), (5, 5), "and Cmd+Shift+A no longer selects all characters either");
key(&mut h, Key::F2, Modifiers::NONE);
assert!(h.state().session.state.text_edit.is_some(), "nor does F2");
// …and the current binding is what ends the edit.
key(&mut h, Key::D, Modifiers { alt: true, ..Modifiers::COMMAND });
assert!(h.state().session.state.text_edit.is_none(), "the rebound chord ends the edit");
assert!(h.state().session.state.selected_layers.is_empty(), "and deselects");
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Hello", "the text is kept");
}
#[test]
fn deselect_all_rebound_onto_an_alt_variant_of_an_editor_chord_forwards() {
let alt_cmd = |k| (k, Modifiers { alt: true, ..Modifiers::COMMAND });
for (bound, (k, mods)) in [("Cmd+Alt+Z", alt_cmd(Key::Z)), ("Cmd+Alt+A", alt_cmd(Key::A))] {
let mut a = app();
a.session.execute("shortcuts.set", json!({"command": "edit.deselectAll", "keys": [bound]})).unwrap();
let (mut h, lid) = type_tool_edit(a, "Hello");
// The editor owns Cmd+A and Cmd+Z themselves, whatever Deselect All is bound to…
key(&mut h, Key::A, Modifiers::COMMAND);
let (_, _, a0, c0) = edited(&h);
assert_eq!((a0, c0), (0, 5), "{bound}: Cmd+A still selects every character");
type_text(&mut h, "Hi");
key(&mut h, Key::Z, Modifiers::COMMAND);
assert_eq!(edited(&h).1, "Hello", "{bound}: Cmd+Z still undoes the typing");
// …but not their Alt variants, which belong to the rebound Deselect All.
key(&mut h, k, mods);
assert!(h.state().session.state.text_edit.is_none(), "{bound}: the rebound chord ends the edit");
assert!(h.state().session.state.selected_layers.is_empty(), "{bound}: and deselects");
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Hello", "{bound}: without undoing or selecting text");
}
}
#[test]
fn bare_letter_rebinding_stays_typing_but_a_function_key_rebinding_forwards() {
let mut a = app();
a.session.execute("shortcuts.set", json!({"command": "edit.deselectAll", "keys": ["A"]})).unwrap();
let (mut h, lid) = type_tool_edit(a, "Hello");
// A binding without a command modifier is text as far as the editor is concerned: the
// keystroke belongs to the text, since a command may not steal a key that types
// (the app-wide dispatcher honors the same invariant by skipping focused text fields).
key(&mut h, Key::A, Modifiers::NONE);
assert!(h.state().session.state.text_edit.is_some(), "a bare-letter Deselect All binding does not end the edit");
type_text(&mut h, "a");
let (lid2, _, a0, c0) = edited(&h);
assert_eq!(lid2, lid, "the same edit on the same layer continues");
assert_eq!((a0, c0), (6, 6), "the letter typed at the caret");
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Helloa", "and landed in the text");
// A function key types nothing, so a modifier-free function-key binding is forwarded
// (the default F2 relies on this).
h.state_mut().session.execute("shortcuts.set", json!({"command": "edit.deselectAll", "keys": ["F7"]})).unwrap();
key(&mut h, Key::F7, Modifiers::NONE);
assert!(h.state().session.state.text_edit.is_none(), "a bare function-key binding ends the edit");
assert!(h.state().session.state.selected_layers.is_empty(), "and deselects");
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Helloa", "the text is kept");
}
/// Opens a text edit the way a user does from the Project panel — Layer ▸ New ▸ Text's shortcut
/// while that panel is focused — so the insertion point lives in the comp while focus stays on
/// Project. Returns the harness and the new layer id.
fn project_focused_text_edit(text: &str) -> (Harness<'static, EffectcraftApp>, u64) {
let mut h = harness(app());
h.run_steps(3);
h.state_mut().ui.focused = PanelKind::Project;
h.run_steps(1);
key(&mut h, Key::T, Modifiers { alt: true, shift: true, ..Modifiers::COMMAND });
type_text(&mut h, text);
let (lid, typed, _, _) = edited(&h);
assert_eq!(typed, text, "typing went into the new layer");
assert_eq!(h.state().ui.focused, PanelKind::Project, "the Project panel is still the focused panel");
(h, lid)
}
#[test]
fn deselect_all_chords_end_an_edit_started_while_the_project_panel_is_focused() {
for (name, k, mods) in [("Cmd/Ctrl+Shift+A", Key::A, Modifiers { shift: true, ..Modifiers::COMMAND }), ("F2", Key::F2, Modifiers::NONE)] {
let (mut h, lid) = project_focused_text_edit("Hi");
key(&mut h, k, mods);
assert!(h.state().session.state.text_edit.is_none(), "{name}: Deselect All ends the edit");
assert!(h.state().session.state.selected_layers.is_empty(), "{name}: and deselects the layer");
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Hi", "{name}: the typed text is kept");
}
}
#[test]
fn deselect_all_menu_item_follows_the_edit_while_the_project_panel_is_focused() {
let enabled = |h: &Harness<'_, EffectcraftApp>| {
effectcraft_ui_egui::menus::menu_items(h.state()).into_iter().find(|m| m.id == "edit.deselectAll").is_some_and(|m| m.enabled)
};
let (mut h, _) = project_focused_text_edit("Hi");
h.state_mut().session.execute("project.select", json!({"items": []})).unwrap();
h.run_steps(1);
assert!(h.state().session.state.text_edit.is_some(), "the edit is still open");
// The item ends the edit, so an empty Project selection must not gray it out.
assert!(enabled(&h), "Deselect All is enabled during the edit");
key(&mut h, Key::Escape, Modifiers::NONE);
assert!(h.state().session.state.text_edit.is_none(), "Escape ended the edit");
assert_eq!(h.state().ui.focused, PanelKind::Project, "the Project panel is still the focused panel");
// 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");
}