diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index e1163d9bb..eda675e7b 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -24,6 +24,8 @@ pub enum Action { pub struct KeyTips { prefix: Option, alt_armed: bool, + // Left Alt is down: Alt+H (held, as Excel allows) types keytip letters. + alt_held: bool, // Keep consuming a held mnemonic after its action closes keytips. held: Vec, } @@ -36,6 +38,7 @@ impl KeyTips { pub fn clear(&mut self) { self.prefix = None; self.alt_armed = false; + self.alt_held = false; self.held.clear(); } @@ -61,6 +64,9 @@ impl KeyTips { 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 !m.alt { + self.alt_held = false; + } if command_modifier(*m) || (mac && m.alt) { self.clear(); } else if m.shift { @@ -68,6 +74,7 @@ impl KeyTips { } } Event::Key { key: Key::AltLeft, pressed, repeat, modifiers, .. } if !mac && !command_modifier(*modifiers) && !modifiers.shift => { + self.alt_held = *pressed; if *pressed { if !repeat { self.alt_armed = true; @@ -80,8 +87,20 @@ impl KeyTips { } Event::Key { key, pressed, repeat, modifiers, .. } => { self.alt_armed = false; + // A letter typed with left Alt held continues a sequence (Alt+H, O, A), as + // in Excel; any other Alt combination cancels keytips. + let alt_letter = + self.alt_held && modifiers.alt && !modifiers.shift && !command_modifier(*modifiers) && key.name().len() == 1 && { + let next = format!("{}{}", self.prefix.as_deref().unwrap_or_default(), key.name()); + keytip_table::lookup(&next).is_some() || keytip_table::has_deeper(&next) + }; + if alt_letter && *pressed && self.prefix.is_none() { + self.prefix = Some(String::new()); + } + // The release of that letter still carries Alt. + let alt_release = !*pressed && self.held.contains(key); if command_modifier(*modifiers) - || modifiers.alt + || (modifiers.alt && !alt_letter && !alt_release) || matches!(key, Key::AltLeft | Key::AltRight | Key::ControlLeft | Key::ControlRight | Key::SuperLeft | Key::SuperRight) { self.clear(); diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 24be742a5..1cf286eac 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -267,6 +267,80 @@ fn mouse_press_and_window_focus_loss_cancel_keytips() { assert!(h.state().editor.is_none()); } +fn alt_down(h: &mut Harness, alt: Key) { + events(h, [Event::ModifiersChanged(Modifiers::ALT), key_event(alt, true, Modifiers::ALT)]); +} + +fn alt_up(h: &mut Harness, alt: Key) { + events(h, [Event::ModifiersChanged(Modifiers::NONE), key_event(alt, false, Modifiers::NONE)]); +} + +fn alt_letter(h: &mut Harness, key: Key) { + events(h, [key_event(key, true, Modifiers::ALT), key_event(key, false, Modifiers::ALT)]); +} + +fn line_size(h: &Harness, cols: bool) -> Option { + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + if cols { &sh.cols } else { &sh.rows }.get(&0).and_then(|e| e.size) +} + +#[test] +fn letters_typed_with_alt_held_run_autofit_sequences() { + // #1846: Excel accepts Alt+H, O, A with Alt still held for the first letter (or all of them). + for os in [OperatingSystem::Windows, OperatingSystem::Nix] { + let mut s = blank(); + s.execute("cell.set", json!({"cell": "A1", "input": "a much longer piece of text than the column"})).unwrap(); + s.execute("home.fontSize", json!({"range": "A1", "size": 28})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "1:1", "height": 10})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + let mut h = harness_on(s, os); + + // Alt held through H, O and I: AutoFit Column Width. + alt_down(&mut h, Key::AltLeft); + alt_letter(&mut h, Key::H); + assert_eq!(h.state().keytips.prefix(), Some("H"), "{os:?}: Alt+H opens the Home keytips"); + alt_letter(&mut h, Key::O); + alt_letter(&mut h, Key::I); + alt_up(&mut h, Key::AltLeft); + assert_eq!(h.state().keytips.prefix(), None, "{os:?}"); + assert!(line_size(&h, true).is_some_and(|w| w > 100.0), "{os:?}: column A was autofitted"); + + // Alt released after H: AutoFit Row Height. + alt_down(&mut h, Key::AltLeft); + alt_letter(&mut h, Key::H); + alt_up(&mut h, Key::AltLeft); + assert_eq!(h.state().keytips.prefix(), Some("H"), "{os:?}: releasing Alt keeps the level"); + letter(&mut h, Key::O, false); + letter(&mut h, Key::A, false); + assert!(line_size(&h, false).is_some_and(|r| r > 10.0), "{os:?}: row 1 was autofitted"); + assert_eq!(value(&h, "A1"), Value::Text("a much longer piece of text than the column".into())); + assert!(h.state().editor.is_none(), "{os:?}: no letter reached the grid"); + } +} + +#[test] +fn alt_combinations_outside_a_sequence_still_cancel_keytips() { + let mut h = harness_on(blank(), OperatingSystem::Windows); + alt_down(&mut h, Key::AltLeft); + alt_letter(&mut h, Key::X); + alt_up(&mut h, Key::AltLeft); + assert_eq!(h.state().keytips.prefix(), None, "X starts no sequence"); + + // AltGr types characters (and is Ctrl+Alt on Windows): it never starts keytips. + let mut h = harness_on(blank(), OperatingSystem::Nix); + alt_down(&mut h, Key::AltRight); + alt_letter(&mut h, Key::H); + alt_up(&mut h, Key::AltRight); + assert_eq!(h.state().keytips.prefix(), None); + + // macOS: Option is a text modifier (see #1864). + let mut h = harness_on(blank(), OperatingSystem::Mac); + alt_down(&mut h, Key::AltLeft); + alt_letter(&mut h, Key::H); + alt_up(&mut h, Key::AltLeft); + assert_eq!(h.state().keytips.prefix(), None); +} + #[test] fn held_keys_do_not_toggle_twice_or_leak_repeated_letters() { let mut h = harness(blank()); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..ab0472ba4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (keytip letters typed with Alt held, #1846) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -77,7 +77,7 @@ this logic, with one undo step. | Ctrl+1, Ctrl+; and Ctrl+Shift+; , Ctrl+D/R, Ctrl+E, Ctrl+T/L, Ctrl+K, Ctrl+Shift+L, Ctrl+Shift+~!@#$%^&_ | yes | done | | F2, F4, F5, F7, F9 family, Shift+F2/F3/F10/F11, F12 | yes | done; F11 chart sheet, F8 extend mode, F3 Paste Name, F4 Repeat missing | | Mac-specific (Cmd variants, Control variants, Fn keys) | yes | done where a feature exists (`keymap.rs` picks the Mac variant) | -| Sequential Alt access keys (Alt, H, N, …) on every ribbon tab | yes | 364-sequence keytip table for all tabs: per-control badges, prefix waiting, dropdowns open for editing (type/arrows/Enter), pending bindings for not-yet-built commands; QAT/Backstage excluded | +| Sequential Alt access keys (Alt, H, N, …) on every ribbon tab | yes | 364-sequence keytip table for all tabs: per-control badges, prefix waiting, dropdowns open for editing (type/arrows/Enter), pending bindings for not-yet-built commands; letters also work with left Alt held (Alt+H, O, A), as in Excel; QAT/Backstage excluded | | Requests | — | #110 (more shortcuts and hot keys), #33 (sequential Alt; initial support landed in #43) | ### Shortcut audit (#110) @@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |