diff --git a/README.md b/README.md index f5aa689cf..733312b0d 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,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. + ## For agents: MCP, CLI and the control channel ```sh diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b6c186d2d..827d542fd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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| { diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs new file mode 100644 index 000000000..6fc620a6a --- /dev/null +++ b/crates/ui-egui/src/keytips.rs @@ -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, + alt_armed: bool, + // Keep consuming a held mnemonic after its action closes keytips. + held: Vec, +} + +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 { + 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) { + 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); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8eb1eca1a..2e0014d39 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -14,6 +14,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; @@ -105,6 +106,8 @@ pub struct SheetApp { pub fonts_ready: bool, fonts_set: bool, pub name_box: Option, + /// 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, @@ -134,6 +137,7 @@ impl SheetApp { fonts_ready: false, fonts_set: false, name_box: None, + keytips: keytips::KeyTips::default(), shots: control::Shots::default(), selected_chart: None, started: now_ms(), @@ -400,6 +404,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 { @@ -452,6 +457,41 @@ 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. + if matches!(self.keytips.prefix(), 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() }; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 47c5432b4..e19a7d5d6 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -167,6 +167,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)); + } } }); }); @@ -242,13 +245,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 { @@ -263,6 +289,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})); @@ -270,7 +299,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::(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, @@ -453,7 +504,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!({})); } } @@ -569,7 +629,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, diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs new file mode 100644 index 000000000..619254b19 --- /dev/null +++ b/crates/ui-egui/tests/keytips.rs @@ -0,0 +1,306 @@ +//! 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) { + 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 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, "é"); +}