From be0882a148d087139d8275005da5e80587ce943e Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:22:18 -0500 Subject: [PATCH] Form controls: a button gives the keyboard back when the grid takes a key or a click A click on a button focused it, and the focus stayed while the grid moved the cell cursor with the arrows, Tab or Delete, or opened a cell's menu with a right-click, so a later Enter or Space pressed the button again without a new click, and Space could not start a cell edit. Now a key press the focused button doesn't take (any key but Space, Enter and Escape, or one with Ctrl, Cmd or Alt) gives the keyboard back to the grid, and any press that isn't a left click on the grid, a right-click on a cell included, moves the focus off a control. A slider or spin button keeps the keyboard as the control last worked. Tests (kittest): a_button_gives_the_keyboard_back_when_the_grid_takes_a_key (arrow, then Enter moves the cursor and does not press; arrow, then Space starts a cell edit; Cmd+arrow takes the keyboard too) and a_right_click_on_a_cell_takes_the_keyboard_from_a_button. docs/ui-parity.md says so. --- crates/ui-egui/src/form_controls.rs | 14 ++++- crates/ui-egui/src/grid/keyboard.rs | 9 ++- crates/ui-egui/tests/it/form_controls.rs | 77 +++++++++++++++++++++++- docs/ui-parity.md | 2 +- 4 files changed, 95 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/form_controls.rs b/crates/ui-egui/src/form_controls.rs index fb6d41c03..10b6388b2 100644 --- a/crates/ui-egui/src/form_controls.rs +++ b/crates/ui-egui/src/form_controls.rs @@ -306,8 +306,9 @@ pub fn interact(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, return true; } if !pointer.primary_pressed() || !resp.contains_pointer() { - // Any other press on the grid moves the focus off the control. - if pointer.any_pressed() && app.grid.focused_control.is_some() && !resp.contains_pointer() { + // Any other press, on the grid (a right-click on a cell) or off it, moves the focus off + // the control, so a later Enter or Space doesn't work it. + if pointer.any_pressed() && app.grid.focused_control.is_some() { app.grid.focused_control = None; } return false; @@ -356,6 +357,15 @@ fn step_part(app: &mut SheetApp, c: &FormControl, part: Part) { }; } +/// The grid takes a key while a control is focused: a button gives the keyboard back, so a later +/// Enter or Space doesn't press it again and Space starts a cell edit; a slider or spin button +/// keeps it, as the control last worked, which the arrows still step. +pub fn key_to_grid(app: &mut SheetApp, sh: &Sheet) { + if app.grid.focused_control.and_then(|id| find(sh, id)).is_some_and(|c| c.kind == ControlKind::Button) { + app.grid.focused_control = None; + } +} + /// Keys for the focused control: arrows move by a step, Page Up and Page Down by a page, Home and /// End to the ends; Space or Enter presses a button. True when the key was used. `repeat` is a key /// held down: it steps a slider or spin button again, but a button runs once per press. diff --git a/crates/ui-egui/src/grid/keyboard.rs b/crates/ui-egui/src/grid/keyboard.rs index 81f4b432f..f72badef6 100644 --- a/crates/ui-egui/src/grid/keyboard.rs +++ b/crates/ui-egui/src/grid/keyboard.rs @@ -166,12 +166,17 @@ fn grid_events(app: &mut SheetApp, ctx: &egui::Context, sh: &Sheet) -> Vec bool { match ev { egui::Event::Text(text) => text == " " && app.grid.focused_control.is_some() && app.selected_object.is_none(), egui::Event::Key { key, pressed: true, repeat, modifiers: m, .. } => { - !m.command && !m.ctrl && !m.alt && crate::form_controls::key(app, sh, *key, *repeat) + if !m.command && !m.ctrl && !m.alt && crate::form_controls::key(app, sh, *key, *repeat) { + return true; + } + crate::form_controls::key_to_grid(app, sh); + false } _ => false, } diff --git a/crates/ui-egui/tests/it/form_controls.rs b/crates/ui-egui/tests/it/form_controls.rs index ad13818a1..325ad1a29 100644 --- a/crates/ui-egui/tests/it/form_controls.rs +++ b/crates/ui-egui/tests/it/form_controls.rs @@ -90,11 +90,29 @@ fn click(h: &mut Harness, p: Pos2) { } fn key(h: &mut Harness, key: Key) { - h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); - h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + key_with(h, key, Modifiers::NONE); +} + +fn key_with(h: &mut Harness, key: Key, modifiers: Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); h.run_steps(2); } +/// A Space press as the system sends it: the key and the space it types. +fn space(h: &mut Harness) { + h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Text(" ".into())); + h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn active(h: &Harness) -> String { + h.state().session.doc().unwrap().selection.active.a1() +} + fn thumb(h: &Harness, id: u32) -> Rect { let c = control(h, id); slider_parts(rect(h, id), &c, 1.0).1 @@ -269,6 +287,61 @@ fn holding_enter_or_space_presses_a_focused_button_once() { } } +/// A button holds the keyboard only until the grid takes a key: after an arrow (or Cmd+arrow) +/// moves the cursor, Enter moves it again instead of pressing the button a second time, and +/// Space starts a cell edit. +#[test] +fn a_button_gives_the_keyboard_back_when_the_grid_takes_a_key() { + let (mut h, ids) = fixture(); + let r = rect(&h, ids.button); + click(&mut h, r.center()); + assert_eq!(cell(&h, "H1"), Value::text("pressed")); + let runs = undo_len(&h); + assert_eq!(active(&h), "A20"); + key(&mut h, Key::ArrowDown); + assert_eq!(active(&h), "A21", "the arrow moved the cursor"); + assert_eq!(h.state().grid.focused_control, None, "and took the keyboard from the button"); + key(&mut h, Key::Enter); + assert_eq!(undo_len(&h), runs, "Enter did not press the button again"); + assert_eq!(active(&h), "A22", "it moved the cursor"); + // Space after an arrow starts a cell edit. + click(&mut h, r.center()); + assert_eq!(undo_len(&h), runs + 1, "the click pressed it"); + key(&mut h, Key::ArrowDown); + space(&mut h); + assert!(h.state().editor.is_some(), "Space starts editing the cell"); + assert_eq!(undo_len(&h), runs + 1, "Space did not press the button"); + key(&mut h, Key::Escape); + assert!(h.state().editor.is_none()); + // A key with a modifier the grid takes (Cmd+arrow jumps to the edge) takes the keyboard too. + click(&mut h, r.center()); + assert_eq!(h.state().grid.focused_control, Some(ids.button)); + key_with(&mut h, Key::ArrowUp, Modifiers::COMMAND); + assert_eq!(h.state().grid.focused_control, None); + assert_eq!(active(&h), "A1"); +} + +/// A right-click on a cell takes the keyboard from a button, like any other click on the grid: +/// the Enter that follows does not press it again. +#[test] +fn a_right_click_on_a_cell_takes_the_keyboard_from_a_button() { + let (mut h, ids) = fixture(); + let r = rect(&h, ids.button); + click(&mut h, r.center()); + let runs = undo_len(&h); + assert_eq!(h.state().grid.focused_control, Some(ids.button)); + let on_a_cell = Pos2::new(r.right() + 300.0, r.bottom() + 100.0); + h.run_steps(30); + button(&mut h, on_a_cell, true, PointerButton::Secondary, Modifiers::NONE); + button(&mut h, on_a_cell, false, PointerButton::Secondary, Modifiers::NONE); + h.run_steps(2); + assert_eq!(h.state().selected_object, None, "a cell, not an object"); + assert_eq!(h.state().grid.focused_control, None, "the right-click took the keyboard"); + key(&mut h, Key::Enter); + assert_eq!(undo_len(&h), runs, "Enter did not press the button again"); + assert_eq!(cell(&h, "H1"), Value::text("pressed")); +} + #[test] fn a_button_from_a_file_asks_before_it_runs() { use egui_kittest::kittest::Queryable as _; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b4a0b7d9a..b3c467e5e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -69,7 +69,7 @@ this logic, with one undo step. | Copy as HTML table for other apps | done (#36, 10-10) | | Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Show Details on a PivotTable's value, subtotal or grand total cell (`pivot.showDetails`). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | | Move and resize charts, pictures and shapes | done (#190, #213): charts, pictures, shapes and slicers select with a frame of eight handles; each handle resizes only its own edges (they never cross), the body moves the object, and the pointer shows a resize or move cursor; Delete or Backspace deletes the selected object and Escape drops the selection; double-click edits a text box, opens Format Chart for a chart. Rotation handle, Shift for proportional resizing, Alt to snap to cells and multi-object selection missing | -| Form controls: buttons, scroll bars (sliders), spin buttons | done (10-31): Insert › Controls (Button, Slider, Spin Button; Excel has them under Developer › Insert) places one by a drag, or at its default size by a click; a click works a control: drag the thumb, page on the track, click or hold an arrow (a drag or a hold is one undo step), press a button, which runs a macro or a command and asks first in a workbook opened from a file; the control last worked takes the arrows, Page Up and Page Down, Home, End, and Space or Enter; Ctrl or Cmd+click or a right-click selects it to move or resize; its menu has Edit Text, Assign Action, Format Control and Delete. Check boxes, option buttons, list boxes and drop-downs (form controls) are kept from files but not shown | +| Form controls: buttons, scroll bars (sliders), spin buttons | done (10-31): Insert › Controls (Button, Slider, Spin Button; Excel has them under Developer › Insert) places one by a drag, or at its default size by a click; a click works a control: drag the thumb, page on the track, click or hold an arrow (a drag or a hold is one undo step), press a button, which runs a macro or a command and asks first in a workbook opened from a file; the control last worked takes the arrows, Page Up and Page Down, Home, End, and Space or Enter (a button gives the keyboard back as soon as the grid takes a key or a right-click); Ctrl or Cmd+click or a right-click selects it to move or resize; its menu has Edit Text, Assign Action, Format Control and Delete. Check boxes, option buttons, list boxes and drop-downs (form controls) are kept from files but not shown | | Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial | | Quick Analysis (Ctrl+Q) | missing | | Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); dragging the handle at the scrollbar's left edge gives the tabs more or less of the bar; each workbook keeps its share while it is open (not saved in the file) |