mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 02:36:48 -06:00
* CI: dev builds keep line tables only * CI: one integration-test binary per crate * CI: xtask runs tests with nextest, doctests apart, --keep-going on clippy and wasm cargo xtask test runs the workspace tests (--lib --bins --tests) with cargo-nextest when it is installed, else cargo test, then the doctests. cargo xtask ci uses the same two steps. * CI: docs name cargo xtask test * CI: agent guides name the moved fuzz test * CI: tests added on main join their crate's test binary
309 lines
15 KiB
Rust
309 lines
15 KiB
Rust
//! Excel keyboard shortcuts (crate::keymap) dispatched through real egui key events.
|
|
|
|
use egui::{Event, Key, Modifiers};
|
|
use gridcraft_engine::Session;
|
|
use gridcraft_engine::core::CellRef;
|
|
use gridcraft_engine::model::BorderStyle;
|
|
use gridcraft_ui_egui::SheetApp;
|
|
use gridcraft_ui_egui::keymap::{Shortcut, lookup};
|
|
use serde_json::json;
|
|
|
|
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
|
|
|
fn harness(session: Session) -> Harness {
|
|
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
|
|ui, app: &mut SheetApp| {
|
|
let ctx = ui.ctx().clone();
|
|
app.logic(&ctx);
|
|
app.ui(ui);
|
|
},
|
|
SheetApp::new(session, Default::default()),
|
|
);
|
|
h.run_steps(4);
|
|
h
|
|
}
|
|
|
|
fn book(selected: &str) -> Session {
|
|
let mut s = Session::new();
|
|
s.new_workbook();
|
|
s.execute("range.setValues", json!({"range": "A1", "values": [[1.5, 2], [3, 4], [5, 6]]})).unwrap();
|
|
s.execute("cell.set", json!({"cell": "C1", "input": "=A1*2"})).unwrap();
|
|
s.execute("selection.set", json!({"range": selected})).unwrap();
|
|
s
|
|
}
|
|
|
|
/// A key press as a US keyboard reports it (the physical key is the unshifted one).
|
|
fn key(h: &mut Harness, k: Key, m: Modifiers) {
|
|
h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: m });
|
|
h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: m });
|
|
h.run_steps(2);
|
|
}
|
|
|
|
fn style(h: &Harness, a1: &str) -> gridcraft_engine::model::Style {
|
|
let d = h.state().session.active().unwrap();
|
|
let sh = d.wb.active().unwrap();
|
|
d.wb.styles.get(sh.style_id(CellRef::parse(a1).unwrap())).clone()
|
|
}
|
|
|
|
/// Ctrl on Windows and Linux sets both `ctrl` and `command`.
|
|
const CTRL: Modifiers = Modifiers { alt: false, ctrl: true, shift: false, mac_cmd: false, command: true };
|
|
const CTRL_SHIFT: Modifiers = Modifiers { shift: true, ..CTRL };
|
|
|
|
#[test]
|
|
fn ctrl_shift_symbols_apply_number_formats_and_borders() {
|
|
let mut h = harness(book("A1:B2"));
|
|
key(&mut h, Key::Num4, CTRL_SHIFT);
|
|
assert_eq!(style(&h, "B2").num_fmt.as_str(), "$#,##0.00_);($#,##0.00)");
|
|
key(&mut h, Key::Num5, CTRL_SHIFT);
|
|
assert_eq!(style(&h, "A1").num_fmt.as_str(), "0%");
|
|
key(&mut h, Key::Backtick, CTRL_SHIFT);
|
|
assert_eq!(style(&h, "A1").num_fmt.as_str(), "General");
|
|
key(&mut h, Key::Num7, CTRL_SHIFT);
|
|
assert!(style(&h, "A1").borders.top.style != BorderStyle::None && style(&h, "B2").borders.right.style != BorderStyle::None);
|
|
key(&mut h, Key::Minus, CTRL_SHIFT);
|
|
assert!(style(&h, "A1").borders.top.style == BorderStyle::None);
|
|
// Ctrl+Shift+End extends from the anchor to the last used cell.
|
|
key(&mut h, Key::End, CTRL_SHIFT);
|
|
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "A1:C3");
|
|
}
|
|
|
|
#[test]
|
|
fn copy_formula_from_above_and_function_keys() {
|
|
let mut h = harness(book("C2"));
|
|
key(&mut h, Key::Quote, CTRL);
|
|
assert_eq!(h.state().editor.as_ref().map(|e| e.text.clone()).as_deref(), Some("=A1*2"));
|
|
key(&mut h, Key::Escape, Modifiers::NONE);
|
|
assert!(h.state().editor.is_none());
|
|
let sheets = h.state().session.active().unwrap().wb.sheets.len();
|
|
key(&mut h, Key::F11, Modifiers::SHIFT);
|
|
assert_eq!(h.state().session.active().unwrap().wb.sheets.len(), sheets + 1);
|
|
// Back on the data: Alt+F1 charts the current region, Ctrl+Alt+= zooms in.
|
|
h.state_mut().session.run("sheet.previous", json!({})).unwrap();
|
|
h.state_mut().session.run("selection.set", json!({"range": "A1:B3"})).unwrap();
|
|
key(&mut h, Key::F1, Modifiers::ALT);
|
|
assert_eq!(h.state().session.active().unwrap().wb.sheets[0].charts.len(), 1);
|
|
key(&mut h, Key::Equals, Modifiers { alt: true, ..CTRL });
|
|
assert_eq!(h.state().session.active().unwrap().wb.sheets[0].zoom, 110);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_and_mac_variants() {
|
|
let cmd = CTRL;
|
|
let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
|
|
let ctrl = Modifiers::CTRL;
|
|
let run = |id: &'static str| Some(Shortcut::Run(id, json!({})));
|
|
assert_eq!(lookup(Key::L, Some(Key::L), cmd, false), run("insert.table"));
|
|
assert_eq!(lookup(Key::L, Some(Key::L), mac_cmd, true), run("home.alignLeft"));
|
|
assert_eq!(lookup(Key::E, Some(Key::E), mac_cmd, true), run("home.alignCenter"));
|
|
assert_eq!(lookup(Key::E, Some(Key::E), ctrl, true), run("edit.flashFill"));
|
|
assert_eq!(lookup(Key::Equals, Some(Key::Equals), Modifiers::ALT, false), run("formulas.autoSum"));
|
|
// The shifted character doesn't matter: Ctrl+Shift+1 is the Number format wherever `!` is.
|
|
assert_eq!(
|
|
lookup(Key::Exclamationmark, Some(Key::Num1), CTRL_SHIFT, false),
|
|
Some(Shortcut::Run("home.numberFormat", json!({"code": "#,##0.00"})))
|
|
);
|
|
// Navigation keys go by what they are, not where: the numpad's arrows and Page Down (Num
|
|
// Lock off) report Num6 / Num3 as their physical keys.
|
|
let alt_shift = Modifiers { alt: true, shift: true, ..Modifiers::NONE };
|
|
assert_eq!(lookup(Key::ArrowRight, Some(Key::Num6), alt_shift, false), run("data.group"));
|
|
assert_eq!(lookup(Key::ArrowLeft, Some(Key::Num4), alt_shift, false), run("data.ungroup"));
|
|
assert_eq!(lookup(Key::PageDown, Some(Key::Num3), Modifiers::ALT, false), Some(Shortcut::ScreenRight));
|
|
assert_eq!(lookup(Key::PageDown, Some(Key::Num3), CTRL_SHIFT, false), None);
|
|
// Plain keys stay with the grid (typing, navigation).
|
|
assert_eq!(lookup(Key::A, Some(Key::A), Modifiers::NONE, false), None);
|
|
assert_eq!(lookup(Key::F2, Some(Key::F2), Modifiers::NONE, false), None);
|
|
}
|
|
|
|
#[test]
|
|
fn space_selection_shortcuts_do_not_start_an_edit() {
|
|
let mut h = harness(book("B2"));
|
|
// Shift+Space: the key press, then the space the OS types with it.
|
|
let shift = Modifiers::SHIFT;
|
|
h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: Some(Key::Space), pressed: true, repeat: false, modifiers: shift });
|
|
h.input_mut().events.push(Event::Text(" ".into()));
|
|
h.run_steps(2);
|
|
assert!(h.state().editor.is_none(), "Shift+Space started an edit");
|
|
assert_eq!(h.state().session.active().unwrap().selection.ranges[0].a1(), "2:2");
|
|
h.state_mut().session.run("selection.set", json!({"range": "B2"})).unwrap();
|
|
// Ctrl+Space selects the column, also without starting an edit.
|
|
h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: Some(Key::Space), pressed: true, repeat: false, modifiers: CTRL });
|
|
h.input_mut().events.push(Event::Text(" ".into()));
|
|
h.run_steps(2);
|
|
assert!(h.state().editor.is_none());
|
|
assert_eq!(h.state().session.active().unwrap().selection.ranges[0].a1(), "B:B");
|
|
// Plain typing still edits.
|
|
h.input_mut().events.push(Event::Text("x".into()));
|
|
h.run_steps(2);
|
|
assert!(h.state().editor.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn space_shortcuts_per_platform() {
|
|
let run = |id: &'static str| Some(Shortcut::Run(id, json!({})));
|
|
let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
|
|
let space = |m, mac| lookup(Key::Space, Some(Key::Space), m, mac);
|
|
assert_eq!(space(Modifiers::SHIFT, false), run("selection.row"));
|
|
assert_eq!(space(Modifiers::SHIFT, true), run("selection.row"));
|
|
assert_eq!(space(CTRL, false), run("selection.column"));
|
|
assert_eq!(space(Modifiers::CTRL, true), run("selection.column"));
|
|
// Cmd+Space is Spotlight on macOS.
|
|
assert_eq!(space(mac_cmd, true), None);
|
|
}
|
|
|
|
#[test]
|
|
fn alt_down_opens_autofilter_headers_without_changing_the_workbook() {
|
|
for rtl in [false, true] {
|
|
for cell in ["A1", "B1"] {
|
|
let mut session = book(cell);
|
|
session.execute("data.filter", json!({"range": "A1:B3"})).unwrap();
|
|
session.execute("view.rightToLeft", json!({"on": rtl})).unwrap();
|
|
let mut h = harness(session);
|
|
let before = h.state().session.active().unwrap().wb.clone();
|
|
key(&mut h, Key::ArrowDown, Modifiers::ALT);
|
|
let (column, _row, at) = h.state().grid.filter_menu.expect("Alt+Down must open the filter header menu");
|
|
let d = h.state().session.active().unwrap();
|
|
let active = CellRef::parse(cell).unwrap();
|
|
assert_eq!(column, active.col);
|
|
assert_eq!(d.selection.active, active);
|
|
assert_eq!(d.wb, before);
|
|
let sh = d.wb.active().unwrap();
|
|
let geo = gridcraft_ui_egui::grid::Geo::new(sh, h.state().grid.rect.unwrap(), h.state().view().scroll);
|
|
assert_eq!(at, geo.mirror_pos(gridcraft_ui_egui::grid::filter_button_rect(&geo, sh, active).center()));
|
|
// Opening focuses the existing search box; typing must not edit the header.
|
|
h.input_mut().events.push(Event::Text("1".into()));
|
|
h.run_steps(2);
|
|
assert_eq!(h.ctx.data(|data| data.get_temp::<String>(egui::Id::new(("filter_search", column)))).as_deref(), Some("1"));
|
|
assert!(h.state().editor.is_none());
|
|
assert_eq!(h.state().session.active().unwrap().wb, before);
|
|
key(&mut h, Key::Escape, Modifiers::NONE);
|
|
assert!(h.state().grid.filter_menu.is_none());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn alt_down_keeps_validation_priority_and_plain_down_navigation() {
|
|
let mut session = book("A1");
|
|
session.execute("data.filter", json!({"range": "A1:B3"})).unwrap();
|
|
session.execute("data.validation", json!({"range": "A1", "type": "list", "formula1": "\"First,Second\""})).unwrap();
|
|
let mut h = harness(session);
|
|
key(&mut h, Key::ArrowDown, Modifiers::ALT);
|
|
assert_eq!(h.state().grid.list_picker, Some(CellRef::parse("A1").unwrap()));
|
|
assert!(h.state().grid.filter_menu.is_none());
|
|
key(&mut h, Key::Escape, Modifiers::NONE);
|
|
key(&mut h, Key::ArrowDown, Modifiers::NONE);
|
|
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A2");
|
|
assert!(h.state().grid.filter_menu.is_none());
|
|
key(&mut h, Key::ArrowDown, Modifiers::ALT);
|
|
assert!(h.state().grid.filter_menu.is_none(), "a data cell has no filter button");
|
|
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A2");
|
|
}
|
|
|
|
#[test]
|
|
fn alt_down_only_opens_enabled_table_filter_buttons() {
|
|
for enabled in [false, true] {
|
|
let mut session = book("B1");
|
|
session.execute("insert.table", json!({"range": "A1:B3"})).unwrap();
|
|
if !enabled {
|
|
session.execute("table.headerRow", json!({"on": false})).unwrap();
|
|
}
|
|
session.execute("selection.set", json!({"cell": "B1"})).unwrap();
|
|
let mut h = harness(session);
|
|
key(&mut h, Key::ArrowDown, Modifiers::ALT);
|
|
assert_eq!(h.state().grid.filter_menu.map(|(column, _, _)| column), enabled.then_some(1));
|
|
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "B1");
|
|
}
|
|
}
|
|
|
|
/// #1443: Ctrl+Q (also with Shift or Alt, which is what AltGr+Q reports on Windows) must not
|
|
/// close the window and lose unsaved input; macOS keeps Cmd+Q as the app's Quit.
|
|
#[test]
|
|
fn ctrl_q_does_not_close_the_window_outside_macos() {
|
|
let closes = |h: &mut Harness, k: Key, m: Modifiers| -> bool {
|
|
h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: m });
|
|
h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: m });
|
|
let mut closed = false;
|
|
for _ in 0..3 {
|
|
h.step();
|
|
closed |= h.output().viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Close)));
|
|
}
|
|
closed
|
|
};
|
|
let ctrl_alt = Modifiers { alt: true, ..CTRL };
|
|
for os in [egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] {
|
|
let mut h = harness(book("A1"));
|
|
h.ctx.set_os(os);
|
|
h.run_steps(2);
|
|
for (name, m) in [("Ctrl+Q", CTRL), ("Ctrl+Shift+Q", CTRL_SHIFT), ("Ctrl+Alt+Q", ctrl_alt)] {
|
|
assert!(!closes(&mut h, Key::Q, m), "{os:?}: {name} closed the window");
|
|
}
|
|
assert!(h.ctx.options(|o| o.quit_shortcuts.is_empty()), "{os:?}: no quit shortcut left");
|
|
}
|
|
// macOS: Cmd+Q stays the app's Quit.
|
|
let mut h = harness(book("A1"));
|
|
// The harness's first frame already ran as the host OS; restore egui's default, as a real
|
|
// macOS launch would have it.
|
|
h.ctx.options_mut(|o| o.quit_shortcuts = vec![egui::KeyboardShortcut::new(Modifiers::COMMAND, Key::Q)]);
|
|
h.ctx.set_os(egui::os::OperatingSystem::Mac);
|
|
h.run_steps(2);
|
|
assert!(h.ctx.options(|o| !o.quit_shortcuts.is_empty()), "Cmd+Q is still egui's quit shortcut on macOS");
|
|
}
|
|
|
|
#[test]
|
|
fn ctrl_6_is_show_objects_and_nothing_else() {
|
|
let show = Some(Shortcut::Run("view.showObjects", json!({})));
|
|
let mac_control = Modifiers::CTRL;
|
|
assert_eq!(lookup(Key::Num6, Some(Key::Num6), CTRL, false), show);
|
|
assert_eq!(lookup(Key::Num6, Some(Key::Num6), mac_control, true), show);
|
|
// Its neighbours keep theirs: Ctrl+Shift+6 (Ctrl+^) is Scientific, Cmd+6 on a Mac is free.
|
|
assert_eq!(lookup(Key::Num6, Some(Key::Num6), CTRL_SHIFT, false), Some(Shortcut::Run("home.numberFormat", json!({"code": "0.00E+00"}))));
|
|
assert_eq!(lookup(Key::Num6, Some(Key::Num6), Modifiers::MAC_CMD, true), None);
|
|
// No other command claims Ctrl+6.
|
|
let claims: Vec<&str> = Session::new().commands().iter().filter(|c| c.shortcut == Some("Ctrl+6")).map(|c| c.id).collect();
|
|
assert_eq!(claims, ["view.showObjects"]);
|
|
}
|
|
|
|
/// Alt+= as Windows and Linux report it: the key press, then the `=` the OS types with it.
|
|
fn alt_equals(h: &mut Harness) {
|
|
h.input_mut().events.push(Event::Key {
|
|
key: Key::Equals,
|
|
physical_key: Some(Key::Equals),
|
|
pressed: true,
|
|
repeat: false,
|
|
modifiers: Modifiers::ALT,
|
|
});
|
|
h.input_mut().events.push(Event::Text("=".into()));
|
|
h.run_steps(2);
|
|
}
|
|
|
|
#[test]
|
|
fn alt_equals_autosum_does_not_also_type_the_equals_sign() {
|
|
let input = |h: &Harness, a1: &str| h.state().session.active().unwrap().wb.active().unwrap().input_text(CellRef::parse(a1).unwrap());
|
|
// Column above, row to the left, and a block (totals below each column).
|
|
for (selected, cell, formula) in [("A4", "A4", "=SUM(A1:A3)"), ("C3", "C3", "=SUM(A3:B3)"), ("A1:B3", "B4", "=SUM(B1:B3)")] {
|
|
let mut h = harness(book(selected));
|
|
alt_equals(&mut h);
|
|
assert_eq!(input(&h, cell), formula, "Alt+= on {selected}");
|
|
assert!(h.state().editor.is_none(), "Alt+= on {selected} started an edit");
|
|
// Enter moves on: there is no stray `=` edit to commit.
|
|
key(&mut h, Key::Enter, Modifiers::NONE);
|
|
assert!(h.state().message.is_none(), "Alt+= on {selected}: {:?}", h.state().message);
|
|
assert_eq!(input(&h, cell), formula);
|
|
}
|
|
// Nothing to sum: AutoSum's own edit, `=SUM()`, not `=`.
|
|
let mut h = harness(book("E10"));
|
|
alt_equals(&mut h);
|
|
assert_eq!(h.state().editor.as_ref().map(|e| e.text.clone()).as_deref(), Some("=SUM()"));
|
|
}
|
|
|
|
#[test]
|
|
fn ctrl_alt_letter_that_typed_text_is_not_select_all() {
|
|
let mut h = harness(book("B2"));
|
|
let ctrl_alt = Modifiers { alt: true, ..CTRL };
|
|
h.input_mut().events.push(Event::Key { key: Key::A, physical_key: Some(Key::A), pressed: true, repeat: false, modifiers: ctrl_alt });
|
|
h.input_mut().events.push(Event::Text("\u{e4}".into()));
|
|
h.run_steps(2);
|
|
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "B2");
|
|
assert_eq!(h.state().editor.as_ref().unwrap().text, "\u{e4}");
|
|
}
|