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}"); +}