Merge pull request #43 from harrisonoconnorhover/codex/ribbon-keytips

Add initial Alt ribbon keytips and legacy shortcuts (#33)
This commit is contained in:
Brandon Thomas 2026-10-10 12:58:36 -04:00
commit cb794080b9
6 changed files with 740 additions and 9 deletions

View File

@ -117,6 +117,29 @@ cd apps/gridcraft-web && trunk serve --release # http://127.0.0.1:8771 (?sa
You need [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. Open uses the
browser's file picker (dropping files works too) and Save downloads the workbook.
### Ribbon access keys
Press and release **left Alt** on Windows/Linux/BSD, or press **F10** on any platform,
then type a sequence below one key at a time. Keytips appear on the supported Home
controls and menu items. On macOS, use F10 (Fn+F10 if required by your keyboard); Option
remains available for entering accented characters.
| Sequence after Alt/F10 | Action |
|---|---|
| `H A L` / `H A C` / `H A R` | Align left / center / right |
| `H V S` or legacy `E S` | Open Paste Special |
| `H O I` or legacy `O C A` | AutoFit selected columns |
In Paste Special, **T/V/F/C** select formats/values/formulas/comments, and **E** toggles
Transpose. **Enter** applies the selected options; **Escape** cancels the dialog.
These letters also work with Alt held on Windows/Linux/BSD.
Escape backs out one keytip level; Alt/F10 again exits keytips. Clicking, scrolling,
or switching windows cancels navigation. Access keys do not start while editing text
or using another dialog. This is initial support for these Home and legacy paths;
other ribbon commands and full mouse-free navigation remain to be implemented.
Browsers or desktop environments may reserve Alt/F10 for their own menus.
### Logs
The desktop app writes its log to standard error and to `gridcraft.log` in the `logs` folder

View File

@ -188,10 +188,10 @@ impl Dialog {
label: "Paste",
options: vec![
("all", "All"),
("formulas", "Formulas"),
("values", "Values"),
("formats", "Formats"),
("comments", "Comments and notes"),
("formulas", "Formulas (F)"),
("values", "Values (V)"),
("formats", "Formats (T)"),
("comments", "Comments and notes (C)"),
("validation", "Validation"),
("allExceptBorders", "All except borders"),
("columnWidths", "Column widths"),
@ -205,7 +205,7 @@ impl Dialog {
options: vec![("none", "None"), ("add", "Add"), ("subtract", "Subtract"), ("multiply", "Multiply"), ("divide", "Divide")],
},
Check { key: "skipBlanks", label: "Skip blanks" },
Check { key: "transpose", label: "Transpose" },
Check { key: "transpose", label: "Transpose (E)" },
Check { key: "link", label: "Paste Link" },
],
json!({"what": "all", "operation": "none"}),
@ -632,6 +632,9 @@ fn build_params(d: &Dialog) -> Json {
pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
let Some(mut d) = app.dialog.take() else { return };
if d.name == "pasteSpecial" {
paste_special_shortcuts(&mut d, ctx);
}
let mut open = true;
let mut win_open = true;
let mut confirm = false;
@ -828,6 +831,71 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
}
}
/// Paste Special's legacy mnemonics select options; Enter still performs the paste.
fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
if ctx.text_edit_focused() {
return;
}
let right_alt = ctx.input(|i| {
i.key_down(Key::AltRight)
|| i.events.iter().any(|event| {
matches!(event, egui::Event::Key { key: Key::AltRight, .. })
|| matches!(event, egui::Event::Key { physical_key: Some(Key::AltRight), .. })
})
});
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt));
let mnemonic = |key| match key {
Key::T => Some('t'),
Key::V => Some('v'),
Key::F => Some('f'),
Key::C => Some('c'),
Key::E => Some('e'),
_ => None,
};
let mut text_to_consume = Vec::new();
for event in ctx.input(|i| i.events.clone()) {
let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue };
if !allowed(modifiers) {
continue;
}
let Some(letter) = mnemonic(key) else { continue };
text_to_consume.push(letter);
if repeat {
continue;
}
if key == Key::E {
let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false);
d.values.insert("transpose".into(), json!(!transpose));
} else {
let what = match key {
Key::T => "formats",
Key::V => "values",
Key::F => "formulas",
_ => "comments",
};
d.values.insert("what".into(), json!(what));
}
}
// Browsers can deliver Text before or after Key. Act only on Key, then remove
// its matching text so one physical press cannot toggle Transpose twice.
ctx.input_mut(|i| {
i.events.retain(|event| match event {
egui::Event::Key { key, modifiers, .. } => !(allowed(*modifiers) && mnemonic(*key).is_some()),
egui::Event::Text(text) if text.len() == 1 => {
let letter = text.chars().next().unwrap_or_default().to_ascii_lowercase();
if let Some(index) = text_to_consume.iter().position(|&c| c == letter) {
text_to_consume.remove(index);
false
} else {
true
}
}
_ => true,
});
});
}
fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) {
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {

View File

@ -0,0 +1,208 @@
//! Sequential ribbon shortcuts. The host paints hints from `prefix` and runs returned actions.
use egui::{Context, Event, Key, Modifiers, os::OperatingSystem};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
Home,
Command(&'static str),
Dialog(&'static str),
}
#[derive(Default)]
pub struct KeyTips {
prefix: Option<String>,
alt_armed: bool,
// Keep consuming a held mnemonic after its action closes keytips.
held: Vec<Key>,
}
impl KeyTips {
pub fn prefix(&self) -> Option<&str> {
self.prefix.as_deref()
}
pub fn clear(&mut self) {
self.prefix = None;
self.alt_armed = false;
self.held.clear();
}
fn toggle(&mut self) {
self.prefix = if self.prefix.is_some() { None } else { Some(String::new()) };
self.alt_armed = false;
}
/// Remove only handled events before any text editors or the grid see them.
pub fn process(&mut self, ctx: &Context, enabled: bool) -> Vec<Action> {
if !enabled || !ctx.input(|i| i.focused) {
self.clear();
return Vec::new();
}
let mac = ctx.os() == OperatingSystem::Mac;
let events = ctx.input_mut(|i| std::mem::take(&mut i.events));
let pairs = text_pairs(&events);
let mut consumed = std::collections::HashSet::new();
let mut actions = Vec::new();
for (index, event) in events.iter().enumerate() {
let mut handled = false;
match event {
Event::PointerButton { pressed: true, .. } | Event::MouseWheel { .. } | Event::WindowFocused(false) | Event::Ime(_) => self.clear(),
Event::Copy | Event::Cut | Event::Paste(_) => self.clear(),
Event::ModifiersChanged(m) => {
if command_modifier(*m) || (mac && m.alt) {
self.clear();
} else if m.shift {
self.alt_armed = false;
}
}
Event::Key { key: Key::AltLeft, pressed, repeat, modifiers, .. } if !mac && !command_modifier(*modifiers) && !modifiers.shift => {
if *pressed {
if !repeat {
self.alt_armed = true;
}
handled = true;
} else if self.alt_armed {
self.toggle();
handled = true;
}
}
Event::Key { key, pressed, repeat, modifiers, .. } => {
self.alt_armed = false;
if command_modifier(*modifiers)
|| modifiers.alt
|| matches!(key, Key::AltLeft | Key::AltRight | Key::ControlLeft | Key::ControlRight | Key::SuperLeft | Key::SuperRight)
{
self.clear();
} else if !pressed {
handled = self.held.contains(key);
self.held.retain(|held| held != key);
} else if *repeat && (self.prefix.is_some() || self.held.contains(key)) {
handled = true;
} else if *key == Key::F10 && modifiers.is_none() {
self.toggle();
handled = true;
} else if self.prefix.is_some() && !matches!(key, Key::ShiftLeft | Key::ShiftRight) {
handled = true;
if *key == Key::Escape {
if let Some(prefix) = self.prefix.as_mut()
&& prefix.pop().is_none()
{
self.prefix = None;
}
} else if key.name().len() == 1 {
let next = format!("{}{key_name}", self.prefix.as_deref().unwrap_or_default(), key_name = key.name());
match next.as_str() {
"H" => {
self.prefix = Some(next);
actions.push(Action::Home);
}
"HA" | "HV" | "HO" | "E" | "O" | "OC" => self.prefix = Some(next),
"HAR" => self.finish(Action::Command("home.alignRight"), &mut actions),
"HAL" => self.finish(Action::Command("home.alignLeft"), &mut actions),
"HAC" => self.finish(Action::Command("home.alignCenter"), &mut actions),
"HVS" | "ES" => self.finish(Action::Dialog("pasteSpecial"), &mut actions),
"HOI" | "OCA" => self.finish(Action::Command("home.autofitColumnWidth"), &mut actions),
_ => {}
}
}
}
if handled && *pressed && !self.held.contains(key) {
self.held.push(*key);
}
}
Event::Text(_) => {
self.alt_armed = false;
// Paired text is decided with its key, which may occur later on web.
handled = self.prefix.is_some() && !pairs.iter().any(|(_, text)| *text == index);
}
_ => {}
}
if handled {
consumed.insert(index);
if let Some((_, text)) = pairs.iter().find(|(key, _)| *key == index) {
consumed.insert(*text);
}
}
}
ctx.input_mut(|i| {
i.events = events.into_iter().enumerate().filter_map(|(index, event)| (!consumed.contains(&index)).then_some(event)).collect()
});
actions
}
fn finish(&mut self, action: Action, actions: &mut Vec<Action>) {
self.prefix = None;
actions.push(action);
}
}
fn command_modifier(m: Modifiers) -> bool {
m.ctrl || m.command || m.mac_cmd
}
/// Native integrations emit Key then Text; browsers can emit Text then Key.
fn text_pairs(events: &[Event]) -> Vec<(usize, usize)> {
let mut pairs = Vec::new();
for (text_index, event) in events.iter().enumerate() {
let Event::Text(_) = event else { continue };
let before = events.iter().enumerate().take(text_index).rev().find(|(_, e)| !matches!(e, Event::ModifiersChanged(_)));
let after = events.iter().enumerate().skip(text_index + 1).find(|(_, e)| !matches!(e, Event::ModifiersChanged(_)));
for (key_index, event) in before.into_iter().chain(after) {
if let Event::Key { key, pressed: true, modifiers, .. } = event
&& (key.name().len() == 1 || modifiers.alt || command_modifier(*modifiers))
&& !pairs.iter().any(|(paired, _)| *paired == key_index)
{
pairs.push((key_index, text_index));
break;
}
}
}
pairs
}
#[cfg(test)]
mod tests {
use super::*;
fn key(key: Key, pressed: bool, modifiers: Modifiers) -> Event {
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }
}
#[test]
fn active_mode_preserves_modified_text_before_key() {
for (os, letter, text, modifiers) in [
(OperatingSystem::Windows, Key::E, "€", Modifiers::CTRL | Modifiers::ALT),
(OperatingSystem::Nix, Key::E, "€", Modifiers::ALT),
(OperatingSystem::Mac, Key::O, "œ", Modifiers::ALT),
(OperatingSystem::Mac, Key::C, "c", Modifiers::COMMAND),
] {
let ctx = Context::default();
ctx.set_os(os);
let mut tips = KeyTips { prefix: Some("H".into()), ..Default::default() };
let events = vec![Event::Text(text.into()), key(letter, true, modifiers)];
let mut remaining = Vec::new();
let mut output = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| {
assert!(tips.process(ui.ctx(), true).is_empty());
remaining = ui.input(|i| i.events.clone());
});
output.textures_delta.clear();
assert_eq!(tips.prefix(), None);
assert_eq!(remaining, events);
}
}
#[test]
fn shift_between_alt_press_and_release_does_not_activate() {
let ctx = Context::default();
ctx.set_os(OperatingSystem::Windows);
let mut tips = KeyTips::default();
let events =
vec![key(Key::AltLeft, true, Modifiers::ALT), key(Key::ShiftLeft, true, Modifiers::NONE), key(Key::AltLeft, false, Modifiers::NONE)];
let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
assert!(tips.process(ui.ctx(), true).is_empty());
});
output.textures_delta.clear();
assert_eq!(tips.prefix(), None);
}
}

View File

@ -15,6 +15,7 @@ pub mod editor;
pub mod formula_bar;
pub mod grid;
pub mod icons;
pub mod keytips;
pub mod panes;
pub mod pivot_pane;
pub mod ribbon;
@ -132,6 +133,8 @@ pub struct SheetApp {
fonts_set: bool,
effective_dark: bool,
pub name_box: Option<String>,
/// Transient ribbon keyboard navigation; never saved with UI preferences.
pub keytips: keytips::KeyTips,
pub(crate) shots: control::Shots,
/// Chart selected on the sheet (id).
pub selected_chart: Option<u32>,
@ -162,6 +165,7 @@ impl SheetApp {
fonts_set: false,
effective_dark: false,
name_box: None,
keytips: keytips::KeyTips::default(),
shots: control::Shots::default(),
selected_chart: None,
started: now_ms(),
@ -444,6 +448,7 @@ impl SheetApp {
}
let t0 = now_ms();
let t = theme::Tokens::get(&ctx);
self.ribbon_keys(&ctx);
ribbon::title_bar(self, ui);
ribbon::show(self, ui);
if self.ui.formula_bar {
@ -496,6 +501,43 @@ impl SheetApp {
}
}
fn ribbon_keys(&mut self, ctx: &egui::Context) {
let before = self.keytips.prefix().map(str::to_string);
let enabled = self.editor.is_none()
&& self.dialog.is_none()
&& self.message.is_none()
&& self.name_box.is_none()
&& !ctx.text_edit_focused()
&& self.grid.context_menu.is_none()
&& self.grid.header_menu.is_none()
&& self.grid.filter_menu.is_none()
&& self.grid.list_picker.is_none()
&& self.grid.renaming_tab.is_none()
&& (before.is_some() || !egui::Popup::is_any_open(ctx));
let actions = self.keytips.process(ctx, enabled);
if before.is_some() && before.as_deref() != self.keytips.prefix() && !ctx.input(|i| i.pointer.any_pressed()) {
// Leave pointer cancellation to the popup so clicking a menu item still works.
egui::Popup::close_all(ctx);
}
// The legacy Edit/Format paths lead to the same existing Home menus. Switch once, when
// the prefix changes, not every frame (that would reset a tab the user picked meanwhile).
let after = self.keytips.prefix();
if after != before.as_deref() && matches!(after, Some("E" | "O" | "OC")) {
self.run_or_alert("ui.ribbonTab", json!({"tab": "Home"}));
self.ui.ribbon_collapsed = false;
}
for action in actions {
match action {
keytips::Action::Home => {
self.run_or_alert("ui.ribbonTab", json!({"tab": "Home"}));
self.ui.ribbon_collapsed = false;
}
keytips::Action::Command(id) => self.run_or_alert(id, json!({})),
keytips::Action::Dialog(name) => self.open_dialog(name, json!({})),
}
}
}
/// Current selection's top-left cell text as A1 (for the Name Box).
pub fn name_box_text(&self) -> String {
let Some(d) = self.session.active() else { return String::new() };

View File

@ -168,6 +168,9 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
if resp.double_clicked() {
app.ui.ribbon_collapsed = !collapsed;
}
if tab == "Home" && app.keytips.prefix() == Some("") {
keytip(app, ui, "H", pos2(r.center().x, r.bottom() - 1.0));
}
}
});
});
@ -269,13 +272,36 @@ fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) {
app.run_or_alert(id, params);
}
/// Keytips are painted, not focusable widgets: the grid retains keyboard focus.
fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) {
let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return };
let t = Tokens::get(ui.ctx());
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("ribbon_keytips"))).with_clip_rect(ui.clip_rect());
let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text);
let rect = Rect::from_center_size(at, text.size() + vec2(7.0, 3.0));
painter.rect_filled(rect, 2.0, t.input_bg);
painter.rect_stroke(rect, 2.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
painter.galley(rect.min + vec2(3.5, 1.5), text, t.text);
}
fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json::Value)]) {
for (label, id, p) in items {
if *label == "-" {
ui.separator();
continue;
}
if ui.add(egui::Button::new(*label).frame(false).min_size(vec2(220.0, 22.0))).clicked() {
let response = ui.add(egui::Button::new(*label).frame(false).min_size(vec2(220.0, 22.0)));
let sequence = match (app.keytips.prefix(), *id) {
(Some("HV"), "dialog:pasteSpecial") => Some("HVS"),
(Some("E"), "dialog:pasteSpecial") => Some("ES"),
(Some("HO"), "home.autofitColumnWidth") => Some("HOI"),
(Some("OC"), "home.autofitColumnWidth") => Some("OCA"),
_ => None,
};
if let Some(sequence) = sequence {
keytip(app, ui, sequence, pos2(response.rect.right() - 10.0, response.rect.center().y));
}
if response.clicked() {
if let Some(d) = id.strip_prefix("dialog:") {
app.open_dialog(d, p.clone());
} else {
@ -290,6 +316,9 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
let st = active_style(app);
// Clipboard
let paste = big_button(ui, Icon::Paste, "Paste", "Paste (⌘V)", true);
if app.keytips.prefix() == Some("H") {
keytip(app, ui, "HV", paste.rect.center_bottom());
}
if paste.clicked() {
if let Some(text) = app.grid.system_clipboard.take() {
act(app, "edit.paste", json!({"text": text}));
@ -297,7 +326,29 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
act(app, "edit.paste", json!({}));
}
}
egui::Popup::context_menu(&paste).show(|ui| {
let paste_keys = matches!(app.keytips.prefix(), Some("HV" | "E"));
// Keep a keyboard-opened menu anchored to the button when a pointer click
// cancels keytips, so its items remain in place and can still be clicked.
let anchor_id = paste.id.with("keytip_anchor");
let anchored = ui.ctx().data_mut(|data| {
let anchored = if paste_keys {
true
} else if paste.secondary_clicked() {
false
} else {
data.get_temp::<bool>(anchor_id).unwrap_or(false)
};
data.insert_temp(anchor_id, anchored);
anchored
});
let mut paste_popup = egui::Popup::context_menu(&paste);
if anchored {
paste_popup = paste_popup.anchor(&paste);
}
if paste_keys {
paste_popup = paste_popup.open_memory(egui::SetOpenCommand::Bool(true));
}
paste_popup.show(|ui| {
menu_items(
app,
ui,
@ -481,7 +532,16 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
(Icon::AlignCenter, HAlign::Center, "home.alignCenter", "Center"),
(Icon::AlignRight, HAlign::Right, "home.alignRight", "Align Right"),
] {
if toggle_button(ui, icon, st.align.h == h, tip).clicked() {
let response = toggle_button(ui, icon, st.align.h == h, tip);
if matches!(app.keytips.prefix(), Some("H" | "HA")) {
let sequence = match h {
HAlign::Left => "HAL",
HAlign::Center => "HAC",
_ => "HAR",
};
keytip(app, ui, sequence, response.rect.center_bottom());
}
if response.clicked() {
act(app, id, json!({}));
}
}
@ -597,7 +657,17 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
);
});
let fmt = small_button(ui, Icon::Format, "Format", "Format", true);
egui::Popup::menu(&fmt).show(|ui| {
match app.keytips.prefix() {
Some("H") => keytip(app, ui, "HO", fmt.rect.center_bottom()),
Some("O") => keytip(app, ui, "OC", fmt.rect.center_bottom()),
_ => {}
}
let format_keys = matches!(app.keytips.prefix(), Some("HO" | "OC"));
let mut format_popup = egui::Popup::menu(&fmt);
if format_keys {
format_popup = format_popup.open_memory(egui::SetOpenCommand::Bool(true));
}
format_popup.show(|ui| {
menu_items(
app,
ui,

View File

@ -0,0 +1,320 @@
//! Ribbon access keys through the complete egui input and UI path.
use egui::{Event, Key, Modifiers, PointerButton, os::OperatingSystem};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_engine::model::HAlign;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness(session: Session) -> Harness {
harness_on(session, OperatingSystem::Nix)
}
fn harness_on(session: Session, os: OperatingSystem) -> Harness {
let mut h = egui_kittest::Harness::builder().with_os(os).with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(4);
h
}
fn blank() -> Session {
let mut s = Session::new();
s.new_workbook();
s
}
fn key_event(key: Key, pressed: bool, modifiers: Modifiers) -> Event {
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }
}
fn events(h: &mut Harness, events: impl IntoIterator<Item = Event>) {
h.input_mut().events.extend(events);
h.run_steps(2);
}
fn key(h: &mut Harness, key: Key) {
events(h, [key_event(key, true, Modifiers::NONE), key_event(key, false, Modifiers::NONE)]);
}
fn letter(h: &mut Harness, key: Key, text_first: bool) {
let text = Event::Text(key.name().to_ascii_lowercase());
let pressed = key_event(key, true, Modifiers::NONE);
let pair = if text_first { [text, pressed] } else { [pressed, text] };
events(h, pair.into_iter().chain([key_event(key, false, Modifiers::NONE)]));
}
fn sequence(h: &mut Harness, letters: &[Key], text_first: bool) {
key(h, Key::F10);
for &k in letters {
letter(h, k, text_first);
}
}
fn value(h: &Harness, cell: &str) -> Value {
h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap())
}
fn alignment(h: &Harness) -> HAlign {
let d = h.state().session.active().unwrap();
let sh = d.wb.active().unwrap();
d.wb.styles.get(sh.style_id(CellRef::parse("A1").unwrap())).align.h
}
fn copied_row() -> Session {
let mut s = blank();
s.execute("cell.set", json!({"cell": "A1", "input": "=20+22"})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "99"})).unwrap();
s.execute("edit.copy", json!({"range": "A1:B1"})).unwrap();
s.execute("selection.set", json!({"cell": "D4"})).unwrap();
s
}
#[test]
fn alignment_sequences_consume_key_and_text_events_in_either_order() {
for text_first in [false, true] {
let mut s = blank();
s.execute("cell.set", json!({"cell": "A1", "input": "keep me"})).unwrap();
let mut h = harness(s);
h.state_mut().ui.ribbon_tab = "Insert".into();
for (k, expected) in [(Key::R, HAlign::Right), (Key::L, HAlign::Left), (Key::C, HAlign::Center)] {
sequence(&mut h, &[Key::H, Key::A, k], text_first);
assert_eq!(h.state().ui.ribbon_tab, "Home");
assert_eq!(alignment(&h), expected);
assert_eq!(value(&h, "A1"), Value::from("keep me"));
assert!(h.state().editor.is_none(), "access-key letters must not begin a cell edit");
assert_eq!(h.state().keytips.prefix(), None);
}
// Consuming the terminal access key must not swallow the next ordinary keystroke.
letter(&mut h, Key::X, text_first);
key(&mut h, Key::Enter);
assert_eq!(value(&h, "A1"), Value::from("x"));
}
}
#[test]
fn paste_special_options_wait_for_enter_and_transpose_values() {
let mut h = harness(copied_row());
sequence(&mut h, &[Key::H, Key::V, Key::S], true);
assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial");
for (k, what) in [(Key::T, "formats"), (Key::V, "values"), (Key::F, "formulas"), (Key::C, "comments")] {
letter(&mut h, k, true);
assert_eq!(h.state().dialog.as_ref().unwrap().values.get("what"), Some(&json!(what)));
assert_eq!(value(&h, "D4"), Value::Empty, "choosing a paste option must not paste yet");
}
letter(&mut h, Key::V, true);
letter(&mut h, Key::E, true);
assert_eq!(h.state().dialog.as_ref().unwrap().values.get("transpose"), Some(&json!(true)));
for modifiers in [Modifiers::CTRL, Modifiers::COMMAND] {
events(
&mut h,
[
Event::ModifiersChanged(modifiers),
key_event(Key::T, true, modifiers),
key_event(Key::T, false, modifiers),
key_event(Key::E, true, modifiers),
key_event(Key::E, false, modifiers),
Event::ModifiersChanged(Modifiers::NONE),
],
);
let dialog = h.state().dialog.as_ref().unwrap();
assert_eq!(dialog.values.get("what"), Some(&json!("values")), "Ctrl/Cmd must not select a paste mnemonic");
assert_eq!(dialog.values.get("transpose"), Some(&json!(true)), "Ctrl/Cmd must not toggle transpose");
}
// Non-macOS Alt+E selects the dialog mnemonic without activating ribbon access keys.
events(
&mut h,
[
Event::ModifiersChanged(Modifiers::ALT),
Event::Text("e".into()),
key_event(Key::E, true, Modifiers::ALT),
key_event(Key::E, false, Modifiers::ALT),
Event::ModifiersChanged(Modifiers::NONE),
],
);
assert_eq!(h.state().dialog.as_ref().unwrap().values.get("transpose"), Some(&json!(false)));
assert_eq!(h.state().keytips.prefix(), None);
letter(&mut h, Key::E, false);
key(&mut h, Key::Enter);
assert!(h.state().dialog.is_none());
assert!(h.state().editor.is_none());
assert_eq!(value(&h, "D4"), Value::Number(42.0));
assert_eq!(value(&h, "D5"), Value::Number(99.0));
assert_eq!(value(&h, "E4"), Value::Empty);
let sh = h.state().session.active().unwrap().wb.active().unwrap();
assert!(sh.cell(CellRef::parse("D4").unwrap()).unwrap().formula.is_none());
}
#[test]
fn keyboard_opened_paste_menu_accepts_a_mouse_click_after_keytips_cancel() {
let mut h = harness(copied_row());
sequence(&mut h, &[Key::H, Key::V], false);
assert_eq!(h.state().keytips.prefix(), Some("HV"));
assert!(h.state().dialog.is_none());
// This sends actual pointer events at the accessible menu item's rectangle.
// The press cancels keytips, but must not relocate or close the menu before the click.
h.get_by_label("Paste Special…").click();
h.run_steps(3);
assert_eq!(h.state().keytips.prefix(), None);
assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial");
assert_eq!(value(&h, "D4"), Value::Empty);
assert!(h.state().editor.is_none());
}
#[test]
fn legacy_paste_special_keeps_formulas_when_requested() {
let mut h = harness(copied_row());
sequence(&mut h, &[Key::E, Key::S], false);
assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial");
letter(&mut h, Key::F, false);
assert_eq!(value(&h, "D4"), Value::Empty);
key(&mut h, Key::Enter);
let sh = h.state().session.active().unwrap().wb.active().unwrap();
assert_eq!(sh.cell(CellRef::parse("D4").unwrap()).unwrap().formula.as_ref().unwrap().text, "20+22");
assert_eq!(value(&h, "D4"), Value::Number(42.0));
assert_eq!(value(&h, "E4"), Value::Number(99.0));
assert!(h.state().editor.is_none());
key(&mut h, Key::ArrowDown);
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "D5");
}
#[test]
fn ribbon_and_legacy_autofit_sequences_resize_the_selected_column() {
for route in [&[Key::H, Key::O, Key::I][..], &[Key::O, Key::C, Key::A][..]] {
let mut s = blank();
s.execute("cell.set", json!({"cell": "A1", "input": "A long heading that needs a wider column"})).unwrap();
s.execute("home.columnWidth", json!({"cols": "A:A", "width": 12})).unwrap();
let mut h = harness(s);
let before = h.state().session.active().unwrap().wb.active().unwrap().col_width(0);
sequence(&mut h, route, false);
let after = h.state().session.active().unwrap().wb.active().unwrap().col_width(0);
assert!(after > before, "autofit must widen the narrow column");
assert_eq!(value(&h, "A1"), Value::from("A long heading that needs a wider column"));
assert!(h.state().editor.is_none());
assert_eq!(h.state().keytips.prefix(), None);
letter(&mut h, Key::X, false);
key(&mut h, Key::Enter);
assert_eq!(value(&h, "A1"), Value::from("x"));
}
}
#[test]
fn legacy_prefix_switches_to_home_once_not_every_frame() {
let mut h = harness(blank());
h.state_mut().ui.ribbon_tab = "Insert".into();
sequence(&mut h, &[Key::E], false);
assert_eq!(h.state().keytips.prefix(), Some("E"));
assert_eq!(h.state().ui.ribbon_tab, "Home");
// While the prefix stays the same, later frames must not keep forcing the Home tab.
h.state_mut().ui.ribbon_tab = "Insert".into();
h.run_steps(3);
assert_eq!(h.state().keytips.prefix(), Some("E"));
assert_eq!(h.state().ui.ribbon_tab, "Insert");
}
#[test]
fn escape_backs_out_without_clearing_copy_and_f10_toggles_off() {
let mut h = harness(copied_row());
sequence(&mut h, &[Key::H, Key::A], false);
assert_eq!(h.state().keytips.prefix(), Some("HA"));
for expected in [Some("H"), Some(""), None] {
key(&mut h, Key::Escape);
assert_eq!(h.state().keytips.prefix(), expected);
assert_eq!(h.state().session.clipboard.as_ref().unwrap().range.a1(), "A1:B1");
}
key(&mut h, Key::F10);
assert_eq!(h.state().keytips.prefix(), Some(""));
key(&mut h, Key::F10);
assert_eq!(h.state().keytips.prefix(), None);
assert!(h.state().session.clipboard.is_some());
// A bare left Alt is the desktop entry point on non-macOS systems.
events(&mut h, [Event::ModifiersChanged(Modifiers::ALT), key_event(Key::AltLeft, true, Modifiers::ALT)]);
events(&mut h, [Event::ModifiersChanged(Modifiers::NONE), key_event(Key::AltLeft, false, Modifiers::NONE)]);
assert_eq!(h.state().keytips.prefix(), Some(""));
}
#[test]
fn mouse_press_and_window_focus_loss_cancel_keytips() {
let mut h = harness(blank());
sequence(&mut h, &[Key::H], false);
let pos = h.state().grid.cells_rect.unwrap().center();
events(
&mut h,
[
Event::PointerMoved(pos),
Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
],
);
assert_eq!(h.state().keytips.prefix(), None);
key(&mut h, Key::F10);
assert_eq!(h.state().keytips.prefix(), Some(""));
h.input_mut().focused = false;
events(&mut h, [Event::WindowFocused(false)]);
assert_eq!(h.state().keytips.prefix(), None);
assert!(h.state().editor.is_none());
}
#[test]
fn held_keys_do_not_toggle_twice_or_leak_repeated_letters() {
let mut h = harness(blank());
// egui derives repeats from held-key state, so send presses across frames before release.
events(&mut h, [key_event(Key::F10, true, Modifiers::NONE)]);
events(&mut h, [key_event(Key::F10, true, Modifiers::NONE)]);
assert_eq!(h.state().keytips.prefix(), Some(""));
events(&mut h, [key_event(Key::F10, false, Modifiers::NONE)]);
events(&mut h, [key_event(Key::H, true, Modifiers::NONE), Event::Text("h".into())]);
events(&mut h, [key_event(Key::H, true, Modifiers::NONE), Event::Text("h".into())]);
assert_eq!(h.state().keytips.prefix(), Some("H"));
events(&mut h, [key_event(Key::H, false, Modifiers::NONE)]);
letter(&mut h, Key::A, false);
events(
&mut h,
[
key_event(Key::R, true, Modifiers::NONE),
Event::Text("r".into()),
key_event(Key::R, true, Modifiers::NONE),
Event::Text("r".into()),
key_event(Key::R, false, Modifiers::NONE),
],
);
assert_eq!(alignment(&h), HAlign::Right);
assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "home.alignRight").count(), 1);
assert!(h.state().editor.is_none());
assert_eq!(value(&h, "A1"), Value::Empty);
}
#[test]
fn ordinary_typing_editor_shortcuts_and_altgr_remain_text_input() {
let mut h = harness(blank());
events(&mut h, [Event::Text("hello".into())]);
key(&mut h, Key::F10);
assert_eq!(h.state().keytips.prefix(), None, "F10 must not interrupt the cell editor");
assert_eq!(h.state().editor.as_ref().unwrap().text, "hello");
events(&mut h, [Event::Text("!".into())]);
key(&mut h, Key::Enter);
assert_eq!(value(&h, "A1"), Value::from("hello!"));
let mut h = harness(blank());
let altgr = Modifiers { alt: true, ctrl: true, ..Modifiers::NONE };
events(&mut h, [Event::ModifiersChanged(altgr), key_event(Key::AltRight, true, altgr), key_event(Key::Q, true, altgr), Event::Text("@".into())]);
assert_eq!(h.state().keytips.prefix(), None);
assert_eq!(h.state().editor.as_ref().unwrap().text, "@");
let mut h = harness_on(blank(), OperatingSystem::Mac);
events(&mut h, [Event::ModifiersChanged(Modifiers::ALT), key_event(Key::AltLeft, true, Modifiers::ALT)]);
events(&mut h, [Event::ModifiersChanged(Modifiers::NONE), key_event(Key::AltLeft, false, Modifiers::NONE), Event::Text("é".into())]);
assert_eq!(h.state().keytips.prefix(), None, "macOS Option must remain available for text composition");
assert_eq!(h.state().editor.as_ref().unwrap().text, "é");
}