diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 3e125c81..c973b467 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -66,12 +66,14 @@ pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> pub(super) fn text_edit(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Response { let t = Tokens::get(ui.ctx()); let mut s = d.str(key); + let id = ui.id().with(("dlg-text", key)); + crate::widgets::take_dialog_focus(ui, id, &s); let r = egui::Frame::NONE .fill(t.input) .stroke(egui::Stroke::new(1.0, t.input_border)) .corner_radius(egui::CornerRadius::same(3)) .inner_margin(egui::Margin::symmetric(6, 3)) - .show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(width))); + .show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).id(id).frame(egui::Frame::NONE).desired_width(width))); if r.inner.changed() { d.fields.insert(key.into(), Value::String(s)); } diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index b7e292c9..325f2068 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -314,10 +314,24 @@ pub fn confirm(app: &mut VectorcraftApp) -> DialogResult { pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { about::show(app, ctx); + // The kind of dialog shown last frame: a different one (or none) means this one just opened, and + // its first field is to take the keyboard focus (`focus_id`, until a field takes it). + let (shown_id, focus_id) = (egui::Id::new("dialog-shown"), egui::Id::new("dialog-focus-pending")); let Some(mut d) = app.ui.dialog.clone() else { app.ui.dialog_file = None; + ctx.data_mut(|m| { + m.remove::(shown_id); + m.remove::(focus_id); + }); return; }; + let focus_first = ctx.data_mut(|m| { + if m.get_temp::(shown_id).as_deref() != Some(d.kind.as_str()) { + m.insert_temp(shown_id, d.kind.clone()); + m.insert_temp(focus_id, true); + } + m.get_temp::(focus_id).unwrap_or(false) + }); let spec = spec(&d.kind); if let Some(window) = spec.window { return window(app, ctx); @@ -347,7 +361,16 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.set_max_width(spec.max_width.map_or(room, |w| w.min(room))); ui.label(egui::RichText::new(heading.as_str()).font(theme::semibold(16.0)).color(t.text)); ui.add_space(12.0); + // Just opened: its first field takes the keyboard focus, as in the reference app (type a + // value, press Enter). Only the body's fields can take it. + if focus_first { + ui.data_mut(|m| m.insert_temp(widgets::dialog_focus_flag(), true)); + } cancel = (spec.body)(app, ui, &mut d); + // Taken (the flag is gone): done. Still there (the window's measuring frame): next frame. + if focus_first && ui.data_mut(|m| m.remove_temp::(widgets::dialog_focus_flag())).is_none() { + ui.data_mut(|m| m.remove::(focus_id)); + } ui.add_space(16.0); // The button row is as wide as the fields above it and as tall as the buttons: a // right-to-left layout would otherwise take all the room left in the window, so the diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index bb861c42..853f29a4 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -313,3 +313,35 @@ fn given_headings_are_shown_as_they_are() { let d = Dialog::new(plugin::KIND, json!({"__label": "Black", "__plugin": "x"})); assert_eq!((spec(&d.kind).heading)(&d), "Black"); } + +#[test] +fn a_dialog_opens_with_its_first_field_focused_so_typing_and_enter_apply() { + let mut app = app(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + }; + let enter = || egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + let bounds = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + // Object › Transform › Rotate…: type 90 over the angle, press Enter. + crate::menus::invoke(&mut app, "object.rotate", json!({})); + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text("90".into())]); + frame(&mut app, vec![enter()]); + assert!(app.ui.dialog.is_none()); + let b = bounds(&app); + assert!((b.width() - 20.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "{b:?}"); + // Move…: its first field is a distance (with a unit): typing replaces it as well. + let before = bounds(&app); + crate::menus::invoke(&mut app, "object.move", json!({})); + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text("15".into())]); + frame(&mut app, vec![enter()]); + assert!(app.ui.dialog.is_none()); + let after = bounds(&app); + assert!((after.x0 - before.x0 - 15.0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "{before:?} → {after:?}"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 06a2425c..bf5f5ffb 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -109,6 +109,7 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: if !editing { buf = shown.clone(); } + take_dialog_focus(ui, id, &buf); let resp = ui .allocate_ui_with_layout(vec2(width, 26.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { ui.set_min_width(width); @@ -137,6 +138,26 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: if commit { unit.parse(&buf) } else { None } } +/// The flag `dialogs::show` raises while it draws the body of a dialog that has just opened; the +/// first field drawn takes it ([`take_dialog_focus`]). +pub(crate) fn dialog_focus_flag() -> egui::Id { + egui::Id::new("dialog-focus-first-field") +} + +/// If field `id` (showing `text`) is the first field of a dialog that has just opened, give it the +/// keyboard focus with all of `text` selected: typing replaces the value and Enter applies it. +pub(crate) fn take_dialog_focus(ui: &Ui, id: egui::Id, text: &str) { + // A new window's first frame only measures its contents: nothing can take focus yet. + if ui.is_sizing_pass() || ui.data_mut(|d| d.remove_temp::(dialog_focus_flag())).is_none() { + return; + } + let mut st = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); + st.cursor.set_char_range(Some(all)); + st.store(ui.ctx(), id); + ui.memory_mut(|m| m.request_focus(id)); +} + /// Numeric fields take the whole text, unit included, when they gain focus or are double-clicked /// (egui would select one word): typing a number or an expression replaces it. fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) {