diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 79920b5b8..cde8066a4 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -6,6 +6,7 @@ use super::{Action, Entry}; /// whenever a pressed letter starts a deeper sequence, so only terminal entries are /// listed here. See keytip_table.rs for the helpers that read this table. pub const SEQUENCES: &[(&str, Entry)] = &[ + ("F", Entry::Tab("File")), // Excel: File tab (Backstage view); Opens the File view on Home; its letters below still run their commands. ("H", Entry::Tab("Home")), ("N", Entry::Tab("Insert")), ("JI", Entry::Tab("Draw")), // Two-letter unit. Deeper Draw letters differ between Excel and GridCraft; left blank for future contributors. diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 59aeadcfb..729cf673b 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -11,6 +11,13 @@ " - Keep entries grouped by tab and sorted by sequence within a group; the FIRST entry for a tab is its canonical badge (legacy aliases come after it)." ], "sequences": [ + { + "seq": "F", + "kind": "tab", + "target": "File", + "excel": "File tab (Backstage view)", + "note": "Opens the File view on Home; its letters below still run their commands." + }, { "seq": "H", "kind": "tab", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f2775656b..63cdeb1ad 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1191,7 +1191,8 @@ impl SheetApp { if !self.chrome.file_tab { self.backstage = None; } - if self.backstage.is_none() { + // Keytips opened the File view (Alt, F): its letters keep working there. + if self.backstage.is_none() || self.keytips.prefix().is_some() { self.ribbon_keys(&ctx); } if self.chrome.title_bar { @@ -1270,12 +1271,18 @@ impl SheetApp { && self.grid.renaming_tab.is_none() && (before.is_some() || !egui::Popup::is_any_open(ctx)); let actions = self.keytips.process(ctx, enabled); + // Backing out of F to the tab letters, or finishing an F sequence, closes the File view + // that Alt, F opened: the command runs on the sheet, as it did without the view. + if self.backstage.is_some() && before.is_some() && (self.keytips.prefix() == Some("") || !actions.is_empty()) { + self.backstage = None; + } 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); } for action in actions { match action { + keytips::Action::Tab("File") => self.open_backstage(backstage::Page::Home), keytips::Action::Tab(tab) => { self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); self.ui.ribbon_collapsed = false; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index a0ab5a339..75604ff3e 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -265,6 +265,11 @@ fn file_menu(app: &mut SheetApp, ui: &mut Ui) { if file.clicked() { app.open_backstage(crate::backstage::Page::Home); } + if app.keytips.prefix() == Some("") + && let Some(sequence) = keytip_table::tab_sequence("File") + { + keytip(app, ui, sequence, pos2(file.rect.center().x, file.rect.bottom() - 1.0)); + } } /// System / Light / Dark, as in the View tab's Display theme box (`view.theme`). diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 1cf286eac..a32abe82f 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -390,6 +390,25 @@ fn tab_letters_switch_to_their_ribbon_tabs() { assert_eq!(h.state().keytips.prefix(), None, "terminal tab units finish cleanly"); } +#[test] +fn f_opens_the_file_view_and_its_letters_still_run() { + use gridcraft_ui_egui::backstage::Page; + let mut h = harness(blank()); + sequence(&mut h, &[Key::F], false); + assert_eq!(h.state().backstage.as_ref().map(|b| b.page), Some(Page::Home), "F opens the File view on Home"); + assert_eq!(h.state().keytips.prefix(), Some("F")); + // Esc backs out one level: the File view closes and the tab letters show again. + key(&mut h, Key::Escape); + assert!(h.state().backstage.is_none()); + assert_eq!(h.state().keytips.prefix(), Some("")); + letter(&mut h, Key::F, false); + assert!(h.state().backstage.is_some()); + letter(&mut h, Key::N, false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.new"), "F, N still runs New"); + assert!(h.state().backstage.is_none(), "the command runs on the sheet, as before the view opened"); + assert_eq!(h.state().keytips.prefix(), None); +} + #[test] fn view_gridlines_sequence_runs_end_to_end() { let mut h = harness(blank()); diff --git a/crates/ui-egui/tests/keytips_generated.rs b/crates/ui-egui/tests/keytips_generated.rs index 653554857..cece3b3ed 100644 --- a/crates/ui-egui/tests/keytips_generated.rs +++ b/crates/ui-egui/tests/keytips_generated.rs @@ -59,6 +59,14 @@ fn sequence(h: &mut Harness, seq: &str, text_first: bool) { } } +#[test] +fn keytip_f_() { + let mut h = harness(blank()); + sequence(&mut h, "F", false); + assert!(h.state().backstage.is_some(), "F must open the File view"); + assert_eq!(h.state().keytips.prefix(), Some("F")); +} + #[test] fn keytip_h_() { let mut h = harness(blank()); @@ -2741,7 +2749,7 @@ fn keytip_q_() { #[test] fn generated_test_count_matches_the_table() { - const EXPECTED: usize = 364; + const EXPECTED: usize = 365; assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED); } diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs index 36847222f..54c332c11 100644 --- a/xtask/src/keytips.rs +++ b/xtask/src/keytips.rs @@ -10,7 +10,8 @@ use std::fs; use std::path::Path; -const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"]; +/// "File" is not a ribbon tab: its sequence opens the Backstage view. +const TABS: &[&str] = &["File", "Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"]; /// Commands whose empty-params behaviour depends on the host machine (e.g. the /// system dictionary). These are never probed; their tests assert "invoked @@ -260,6 +261,10 @@ fn render_tests(entries: &[Entry], probe: &Probe) -> String { let mut out = String::from(HEADER); for e in entries { let body = match e.kind.as_str() { + "tab" if e.target == "File" => format!( + "sequence(&mut h, {seq:?}, false);\n assert!(h.state().backstage.is_some(), \"{seq} must open the File view\");\n assert_eq!(h.state().keytips.prefix(), Some({seq:?}));", + seq = e.seq, + ), "tab" => format!( "sequence(&mut h, {seq:?}, false);\n assert_eq!(h.state().ui.ribbon_tab, {tab:?}, \"{seq} must switch to the {tab} tab\");", seq = e.seq,