Merge pull request #892 from tylerjenningsw/fix/text-click-away

Text: stop capturing keyboard input after clicking away (#831)
This commit is contained in:
Brandon Thomas 2026-10-12 04:20:45 -04:00
commit 7a03b6fcd1
4 changed files with 163 additions and 6 deletions

View File

@ -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));
}

View File

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

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,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::<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()));
}
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::<Option<u64>>(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}));

View File

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