Fix #307: a form dialog opens with its first field focused

Rotate, Scale, Move and the other form dialogs opened with no field
focused, so a typed value went nowhere and Enter applied the default (a
Rotate by 0 degrees). The frame a dialog opens on (after the window's
measuring frame), its first text or number field takes the keyboard
focus with its text selected, so typing replaces it and Enter applies
it, as in the reference app.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
theironchefandClaude Opus 5.5 authored and bflatastic committed 2026-10-08 03:22:51 +07:00
1 parent 5e0c911633
commit aa9e2e89c8
4 files changed
+79 -1

No files matched your search

+3 -1
View File
@@ -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));
}
+23
View File
@@ -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::<String>(shown_id);
m.remove::<bool>(focus_id);
});
return;
};
let focus_first = ctx.data_mut(|m| {
if m.get_temp::<String>(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::<bool>(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::<bool>(widgets::dialog_focus_flag())).is_none() {
ui.data_mut(|m| m.remove::<bool>(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
+32
View File
@@ -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<egui::Event>| {
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:?}");
}
+21
View File
@@ -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::<bool>(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) {