diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index dfdd01b5..80d68f8e 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1336,10 +1336,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/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index dc697baa..de971bdc 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -274,6 +274,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..f4604f2e 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,16 +348,18 @@ 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())); } 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)); @@ -359,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 86734b0b..6e16b676 100644 --- a/crates/ui-egui/tests/ui_text_edit.rs +++ b/crates/ui-egui/tests/ui_text_edit.rs @@ -160,6 +160,137 @@ 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"); + } +} + +/// #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());