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) |