From efd081acc0b889a9a376f6e1149f15fa11bb76bd Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Wed, 14 Oct 2026 07:47:42 -0500 Subject: [PATCH] UI parity: form dialogs open ready to type Column Width, Row Height and the other form dialogs now open with the cursor in their first field and its value selected, as in Excel: Alt+H, O, W, then 12 and Enter sets the width without clicking into the field first. - The first text or number field takes focus once it can (a new window's first frame is a sizing pass that ignores focus requests), and its value is selected so typing replaces it. - Number fields commit while typing, so Enter or OK in the same frame no longer confirms the old value. Tests: tests/dialog_focus.rs (Column Width and Row Height: type, Enter, value applied). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 34 +++++++++++++++- crates/ui-egui/tests/dialog_focus.rs | 61 ++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/dialog_focus.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb899c702..1fc373bb8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -36,6 +36,9 @@ pub struct Dialog { pub result: Option, /// "Show password" is ticked: password fields show their text. pub show_password: bool, + /// The first text or number field still has to take keyboard focus (set when a form opens, + /// used once): like Excel, Column Width opens ready to type, its value selected. + focus_first_field: bool, picture_inbox: Option, picture_target: Option<(u64, usize)>, /// The dialog this one was opened from (Name Manager › New…): it comes back when this closes. @@ -70,6 +73,7 @@ impl Dialog { list_index: 0, result: None, show_password: false, + focus_first_field: true, picture_inbox: None, picture_target: None, back: None, @@ -88,6 +92,7 @@ impl Dialog { list_index: 0, result: None, show_password: false, + focus_first_field: false, picture_inbox: None, picture_target: None, back: None, @@ -1387,6 +1392,28 @@ fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) ok_cancel(ui, confirm, open); } +/// Gives keyboard focus to the first text or number field the first time a form is drawn. A +/// focused number field is in typing mode with its value selected, so Alt+H, O, W then 12 and +/// Enter sets the width, as in Excel. +/// Keeps asking until the field really has focus: a new window's first frame is an invisible +/// sizing pass where a focus request is ignored. +fn take_first_focus(d: &mut Dialog, r: &egui::Response) { + if !d.focus_first_field { + return; + } + if !r.has_focus() { + r.request_focus(); + return; + } + // Focus has landed, and the field is now a text box: select its whole value so typing + // replaces it (the field's own select-on-focus fired before it was a text box). + let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(usize::MAX / 2)); + state.cursor.set_char_range(Some(all)); + state.store(&r.ctx, r.id); + d.focus_first_field = false; +} + /// Draws a dialog's `fields`, editing its values. fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { egui::Grid::new("form").num_columns(1).spacing(vec2(8.0, 6.0)).show(ui, |ui| { @@ -1406,6 +1433,7 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { } else { ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0)) }; + take_first_focus(d, &r); if r.changed() { d.values.insert(key.into(), json!(s)); } @@ -1426,7 +1454,11 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { ui.horizontal(|ui| { ui.label(label); let mut n = d.values.get(key).and_then(Json::as_f64).unwrap_or(0.0); - if ui.add(egui::DragValue::new(&mut n).speed(0.5)).changed() { + // Typed values count straight away: otherwise Enter or OK in the same frame + // confirms the old value, because a number field only commits when it loses focus. + let r = ui.add(egui::DragValue::new(&mut n).speed(0.5).update_while_editing(true)); + take_first_focus(d, &r); + if r.changed() { d.values.insert(key.into(), json!(n)); } }); diff --git a/crates/ui-egui/tests/dialog_focus.rs b/crates/ui-egui/tests/dialog_focus.rs new file mode 100644 index 000000000..2dd047554 --- /dev/null +++ b/crates/ui-egui/tests/dialog_focus.rs @@ -0,0 +1,61 @@ +//! Form dialogs open ready to type, as in Excel: Alt+H, O, W then a number and Enter sets the +//! column width without clicking into the field first. + +use egui::{Event, Key, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("selection.set", json!({"cell": "C3"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(6); + h +} + +fn type_and_enter(h: &mut egui_kittest::Harness<'static, SheetApp>, text: &str) { + for c in text.chars() { + h.input_mut().events.push(Event::Text(c.to_string())); + h.run_steps(1); + } + h.key_press(Key::Enter); + h.run_steps(6); +} + +#[test] +fn column_width_opens_with_the_width_ready_to_type() { + let mut h = harness(); + h.state_mut().open_dialog("columnWidth", json!({})); + h.run_steps(6); + type_and_enter(&mut h, "12"); + assert!(h.state().dialog.is_none(), "Enter confirmed the dialog"); + let chars = { + let d = h.state().session.active().unwrap(); + let sheet = d.wb.active().unwrap(); + gridcraft_engine::cmd::format::points_to_chars(sheet.col_width(CellRef::parse("C3").unwrap().col) as f64) + }; + assert!((chars - 12.0).abs() < 0.2, "column C is 12 characters wide, got {chars}"); +} + +#[test] +fn row_height_opens_with_the_height_ready_to_type() { + let mut h = harness(); + h.state_mut().open_dialog("rowHeight", json!({})); + h.run_steps(6); + type_and_enter(&mut h, "40"); + assert!(h.state().dialog.is_none(), "Enter confirmed the dialog"); + let height = { + let d = h.state().session.active().unwrap(); + d.wb.active().unwrap().row_height(CellRef::parse("C3").unwrap().row) + }; + assert!((height as f64 - 40.0).abs() < 0.6, "row 3 is 40 high, got {height}"); +}