Files
gridcraft/crates/ui-egui/src/keymap.rs
T
Hector VillarrealandClaude Opus 5.5 8b2550c9cf #1442: Ctrl+. moves to the next corner of the selection
Excel's Next Corner of Selection (Ctrl+., Control+. on a Mac) moves the
active cell clockwise to the next corner of the selected area (top-left,
top-right, bottom-right, bottom-left) and keeps the area selected; the
new active cell is also the anchor, so Shift+arrows then move the
opposite corner. GridCraft had no such command.

New command selection.nextCorner (Selection::next_corner) bound to
Ctrl+. on every platform. A one-row or one-column area goes end to end;
a single cell stays where it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:44:15 -06:00

386 lines
17 KiB
Rust

//! Excel keyboard shortcuts beyond the plain Ctrl/Cmd letters in [`crate::ribbon::shortcut`]:
//! number-format and border combinations, F-keys, Alt/Option combinations and the Excel for Mac
//! variants. Every entry maps to an existing command or dialog. `docs/ui-parity.md` holds the
//! audit against Microsoft's published Windows and Mac shortcut lists.
use egui::{Key, Modifiers};
use gridcraft_engine::core::{CellRef, RangeRef};
use gridcraft_engine::model::Sheet;
use serde_json::{Value as Json, json};
use crate::SheetApp;
use crate::grid::Geo;
/// What a shortcut does.
#[derive(Clone, Debug, PartialEq)]
pub enum Shortcut {
/// Run a command with these parameters.
Run(&'static str, Json),
/// Open a dialog.
Dialog(&'static str, Json),
/// Start editing with the current date or time (Ctrl+; and Ctrl+Shift+;).
Date,
Time,
/// Ctrl+' / Ctrl+Shift+": the formula / value of the cell above, ready to edit.
FormulaAbove,
ValueAbove,
/// Extend the selection to A1, to the last used cell, or to the start of the row.
ExtendToStart,
ExtendToEnd,
ExtendToRowStart,
/// Alt+Page Down / Alt+Page Up: one screen right / left.
ScreenRight,
ScreenLeft,
/// Excel for Mac Control+U: edit the active cell (like F2).
Edit,
/// Shift+F10: the cell context menu.
ContextMenu,
/// Alt+Down: the active cell's drop-down list.
DropDown,
/// Ctrl+Alt+= / Ctrl+Alt+-: zoom in / out.
Zoom(i32),
/// Ctrl+Alt+Shift+P: edit the active cell as Python code (Python mode).
PythonMode,
}
fn run(id: &'static str) -> Shortcut {
Shortcut::Run(id, json!({}))
}
fn number_format(code: &str) -> Shortcut {
Shortcut::Run("home.numberFormat", json!({"code": code}))
}
fn borders(preset: &str) -> Shortcut {
Shortcut::Run("home.borders", json!({"preset": preset}))
}
/// The shortcut for a key press in the grid, if any. `physical` is the key's position on a US
/// layout (as Excel matches Ctrl+Shift+4 rather than the `$` character); `mac` selects the Excel
/// for Mac variants where they differ. Navigation keys go by the logical key: with Num Lock off
/// the numpad's arrows and Page Down report the digit keys (Num6, Num3) as their physical keys.
pub fn lookup(key: Key, physical: Option<Key>, m: Modifiers, mac: bool) -> Option<Shortcut> {
use Key::*;
let navigation = matches!(key, ArrowLeft | ArrowRight | ArrowUp | ArrowDown | PageUp | PageDown | Home | End | Insert | Delete);
let key = if navigation { key } else { physical.unwrap_or(key) };
let ctx = LookupContext {
key,
modifiers: m,
ctrl: m.ctrl && !m.alt,
cmd: m.command && !m.alt,
alt: m.alt && !m.ctrl && !m.command,
ctrl_alt: m.alt && (m.ctrl || m.command),
plain: !m.ctrl && !m.command && !m.alt,
shift: m.shift,
mac,
};
lookup_number_formats(ctx)
.or_else(|| lookup_control_shortcuts(ctx))
.or_else(|| lookup_windows_shortcuts(ctx))
.or_else(|| lookup_platform_letters(ctx))
.or_else(|| lookup_selection_shortcuts(ctx))
.or_else(|| lookup_control_alt_shortcuts(ctx))
.or_else(|| lookup_alt_shortcuts(ctx))
.or_else(|| lookup_function_shortcuts(ctx))
}
#[derive(Clone, Copy)]
struct LookupContext {
key: Key,
modifiers: Modifiers,
ctrl: bool,
cmd: bool,
alt: bool,
ctrl_alt: bool,
plain: bool,
shift: bool,
mac: bool,
}
fn lookup_number_formats(ctx: LookupContext) -> Option<Shortcut> {
use Key::*;
let LookupContext { key: k, ctrl, cmd, alt, ctrl_alt, plain, shift: s, mac, .. } = ctx;
let _ = (ctrl, cmd, alt, ctrl_alt, plain, s, mac);
let m = ctx.modifiers;
let _ = m;
Some(match k {
// Ctrl+Shift+~ ! @ # $ % ^ & _ : number formats and borders (Windows and Mac).
Backtick if ctrl && s => Shortcut::Run("home.numberFormat", json!({"format": "General"})),
Num1 if ctrl && s => number_format("#,##0.00"),
Num2 if ctrl && s => number_format("h:mm AM/PM"),
Num3 if ctrl && s => number_format("d-mmm-yy"),
Num4 if ctrl && s => number_format("$#,##0.00_);($#,##0.00)"),
Num5 if ctrl && s => number_format("0%"),
Num6 if ctrl && s => number_format("0.00E+00"),
Num7 if ctrl && s => borders("outside"),
Minus if ctrl && s => borders("none"),
Num8 if ctrl && s => run("selection.currentRegion"),
// Ctrl+. (Control on a Mac too): Next Corner of Selection.
Period if ctrl && !s => run("selection.nextCorner"),
Semicolon if ctrl && s => Shortcut::Time,
Quote if ctrl && s => Shortcut::ValueAbove,
Quote if ctrl => Shortcut::FormulaAbove,
// Ctrl+[ / Ctrl+] (Excel): select precedent / dependent cells; Shift walks every level.
OpenBracket if ctrl && !s => run("formulas.selectPrecedents"),
OpenBracket if ctrl => Shortcut::Run("formulas.selectPrecedents", json!({"all": true})),
CloseBracket if ctrl && !s => run("formulas.selectDependents"),
CloseBracket if ctrl => Shortcut::Run("formulas.selectDependents", json!({"all": true})),
_ => return None,
})
}
fn lookup_control_shortcuts(ctx: LookupContext) -> Option<Shortcut> {
use Key::*;
let LookupContext { key: k, ctrl, cmd, alt, ctrl_alt, plain, shift: s, mac, .. } = ctx;
let _ = (ctrl, cmd, alt, ctrl_alt, plain, s, mac);
let m = ctx.modifiers;
let _ = m;
Some(match k {
// Windows Ctrl+; is in `ribbon::shortcut`; Excel for Mac uses Control+; and Command+;.
Semicolon if mac && ctrl => Shortcut::Date,
Semicolon if mac && cmd && !s => Shortcut::Time,
_ => return None,
})
}
fn lookup_windows_shortcuts(ctx: LookupContext) -> Option<Shortcut> {
use Key::*;
let LookupContext { key: k, ctrl, cmd, alt, ctrl_alt, plain, shift: s, mac, .. } = ctx;
let _ = (ctrl, cmd, alt, ctrl_alt, plain, s, mac);
let m = ctx.modifiers;
let _ = m;
Some(match k {
// Ctrl+2..5 (Windows).
Num2 if ctrl && !mac => run("home.bold"),
Num3 if ctrl && !mac => run("home.italic"),
Num4 if ctrl && !mac => run("home.underline"),
Num5 if ctrl && !mac => run("home.strikethrough"),
// Ctrl+6 hides and shows objects (Windows, and Control+6 in Excel for Mac).
Num6 if ctrl && !s => run("view.showObjects"),
_ => return None,
})
}
fn lookup_platform_letters(ctx: LookupContext) -> Option<Shortcut> {
use Key::*;
let LookupContext { key: k, ctrl, cmd, alt, ctrl_alt, plain, shift: s, mac, .. } = ctx;
let _ = (ctrl, cmd, alt, ctrl_alt, plain, s, mac);
let m = ctx.modifiers;
let _ = m;
Some(match k {
// Letters that differ between Windows and Mac.
L if (cmd || ctrl) && s => run("data.filter"),
L if cmd && mac => run("home.alignLeft"),
L if ctrl && mac => Shortcut::Dialog("defineName", json!({})),
// Ctrl+Shift+U (Control+Shift+U in Excel for Mac) expands or collapses the formula bar.
U if ctrl && s => run("view.expandFormulaBar"),
U if ctrl && mac && !s => Shortcut::Edit,
L if cmd => run("insert.table"),
E if cmd && mac && !s => run("home.alignCenter"),
E if ctrl && mac && !s => run("edit.flashFill"),
F if cmd && s && !mac => Shortcut::Dialog("formatCells", json!({"tab": "Font"})),
G if cmd && s && !mac => Shortcut::Dialog("statistics", json!({})),
H if cmd && s && mac => Shortcut::Dialog("find", json!({"replace": true})),
R if cmd && s && mac => Shortcut::Dialog("sort", json!({})),
K if cmd && s && mac => run("data.group"),
J if cmd && s && mac => run("data.ungroup"),
P if cmd && !s => run("file.print"),
Period if cmd && s => run("home.increaseFontSize"),
Comma if cmd && s => run("home.decreaseFontSize"),
Space if cmd && s => run("edit.selectAll"),
_ => return None,
})
}
fn lookup_selection_shortcuts(ctx: LookupContext) -> Option<Shortcut> {
use Key::*;
let LookupContext { key: k, ctrl, cmd, alt, ctrl_alt, plain, shift: s, mac, .. } = ctx;
let _ = (ctrl, cmd, alt, ctrl_alt, plain, s, mac);
let m = ctx.modifiers;
let _ = m;
Some(match k {
// Shift+Space selects the row; Ctrl+Space (Windows) / Control+Space (Excel for Mac) the
// column. Cmd+Space stays free: it is the macOS Spotlight shortcut.
Space if plain && s => run("selection.row"),
Space if !mac && cmd && !s => run("selection.column"),
Space if mac && ctrl && !m.command && !s => run("selection.column"),
Home if cmd && s => Shortcut::ExtendToStart,
End if cmd && s => Shortcut::ExtendToEnd,
Home if plain && s => Shortcut::ExtendToRowStart,
_ => return None,
})
}
fn lookup_control_alt_shortcuts(ctx: LookupContext) -> Option<Shortcut> {
use Key::*;
let LookupContext { key: k, ctrl, cmd, alt, ctrl_alt, plain, shift: s, mac, .. } = ctx;
let _ = (ctrl, cmd, alt, ctrl_alt, plain, s, mac);
let m = ctx.modifiers;
let _ = m;
Some(match k {
// Ctrl+Alt / Cmd+Option combinations.
V if ctrl_alt || (mac && m.ctrl && m.command) => Shortcut::Dialog("pasteSpecial", json!({})),
Equals | Plus if ctrl_alt => Shortcut::Zoom(10),
Minus if ctrl_alt && !mac => Shortcut::Zoom(-10),
F9 if ctrl_alt => run("formulas.calculateNow"),
// Python in cells (Cmd+Option on macOS too).
P if ctrl_alt && s => Shortcut::PythonMode,
R if ctrl_alt && mac => run("view.collapseRibbon"),
Num0 if ctrl_alt && mac => borders("outside"),
Minus if ctrl_alt && mac => borders("none"),
ArrowLeft if ctrl_alt && mac => borders("left"),
ArrowRight if ctrl_alt && mac => borders("right"),
ArrowUp if ctrl_alt && mac => borders("top"),
ArrowDown if ctrl_alt && mac => borders("bottom"),
_ => return None,
})
}
fn lookup_alt_shortcuts(ctx: LookupContext) -> Option<Shortcut> {
use Key::*;
let LookupContext { key: k, ctrl, cmd, alt, ctrl_alt, plain, shift: s, mac, .. } = ctx;
let _ = (ctrl, cmd, alt, ctrl_alt, plain, s, mac);
let m = ctx.modifiers;
let _ = m;
Some(match k {
// Alt / Option alone.
Equals if alt && !s && !mac => run("formulas.autoSum"),
ArrowDown if alt && !s => Shortcut::DropDown,
PageDown if alt => Shortcut::ScreenRight,
PageUp if alt => Shortcut::ScreenLeft,
ArrowRight if alt && s && !mac => run("data.group"),
ArrowLeft if alt && s && !mac => run("data.ungroup"),
ArrowRight if alt && !s && mac => run("sheet.next"),
ArrowLeft if alt && !s && mac => run("sheet.previous"),
F1 if alt && s => run("home.insertSheet"),
F1 if alt => run("insert.chart"),
F10 if alt && s => Shortcut::Dialog("errorChecking", json!({})),
_ => return None,
})
}
fn lookup_function_shortcuts(ctx: LookupContext) -> Option<Shortcut> {
use Key::*;
let LookupContext { key: k, ctrl, cmd, alt, ctrl_alt, plain, shift: s, mac, .. } = ctx;
let _ = (ctrl, cmd, alt, ctrl_alt, plain, s, mac);
let m = ctx.modifiers;
let _ = m;
Some(match k {
// Function keys.
F1 if cmd && !s => run("view.collapseRibbon"),
F2 if (cmd || ctrl) && s => Shortcut::Dialog("comment", json!({"threaded": true})),
F2 if plain && s => Shortcut::Dialog("comment", json!({"threaded": false})),
F3 if cmd && s => run("formulas.createFromSelection"),
F3 if cmd && mac => Shortcut::Dialog("defineName", json!({})),
F3 if cmd => Shortcut::Dialog("nameManager", json!({})),
F3 if plain && s => Shortcut::Dialog("insertFunction", json!({})),
F3 if plain => Shortcut::Dialog("pasteName", json!({"shortcut": true})),
F4 if cmd && !s => run("file.close"),
F5 if plain && s => Shortcut::Dialog("find", json!({})),
F5 if plain => Shortcut::Dialog("goTo", json!({})),
F7 if plain && !s => Shortcut::Dialog("spelling", json!({})),
F9 if plain && s => run("formulas.calculateSheet"),
F10 if plain && s => Shortcut::ContextMenu,
F11 if plain && s => run("home.insertSheet"),
F12 if plain && !s => run("file.saveAs"),
_ => return None,
})
}
/// AltGr arrives as Ctrl+Alt on Windows: a key that also typed a character (AltGr+= is `~` on a German
/// layout; a user-defined Ctrl+Alt+A hotkey may type `ä`) is text, not a shortcut.
pub(crate) fn altgr_text(ctx: &egui::Context, m: Modifiers, mac: bool) -> bool {
!mac && m.alt && m.ctrl && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(_))))
}
/// Handles a key press in the grid. Returns whether it was a shortcut.
pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physical: Option<Key>, m: Modifiers, geo: &Geo, sh: &Sheet) -> bool {
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
if altgr_text(ctx, m, mac) {
return false;
}
let Some(shortcut) = lookup(key, physical, m, mac) else { return false };
let (active, anchor) = app.session.active().map(|d| (d.selection.active, d.selection.anchor)).unwrap_or_default();
match shortcut {
Shortcut::Run(id, params) => {
app.run_or_alert(id, params);
app.grid.ensure_visible = true;
}
Shortcut::Dialog(name, params) => app.open_dialog(name, params),
Shortcut::Date | Shortcut::Time => {
let text = app
.session
.active()
.map(|d| {
let now = gridcraft_engine::core::date::serial_in_system(d.wb.date_system, gridcraft_engine::calc::now_serial());
let (value, format) = if shortcut == Shortcut::Date { (now.floor(), "m/d/yyyy") } else { (now.fract(), "h:mm AM/PM") };
gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(value), format, &d.wb).text
})
.unwrap_or_default();
edit_with(app, text);
}
Shortcut::FormulaAbove | Shortcut::ValueAbove => {
let Some(above) = active.row.checked_sub(1).map(|r| CellRef::new(r, active.col)) else { return true };
let text = if shortcut == Shortcut::FormulaAbove {
app.session.active().map(|d| gridcraft_engine::display::edit_text(&d.wb, sh, above)).unwrap_or_default()
} else {
app.session
.active()
.and_then(|d| sh.value_ref(above).map(|v| gridcraft_engine::display::format(v, "General", &d.wb).text))
.unwrap_or_default()
};
edit_with(app, text);
}
Shortcut::ExtendToStart => extend(app, anchor, active, CellRef::new(0, 0)),
Shortcut::ExtendToEnd => {
let end = sh.used_range().map(|u| u.end).unwrap_or_default();
extend(app, anchor, active, end);
}
Shortcut::ExtendToRowStart => extend(app, anchor, active, CellRef::new(active.row, 0)),
Shortcut::ScreenRight | Shortcut::ScreenLeft => {
let width = ((geo.cells.width() - geo.frozen_w) / geo.z).max(1.0);
let cols = (width / sh.default_col_width.max(1.0)).floor().max(1.0) as i64;
let dc = if shortcut == Shortcut::ScreenRight { cols } else { -cols };
let from = app.session.active().map(|d| if m.shift { d.selection.moving_end() } else { d.selection.active });
let _ = app.session.run("selection.move", json!({"dr": 0, "dc": dc, "extend": m.shift}));
app.grid.ensure_visible = true;
app.grid.reveal_moving_end = m.shift;
app.grid.reveal_from = from;
}
Shortcut::Edit => app.begin_edit(None, false),
Shortcut::PythonMode => app.toggle_python_mode(),
Shortcut::ContextMenu => app.grid.context_menu = Some(geo.cell_rect(sh, active).center()),
Shortcut::DropDown => {
if sh.validations.iter().any(|d| d.in_cell_dropdown && d.ranges.iter().any(|r| r.contains(active))) {
app.grid.list_picker = Some(active);
} else if sh.has_filter_button(active) {
let at = geo.mirror_pos(crate::grid::filter_button_rect(geo, sh, active).center());
app.grid.filter_menu = Some((active.col, active.row, at));
}
}
Shortcut::Zoom(step) => {
let percent = (i32::from(sh.zoom).saturating_add(step)).clamp(10, 400);
app.run_or_alert("view.zoom", json!({"percent": percent}));
}
}
true
}
/// Starts editing the active cell with `text` and the caret at its end.
fn edit_with(app: &mut SheetApp, text: String) {
app.begin_edit(Some(text), false);
if let Some(ed) = app.editor.as_mut() {
ed.caret = ed.text.chars().count();
}
}
/// Selects `anchor`..`to`, keeping the anchor and the active cell.
fn extend(app: &mut SheetApp, anchor: CellRef, active: CellRef, to: CellRef) {
let _ = app.session.run("selection.set", json!({"range": RangeRef::new(anchor, to).a1(), "active": active.a1()}));
if let Some(d) = app.session.active_mut() {
d.selection.anchor = anchor;
}
app.grid.ensure_visible = true;
app.grid.reveal_moving_end = true;
}