From 1b7ab07822cdc853c9eaa4ea278f52d42e001e68 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 11 Oct 2026 13:01:36 -0400 Subject: [PATCH 1/2] Text: stop capturing keyboard input after clicking away (#831) After typing into a text layer, clicking another panel (a solid in the Timeline, say) left the edit open, and the viewer took the keyboard back as soon as nothing had the focus: typed characters went into the text and Space added a space instead of playing. A click in any panel other than the viewers and the panels that format the selected characters (Character, Paragraph, Properties) now ends the edit with text.endEdit, so the text is kept and an empty layer the Type tool started is removed, and the viewer's text focus is surrendered. The editor takes the keyboard back only after a click in the viewer or one of those panels, no longer merely because nothing has the focus. A click elsewhere in the viewer still commits, and with the Type tool still starts new text. Test: clicking_another_panel_stops_typing_into_text, with the Type tool and with an edit started by command: Character > Faux Bold keeps the edit, its selection and typing; clicking the solid's Timeline row ends it, selects the solid, typed characters leave the text unchanged and Space plays; an empty Type-tool layer is removed. Fails on main. --- crates/ui-egui/src/dock_ui.rs | 1 + crates/ui-egui/src/panels/viewer_text.rs | 24 +++++++-- crates/ui-egui/tests/ui_text_edit.rs | 62 ++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 9c28f353..2a501138 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -264,6 +264,7 @@ impl EffectcraftApp { } DockAction::Focus(p) => { self.ui.focused = p; + panels::viewer_text::panel_clicked(self, &ctx, p); // A click in (or on the tab of) another Composition viewer makes it active. if let Some(id) = panels::viewers::id_of(p) { panels::viewers::activate(self, &ctx, id); diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs index 36a3c7f2..0dd95c68 100644 --- a/crates/ui-egui/src/panels/viewer_text.rs +++ b/crates/ui-egui/src/panels/viewer_text.rs @@ -26,6 +26,7 @@ use serde_json::{Value, json}; use super::viewer::ViewerMap; use crate::EffectcraftApp; +use crate::dock::PanelKind; use crate::state::Tool; /// Pointer interaction owned by text editing (kept across frames while the button is down). @@ -135,6 +136,21 @@ fn exec(app: &mut EffectcraftApp, id: &str, p: Value) -> Option { } } +/// The panels a text edit stays open across: the viewers, and Character, Paragraph and +/// Properties, which format the selected characters. +fn keeps_text_edit(p: PanelKind) -> bool { + super::viewers::id_of(p).is_some() || matches!(p, PanelKind::Character | PanelKind::Paragraph | PanelKind::Properties) +} + +/// A click in panel `p`. Any other panel (the Timeline, the Project panel, …) ends the edit, as a +/// click outside the text does, and gives the keyboard back to the app (#831). +pub fn panel_clicked(app: &mut EffectcraftApp, ctx: &egui::Context, p: PanelKind) { + if app.session.state.text_edit.is_some() && !keeps_text_edit(p) { + exec(app, "text.endEdit", json!({})); + ctx.memory_mut(|m| m.surrender_focus(focus_id())); + } +} + /// Typing merge key: consecutive keystrokes fold into one undo step until the caret moves. fn typing_key(ui: &egui::Ui, bump: bool) -> String { let id = egui::Id::new("viewer-text-typing"); @@ -332,11 +348,13 @@ pub fn hook( ui.memory_mut(|m| { m.set_focus_lock_filter(focus_id(), egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }); }); - // A session started elsewhere (menu, agent, Properties) takes the keyboard once. + // A session started elsewhere (menu, agent, Properties) takes the keyboard once… let seen = egui::Id::new("viewer-text-seen"); let key = app.session.state.text_edit.as_ref().map(|e| e.layer.0); - // …and gets it back whenever nothing else holds it (after a panel click, say). - if ui.data(|d| d.get_temp::>(seen)).flatten() != key || ui.memory(|m| m.focused().is_none()) { + // …and gets it back when a click in the viewer or a formatting panel left nothing holding + // it; not merely because nothing has the focus (#831). + let regain = ui.memory(|m| m.focused().is_none()) && keeps_text_edit(app.ui.focused); + if ui.data(|d| d.get_temp::>(seen)).flatten() != key || regain { ui.data_mut(|d| d.insert_temp(seen, key)); ui.memory_mut(|m| m.request_focus(focus_id())); } diff --git a/crates/ui-egui/tests/ui_text_edit.rs b/crates/ui-egui/tests/ui_text_edit.rs index 1c2c7355..4cd9ec99 100644 --- a/crates/ui-egui/tests/ui_text_edit.rs +++ b/crates/ui-egui/tests/ui_text_edit.rs @@ -160,6 +160,68 @@ fn type_tool_click_type_edit_and_commit() { assert!(h.state().session.state.text_edit.is_none()); } +/// #831: clicking another panel (a solid's row in the Timeline) ends text editing and gives the +/// keyboard back, whether the edit began with the Type tool (which stays selected) or by command: +/// typed characters change no text and Space plays. The Character panel keeps the edit, and a +/// text layer clicked into being and left empty goes away. +#[test] +fn clicking_another_panel_stops_typing_into_text() { + for type_tool in [false, true] { + let mut s = Session::default(); + 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 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)); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(3); + let layers = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.len(); + // (Its row moves down as text layers are added.) + let row = |h: &Harness<'_, EffectcraftApp>| rect(h, &format!("timeline.layer.{solid}.row")).center(); + if type_tool { + h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type; + h.run_steps(1); + let at = rect(&h, "viewer.comp").center(); + click(&mut h, at, Modifiers::NONE); + assert_eq!(layers(&h), 3, "the Type tool started a text layer"); + let at_row = row(&h); + click(&mut h, at_row, Modifiers::NONE); + assert_eq!(layers(&h), 2, "left empty, it is removed"); + click(&mut h, at, Modifiers::NONE); + } else { + h.state_mut().session.execute("text.edit", json!({"layer": title})).unwrap(); + h.run_steps(2); + } + type_text(&mut h, "Hello"); + // The Character panel formats the selection and keeps the edit: typing carries on. + key(&mut h, Key::ArrowLeft, Modifiers::SHIFT); + let range = edited(&h); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + let bold = rect(&h, "character.fauxBold").center(); + click(&mut h, bold, Modifiers::NONE); + assert_eq!(edited(&h), range, "type_tool {type_tool}: Character keeps the edit and its selection"); + key(&mut h, Key::End, Modifiers::NONE); + type_text(&mut h, "!"); + let (lid, text, ..) = edited(&h); + assert!(text.ends_with("Hello!"), "typing goes into the text: {text:?}"); + let at_row = row(&h); + click(&mut h, at_row, Modifiers::NONE); + assert!(h.state().session.state.text_edit.is_none(), "type_tool {type_tool}: clicking the Timeline ends the edit"); + assert_eq!(h.state().session.state.selected_layers, vec![LayerId(solid)]); + assert!(!h.ctx.memory(|m| m.focused().is_some()), "the keyboard is the app's again"); + type_text(&mut h, "abc"); + assert!(!h.state().playback.playing); + h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Text(" ".into())); + h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, text, "type_tool {type_tool}: the text is unchanged"); + assert!(h.state().playback.playing, "type_tool {type_tool}: Space plays"); + assert_eq!(type_tool, matches!(h.state().ui.tool, effectcraft_ui_egui::state::Tool::Type), "the tool is kept"); + } +} + #[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()); From f6d42e2d677c53242017f5edccfc0813f7b125f4 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 11 Oct 2026 14:48:20 -0400 Subject: [PATCH 2/2] Text: formatting tabs keep the edit; keys after a click away skip it (#831) Two follow-ups to ending text editing on a click in another panel. A press anywhere in a panel group focused the group's shown panel, even a press on another of its tabs. Opening Properties or Character behind Effects & Presets (as in Undocked Panels) therefore ended the edit on the press, before the tab's click focused the panel chosen, and later formatting went to the whole layer instead of the selected characters (an empty Type-tool layer was even removed). A press on another tab now waits for that tab's click. The click away is resolved after the frame's panels, so keys that came after it in the same frame's input were still typed into the text. The text keyboard handler now takes the frame's keys in order up to a press outside the viewer: keys before the click are typed, keys after it are neither typed nor run as shortcuts, and from the next frame they are the app's. Tests: opening_a_formatting_tab_keeps_the_text_selection (a real click on the Properties tab keeps the edit and its selection; Faux Bold then styles only "Hello") and keys_after_a_click_away_in_the_same_frame_skip_ the_text ("A", a click on a Timeline row, then "x", Space and Delete in one frame: only "A" is typed, nothing is played or deleted; Space in the next frame plays). Both fail on the previous commit. --- crates/ui-egui/src/dock.rs | 5 +- crates/ui-egui/src/panels/viewer_text.rs | 8 ++- crates/ui-egui/tests/ui_text_edit.rs | 69 ++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 3e802487..d05a3a1f 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1305,10 +1305,13 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra } } } - // clicking anywhere in the panel focuses it + // clicking anywhere in the panel focuses it; a press on another of its tabs waits for that + // tab's click, which focuses the panel actually chosen (a text edit stays open in Character) + let at = ui.input(|i| i.pointer.interact_pos()); if let Some(p) = active_panel && ui.rect_contains_pointer(g.rect) && ui.input(|i| i.pointer.any_pressed()) + && !out.tabs.iter().any(|(q, r)| *q != p && at.is_some_and(|a| r.contains(a))) { out.actions.push(DockAction::Focus(p)); } diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs index 0dd95c68..f4604f2e 100644 --- a/crates/ui-egui/src/panels/viewer_text.rs +++ b/crates/ui-egui/src/panels/viewer_text.rs @@ -359,7 +359,7 @@ pub fn hook( ui.memory_mut(|m| m.request_focus(focus_id())); } if ui.memory(|m| m.has_focus(focus_id())) { - keyboard(app, ui); + keyboard(app, ui, resp.rect); } } else { ui.data_mut(|d| d.insert_temp::>(egui::Id::new("viewer-text-seen"), None)); @@ -377,12 +377,16 @@ pub fn hook( owns } -fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui) { +/// This frame's keys, in order, into the edited text, up to a press outside the `viewer`: the +/// panel it lands in decides (after this frame's panels) whether the edit ends, so the keys after +/// it in this frame are neither typed nor run as shortcuts (#831). +fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) { let events = ui.input(|i| i.events.clone()); let ctx = ui.ctx().clone(); for ev in events { let Some(sel) = app.session.state.text_edit.clone() else { return }; match ev { + egui::Event::PointerButton { pos, pressed: true, .. } if !viewer.contains(pos) => return, egui::Event::Text(t) if !t.chars().any(char::is_control) => { let k = typing_key(ui, false); exec(app, "text.insert", json!({"text": t, "merge": k})); diff --git a/crates/ui-egui/tests/ui_text_edit.rs b/crates/ui-egui/tests/ui_text_edit.rs index 4cd9ec99..08597882 100644 --- a/crates/ui-egui/tests/ui_text_edit.rs +++ b/crates/ui-egui/tests/ui_text_edit.rs @@ -222,6 +222,75 @@ fn clicking_another_panel_stops_typing_into_text() { } } +/// #831: opening a formatting panel's tab behind Effects & Presets in its group (Properties, in +/// Undocked Panels) keeps the edit and its selected characters: the press on the tab used to +/// focus the shown panel and end the edit, after which Faux Bold styled the whole layer. +#[test] +fn opening_a_formatting_tab_keeps_the_text_selection() { + 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": "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)); + h.run_steps(3); + let ctx = h.ctx.clone(); + // Effects & Presets shown, Properties, Character and Paragraph behind it. + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "Undocked Panels"})).unwrap(); + h.state_mut().session.execute("text.edit", json!({"layer": lid})).unwrap(); + h.state_mut().session.execute("text.setSelection", json!({"anchor": 0, "caret": 5})).unwrap(); + h.run_steps(3); + assert!(h.state().auto.find("properties.text.style").is_none(), "Properties is behind Effects & Presets"); + let tab = rect(&h, "panel.tab.Properties").center(); + click(&mut h, tab, Modifiers::NONE); + assert!(h.state().auto.find("properties.text.style").is_some(), "the tab opened Properties"); + assert_eq!(edited(&h), (lid, "Hello world".into(), 0, 5), "the edit and its selection are kept"); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + let bold = rect(&h, "character.fauxBold").center(); + click(&mut h, bold, Modifiers::NONE); + let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap(); + assert!(doc.style_at(1).faux_bold && !doc.style_at(7).faux_bold, "only \"Hello\" is bold"); +} + +/// #831: a click away and keys in one frame's input. The text gets the keys typed before the +/// click and none after it; those later keys don't run as shortcuts either (Delete would remove a +/// layer). From the next frame the keys are the app's: Space plays. +#[test] +fn keys_after_a_click_away_in_the_same_frame_skip_the_text() { + let mut s = Session::default(); + 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)); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(3); + h.state_mut().session.execute("text.edit", json!({"layer": lid, "caret": 5})).unwrap(); + h.run_steps(2); + let row = rect(&h, &format!("timeline.layer.{solid}.row")).center(); + let key = |key: Key, pressed: bool| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + h.input_mut().events.extend([ + Event::Text("A".into()), + Event::PointerMoved(row), + Event::PointerButton { pos: row, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos: row, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + Event::Text("x".into()), + key(Key::Space, true), + Event::Text(" ".into()), + key(Key::Space, false), + key(Key::Delete, true), + key(Key::Delete, false), + ]); + h.run_steps(3); + let comp = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.len(); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "HelloA", "only the key before the click"); + assert!(h.state().session.state.text_edit.is_none(), "the click ended the edit"); + assert_eq!((comp(&h), h.state().playback.playing), (2, false), "the keys after it ran no shortcut"); + h.input_mut().events.extend([key(Key::Space, true), Event::Text(" ".into()), key(Key::Space, false)]); + h.run_steps(2); + assert!(h.state().playback.playing, "Space plays"); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "HelloA"); +} + #[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());