diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index ca003c3b..c0370a0c 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -177,6 +177,7 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr ui.horizontal(|ui| { ui.label(tr("Composition Name:")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); auto.add("dialog.comp.name", r.rect, "Composition Name"); }); ui.add_space(8.0); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index e036b790..3ca0feee 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -400,6 +400,7 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.horizontal(|ui| { ui.label(tr("Name:")); let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(320.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &name); auto.add("dialog.solid.name", r.rect, "Name"); }); ui.add_space(8.0); @@ -675,6 +676,32 @@ mod tests { assert_eq!(app.session.project.comps().count(), comps + 1, "Single Composition is the default"); } + #[test] + fn a_dialog_opens_with_its_name_field_focused_and_selected() { + let (mut app, ctx) = app(); + let selected = |ctx: &egui::Context| { + let id = ctx.memory(|m| m.focused())?; + egui::TextEdit::load_state(ctx, id)?.cursor.char_range().map(|r| r.as_sorted_char_range()).map(|r| r.start.0..r.end.0) + }; + open_new_comp(&mut app); + let name = app.dialog_state.comp.name.clone(); + frame(&mut app, &ctx); + frame(&mut app, &ctx); + assert_eq!(selected(&ctx), Some(0..name.chars().count()), "typing replaces “{name}”"); + // Afterwards the focus is the user's: it is not taken back. + ctx.memory_mut(|m| m.stop_text_input()); + frame(&mut app, &ctx); + assert!(ctx.memory(|m| m.focused()).is_none()); + // Closed and opened again: focused again. + app.dialog = None; + frame(&mut app, &ctx); + frame(&mut app, &ctx); + open_new_solid(&mut app).unwrap(); + frame(&mut app, &ctx); + frame(&mut app, &ctx); + assert_eq!(selected(&ctx), Some(0..app.dialog_state.solid_name.chars().count())); + } + #[test] fn composition_settings_register_every_control_and_new_comp_remembers() { let (mut app, ctx) = app(); diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index afc0fc8a..a1a67bc6 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -173,7 +173,8 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); ui.end_row(); ui.label(tr("Name")); - ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(240.0)); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(240.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); ui.end_row(); ui.label(tr("Preset")); let focal = focal_for_zoom(w, d.zoom); @@ -258,7 +259,8 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { super::dialogs::modal(ctx, "Light Settings", vec2(480.0, 470.0), t, |ui| { egui::Grid::new("light-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { ui.label(tr("Name")); - ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0)); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); ui.end_row(); ui.label(tr("Light Type")); egui::ComboBox::from_id_salt("light-type").selected_text(KINDS[d.kind.min(3)].label()).show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index b04e017b..dacdef28 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -765,6 +765,7 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let h = 120.0 + rows; let mut regs: Vec<(String, egui::Rect, String)> = vec![]; let mut browse: Option = None; + let mut focused = false; // Wide enough for a label column and a 240 px popup without a horizontal scroll bar. super::dialogs::modal(ctx, &f.title.clone(), vec2(480.0, h), t, |ui| { egui::Grid::new("form-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| { @@ -773,9 +774,21 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let r = match &mut fl.kind { FieldKind::Number { value, speed } => { let dec = if *speed < 0.01 { 5 } else { 3 }; - ui.add(crate::widgets::drag_value(egui::DragValue::new(value).speed(*speed).max_decimals(dec))).rect + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(value).speed(*speed).max_decimals(dec))); + // The first field takes the keyboard when the form opens. (A number edits + // as text once focused; the selection is clamped to what it shows.) + if !std::mem::replace(&mut focused, true) { + crate::widgets::focus_on_open(ui.ctx(), &r, &" ".repeat(64)); + } + r.rect + } + FieldKind::Text(s) => { + let r = ui.add(egui::TextEdit::singleline(s).desired_width(220.0)); + if !std::mem::replace(&mut focused, true) { + crate::widgets::focus_on_open(ui.ctx(), &r, s); + } + r.rect } - FieldKind::Text(s) => ui.add(egui::TextEdit::singleline(s).desired_width(220.0)).rect, FieldKind::Path { value, .. } => { ui.horizontal(|ui| { let r = ui.add(egui::TextEdit::singleline(value).desired_width(150.0)).rect; @@ -947,6 +960,37 @@ mod tests { assert_eq!(f.params(), json!({"prop": "position", "value": [10.0, 20.0], "mode": "b"})); } + /// A numeric form (Layer ▸ Transform ▸ Position…) opens with its first number being edited + /// and selected: typing replaces it, and one Enter confirms. + #[test] + fn a_numeric_form_opens_with_its_first_number_selected() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + app.dialog_state.form = Form { + title: "Position".into(), + command: "layer.setTransform".into(), + base: json!({"prop": "position"}), + fields: vec![Field::num("value[0]", "X", 960.0), Field::num("value[1]", "Y", 540.0)], + }; + app.dialog = Some(Dialog::Form); + let frame = |app: &mut EffectcraftApp, events: Vec| { + let raw = egui::RawInput { events, ..Default::default() }; + ctx.run_ui(raw, |ui| { + app.auto.begin_frame(); + crate::panels::dialogs::show(app, ui.ctx()); + }) + .textures_delta + .clear(); + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text("42".into())]); + assert_eq!(app.dialog_state.form.params()["value"], json!([42.0, 540.0])); + frame(&mut app, vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); + assert!(app.dialog.is_none(), "one Enter confirms"); + } + /// File ▸ Export ▸ Adobe Premiere Pro Project… opens a form that says it writes Final Cut Pro /// XML; its fields register automation ids and OK runs `file.exportTimeline`. #[test] diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index 5f6d42a8..7eef7a3b 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -139,6 +139,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); ui.label(tr("Comment:")); let r = ui.add(egui::TextEdit::multiline(&mut d.comment).desired_rows(3).desired_width(320.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.comment); app.auto.add("dialog.marker.comment", r.rect, "Comment"); ui.end_row(); ui.label(tr("Chapter:")); diff --git a/crates/ui-egui/src/panels/precomp.rs b/crates/ui-egui/src/panels/precomp.rs index f4bf4272..8a40c56e 100644 --- a/crates/ui-egui/src/panels/precomp.rs +++ b/crates/ui-egui/src/panels/precomp.rs @@ -115,6 +115,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.horizontal(|ui| { ui.label(tr("New composition name:")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); app.auto.add("dialog.precompose.name", r.rect, "New composition name"); }); ui.add_space(10.0); diff --git a/crates/ui-egui/src/panels/shortcut_editor.rs b/crates/ui-egui/src/panels/shortcut_editor.rs index 021dffe7..c8948ab8 100644 --- a/crates/ui-egui/src/panels/shortcut_editor.rs +++ b/crates/ui-egui/src/panels/shortcut_editor.rs @@ -420,6 +420,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { // ---- search + command list + editor ui.horizontal(|ui| { let r = ui.add(egui::TextEdit::singleline(&mut st.query).hint_text(tr("Search commands or shortcuts")).desired_width(360.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &st.query); app.auto.add("shortcuts.search", r.rect, "Search"); }); ui.add_space(4.0); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 51b6ceef..c005224d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -194,6 +194,24 @@ pub fn keep_focus(ctx: &egui::Context, field: &Response, text: &str) { } } +/// Put the keyboard on a dialog's first text field the frame the dialog opens, with its whole +/// `text` selected, so typing replaces the value without a click. Unlike [`keep_focus`] the +/// focus is the user's afterwards. +pub fn focus_on_open(ctx: &egui::Context, field: &Response, text: &str) { + // The dialog is open in consecutive passes; a gap means it was closed and opened again. + let shown = field.id.with("focus-on-open-pass"); + let pass = ctx.cumulative_pass_nr(); + let previous = ctx.data_mut(|d| { + let previous = d.get_temp::(shown); + d.insert_temp(shown, pass); + previous + }); + if previous.is_none_or(|p| p.saturating_add(1) < pass) { + select_all(ctx, field.id, text); + field.request_focus(); + } +} + /// Select all of the text field `id`'s `text`, so typing replaces it. pub fn select_all(ctx: &egui::Context, id: egui::Id, text: &str) { let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default();