Files
gridcraft/crates/ui-egui/src/keymap.rs
T
prxnvt 9ac06dd8cc UI: Python mode in the formula bar
Python cells get an editing mode of their own in the formula bar, in the
code editor (monospace, Python colours). It starts with Ctrl+Alt+Shift+P
(Cmd+Option+Shift+P on macOS), with =PY then Tab as in Excel, or by
editing a Python cell (F2, a double-click, a click on the formula bar);
typing over a Python cell still replaces it. The shortcut again leaves
Python mode and keeps the text as an ordinary entry.

In Python mode Enter starts a new line with the indentation, Tab and
Shift+Tab indent, Ctrl+Enter (Cmd+Enter on macOS) commits the code
through python.set and Esc cancels. Every commit path of a Python edit
goes through python.set, the formula bar's Enter button and a click on
the sheet included. When the command refuses the code (longer than a
cell holds), its message shows and the edit stays open with the code as
typed. A locked cell on a protected sheet is refused before a draft
exists, and a draft whose cell protection locks while it is open is
dropped with the warning, as an ordinary edit is (#2945). A Python cell's code committed unchanged writes
nothing (no undo step), as F2 then Enter on any other cell does. With
all the code deleted, the commit clears a Python cell through cell.set,
as an empty entry clears any other cell (code of spaces is still code);
on another cell it writes nothing. The formula bar shows while a Python
edit is open, also when View > Formula Bar hides it, since Python mode
edits only there. A Python draft records the window it was typed in,
as an ordinary draft does, so it is committed in that window when
another comes forward.

Python mode replaces the route that sent a changed edit of a Python
cell's code through python.set (EditState.code_edit and
commit_code_edit): every such edit now starts in Python mode, so that
route is removed, and leaving Python mode commits the text as an
ordinary entry through cell.set. The UI tests of that route commit with
Ctrl+Enter, since Enter starts a new line in Python mode; the guard that
keeps a column completion out of a code edit now reads Python mode.

The keys are applied in input order with the typing, on main's
apply_in_order: the code editor takes a set of stop keys from its caller
(apply_in_order_until), so Ctrl+Enter, Esc and the shortcut act on the
text as it was when they were pressed. After a key the text field acts
on (select all), a stop key waits for the next pass with the editor's
keys before it, so select all, Tab and Ctrl+Enter in one frame commits
the indented code. Keyboard events after a stop key in the same pass are
dropped. code_field is the editor without its frame, for the bar.

The PY badge beside fx shows in Python mode and on a selected Python
cell; clicking it switches Python mode as the shortcut does.
ui.pythonMode {on?} does the same from the control channel, without a
dialog, and engine.commands lists it. ui.inspect reports editor.python,
and leaves a Python draft's code out (text null, hidden true) while its
cell's code is hidden on a protected sheet, checked when it is called:
a protection command earlier in the same batch runs before the next
frame. Theme tokens badge_py,
on_badge, sheet_py_tag and on_sheet_py_tag join the contrast test.

A draft, Python or not, records its sheet's identity (Sheet::uid) when
it starts and follows that sheet, since commands that run while it is
open (sheet.move, home.insertSheet, home.deleteSheet, their undo, a
macro, a tool that adds a report sheet) change which sheet sits at its
index. A commit goes to the draft's own sheet; before, a draft committed
after a sheet was inserted before its own went into the inserted sheet,
an ordinary draft as well. A draft whose sheet is gone is dropped with a
message, as one whose cell protection locks is. ui.inspect checks for
hidden code on the draft's own sheet, and once that sheet is gone it
leaves out a Python draft, or one that began on a Python cell.
2026-11-01 21:02:03 -05:00

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