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());