Merge pull request #892 from tylerjenningsw/fix/text-click-away
Text: stop capturing keyboard input after clicking away (#831)
This commit is contained in:
commit
7a03b6fcd1
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}));
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in New Issue