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.
This commit is contained in:
tylerjenningsw committed 2026-10-11 13:01:36 -04:00
1 parent 67434e9038
commit 1b7ab07822
3 files changed
+84 -3

No files matched your search

+1
View File
@@ -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);
+21 -3
View File
@@ -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<Value> {
}
}
/// 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::<Option<u64>>(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::<Option<u64>>(seen)).flatten() != key || regain {
ui.data_mut(|d| d.insert_temp(seen, key));
ui.memory_mut(|m| m.request_focus(focus_id()));
}
+62
View File
@@ -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());