diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..708f80383 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (Alt, H, B border keytips apply their own preset) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-14 | Border keytips: each Alt, H, B letter applies the preset Excel's Borders menu gives it (H,B,O bottom, H,B,B bottom double, H,B,N no border, H,B,A all borders and the rest); they all drew a bottom border before. H,B,M opens Format Cells on the Border tab | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index e1163d9bb..07324dac7 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -10,7 +10,12 @@ pub mod keytip_table; pub enum Action { Tab(&'static str), Command(&'static str), + /// A command run with fixed params (a JSON object literal from keytips.json `params`), for + /// menu items that share one command id, like the Borders presets (HBO bottom, HBN none). + CommandWith(&'static str, &'static str), Dialog(&'static str), + /// A dialog opened with fixed params, like Format Cells on its Border tab (HBM). + DialogWith(&'static str, &'static str), /// The target command does not exist yet: the sequence is reserved, its badge shows, and /// invoking it is a silent no-op until the command lands (see keytips.json `pending`). Pending(&'static str), diff --git a/crates/ui-egui/src/keytips/keytip_table.rs b/crates/ui-egui/src/keytips/keytip_table.rs index 5415b9d2b..d14af141b 100644 --- a/crates/ui-egui/src/keytips/keytip_table.rs +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -50,8 +50,8 @@ pub fn is_group_level(seq: &str) -> bool { fn matches_target(action: &Action, target: &str) -> bool { match action { - Action::Command(id) | Action::Pending(id) | Action::Control(id) => *id == target, - Action::Dialog(name) => format!("dialog:{name}") == target, + Action::Command(id) | Action::CommandWith(id, _) | Action::Pending(id) | Action::Control(id) => *id == target, + Action::Dialog(name) | Action::DialogWith(name, _) => format!("dialog:{name}") == target, Action::Tab(_) => false, } } diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 79920b5b8..5f750e770 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -37,40 +37,40 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("HAN", Entry::Finish(Action::Command("home.accounting"))), // Excel: Accounting Format; fires immediately in Excel; deeper aliases dropped as unreachable ("HAS", Entry::Finish(Action::Command("home.addIns"))), // Excel: Add-ins; containment(Add-ins) ("HB", Entry::Continue), // Excel: Borders Menu; group level: - ("HBA", Entry::Finish(Action::Command("home.borders"))), // Excel: All Borders; containment(Borders) - ("HBB", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Double Border; border-side TBD - ("HBC", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Thick Bottom Border; border-side TBD - ("HBD", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Bottom Border; border-side TBD - ("HBE", Entry::Finish(Action::Command("home.borders"))), // Excel: Erase Border; border-side TBD - ("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border) - ("HBH", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Bottom Border; border-side TBD - ("HBI", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Color; Line Color (submenu TBD) - ("HBL", Entry::Finish(Action::Command("home.borders"))), // Excel: Left Border; border-side TBD - ("HBM", Entry::Finish(Action::Command("home.borders"))), // Excel: More Borders; containment(Borders) - ("HBN", Entry::Finish(Action::Command("home.borders"))), // Excel: No Border; border-side TBD - ("HBO", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Border; border-side TBD - ("HBP", Entry::Finish(Action::Command("home.borders"))), // Excel: Top Border; border-side TBD - ("HBR", Entry::Finish(Action::Command("home.borders"))), // Excel: Right Border; border-side TBD - ("HBS", Entry::Finish(Action::Command("home.borders"))), // Excel: Outside Borders; containment(Borders) - ("HBT", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Outside Borders; border-side TBD - ("HBU", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Double Bottom Border; border-side TBD - ("HBW", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border - ("HBY", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Style; Line Style (submenu TBD) - ("HC", Entry::Finish(Action::Command("edit.copy"))), // Excel: Copy; fires immediately in Excel; deeper aliases dropped as unreachable - ("HDC", Entry::Finish(Action::Command("home.deleteColumns"))), // Excel: Delete Columns; Delete Columns - ("HDD", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Delete Cells - ("HDR", Entry::Finish(Action::Command("home.deleteRows"))), // Excel: Delete Rows; Delete Rows - ("HDS", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Delete Sheet - ("HEA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Clear All - ("HEC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Clear Contents - ("HEF", Entry::Finish(Action::Command("edit.clearFormats"))), // Excel: Clear Formats + ("HBA", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"all\"}"))), // Excel: All Borders; Borders menu item + ("HBB", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"doubleBottom\"}"))), // Excel: Bottom Double Border; Borders menu item + ("HBC", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topThickBottom\"}"))), // Excel: Top and Thick Bottom Border; Borders menu item + ("HBD", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topBottom\"}"))), // Excel: Top and Bottom Border; Borders menu item + ("HBE", Entry::Finish(Action::Pending("home.eraseBorder"))), // pending; Excel: Erase Border; pending: Excel arms an eraser pen; GridCraft has no border pen mode yet + ("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border) + ("HBH", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickBottom\"}"))), // Excel: Thick Bottom Border; Borders menu item + ("HBI", Entry::Finish(Action::Pending("home.borderLineColor"))), // pending; Excel: Line Color; pending: Line Color submenu + ("HBL", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"left\"}"))), // Excel: Left Border; Borders menu item + ("HBM", Entry::Finish(Action::DialogWith("formatCells", "{\"tab\":\"Border\"}"))), // Excel: More Borders; Format Cells on its Border tab + ("HBN", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"none\"}"))), // Excel: No Border; Borders menu item + ("HBO", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"bottom\"}"))), // Excel: Bottom Border; Borders menu item + ("HBP", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"top\"}"))), // Excel: Top Border; Borders menu item + ("HBR", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"right\"}"))), // Excel: Right Border; Borders menu item + ("HBS", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"outside\"}"))), // Excel: Outside Borders; Borders menu item + ("HBT", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickOutside\"}"))), // Excel: Thick Outside Borders; Borders menu item + ("HBU", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topDoubleBottom\"}"))), // Excel: Top and Double Bottom Border; Borders menu item + ("HBW", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border + ("HBY", Entry::Finish(Action::Pending("home.borderLineStyle"))), // pending; Excel: Line Style; pending: Line Style submenu + ("HC", Entry::Finish(Action::Command("edit.copy"))), // Excel: Copy; fires immediately in Excel; deeper aliases dropped as unreachable + ("HDC", Entry::Finish(Action::Command("home.deleteColumns"))), // Excel: Delete Columns; Delete Columns + ("HDD", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Delete Cells + ("HDR", Entry::Finish(Action::Command("home.deleteRows"))), // Excel: Delete Rows; Delete Rows + ("HDS", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Delete Sheet + ("HEA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Clear All + ("HEC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Clear Contents + ("HEF", Entry::Finish(Action::Command("edit.clearFormats"))), // Excel: Clear Formats ("HEL", Entry::Finish(Action::Command("edit.clearHyperlinks"))), // Excel: Clear Hyperlinks - ("HEM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Clear Comments and Notes; containment(Clear Comments and Notes) - ("HFC", Entry::Finish(Action::Control("home.fontColor"))), // Excel: Font Color; opens the font-color palette + ("HEM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Clear Comments and Notes; containment(Clear Comments and Notes) + ("HFC", Entry::Finish(Action::Control("home.fontColor"))), // Excel: Font Color; opens the font-color palette ("HFDC", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Find Conditional Formatting; containment(Conditional Formatting) - ("HFDF", Entry::Finish(Action::Dialog("find"))), // Excel: Find - ("HFDG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Go To - ("HFDM", Entry::Finish(Action::Command("edit.find"))), // Excel: Find Notes; containment(Find) + ("HFDF", Entry::Finish(Action::Dialog("find"))), // Excel: Find + ("HFDG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Go To + ("HFDM", Entry::Finish(Action::Command("edit.find"))), // Excel: Find Notes; containment(Find) ("HFDO", Entry::Finish(Action::Pending("home.selectObjects"))), // pending; Excel: Select Objects; Select Objects; reserved until the command lands ("HFDP", Entry::Finish(Action::Command("arrange.selectionPane"))), // Excel: Selection Pane ("HFDR", Entry::Finish(Action::Dialog("find"))), // Excel: Replace; opens Find and Replace diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 59aeadcfb..b24241c3c 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -8,6 +8,7 @@ " \"continue\" (an explicit group level with no deeper sequences yet).", " - Intermediate levels need no entries: the matcher waits automatically when deeper sequences exist.", " - pending: true = the command does not exist yet; the sequence stays reserved and silent until it lands.", + " - params: an optional JSON object passed to a command or dialog, for menu items that share one id (the Borders presets: HBO {\"preset\": \"bottom\"}).", " - 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": [ @@ -196,36 +197,49 @@ "seq": "HBA", "kind": "command", "target": "home.borders", + "params": { + "preset": "all" + }, "excel": "All Borders", - "note": "containment(Borders)" + "note": "Borders menu item" }, { "seq": "HBB", "kind": "command", "target": "home.borders", + "params": { + "preset": "doubleBottom" + }, "excel": "Bottom Double Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBC", "kind": "command", "target": "home.borders", + "params": { + "preset": "topThickBottom" + }, "excel": "Top and Thick Bottom Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBD", "kind": "command", "target": "home.borders", + "params": { + "preset": "topBottom" + }, "excel": "Top and Bottom Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBE", "kind": "command", - "target": "home.borders", + "target": "home.eraseBorder", + "pending": true, "excel": "Erase Border", - "note": "border-side TBD" + "note": "pending: Excel arms an eraser pen; GridCraft has no border pen mode yet" }, { "seq": "HBG", @@ -238,78 +252,109 @@ "seq": "HBH", "kind": "command", "target": "home.borders", + "params": { + "preset": "thickBottom" + }, "excel": "Thick Bottom Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBI", "kind": "command", - "target": "home.borders", + "target": "home.borderLineColor", + "pending": true, "excel": "Line Color", - "note": "Line Color (submenu TBD)" + "note": "pending: Line Color submenu" }, { "seq": "HBL", "kind": "command", "target": "home.borders", + "params": { + "preset": "left" + }, "excel": "Left Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBM", - "kind": "command", - "target": "home.borders", + "kind": "dialog", + "target": "formatCells", + "params": { + "tab": "Border" + }, "excel": "More Borders", - "note": "containment(Borders)" + "note": "Format Cells on its Border tab" }, { "seq": "HBN", "kind": "command", "target": "home.borders", + "params": { + "preset": "none" + }, "excel": "No Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBO", "kind": "command", "target": "home.borders", + "params": { + "preset": "bottom" + }, "excel": "Bottom Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBP", "kind": "command", "target": "home.borders", + "params": { + "preset": "top" + }, "excel": "Top Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBR", "kind": "command", "target": "home.borders", + "params": { + "preset": "right" + }, "excel": "Right Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBS", "kind": "command", "target": "home.borders", + "params": { + "preset": "outside" + }, "excel": "Outside Borders", - "note": "containment(Borders)" + "note": "Borders menu item" }, { "seq": "HBT", "kind": "command", "target": "home.borders", + "params": { + "preset": "thickOutside" + }, "excel": "Thick Outside Borders", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBU", "kind": "command", "target": "home.borders", + "params": { + "preset": "topDoubleBottom" + }, "excel": "Top and Double Bottom Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBW", @@ -321,9 +366,10 @@ { "seq": "HBY", "kind": "command", - "target": "home.borders", + "target": "home.borderLineStyle", + "pending": true, "excel": "Line Style", - "note": "Line Style (submenu TBD)" + "note": "pending: Line Style submenu" }, { "seq": "HC", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f96cdac3c..4e7de595a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1193,7 +1193,19 @@ impl SheetApp { keytips::Action::Command(id @ ("edit.copy" | "edit.cut")) => self.copy_to_clipboard(ctx, id), keytips::Action::Command("edit.paste") => self.paste_from_clipboard(), keytips::Action::Command(id) => self.run_or_alert(id, json!({})), + keytips::Action::CommandWith(id, params) => { + let params: Json = serde_json::from_str(params).unwrap_or_else(|_| json!({})); + // A Borders preset picked by key tip becomes the split button's face, as a + // click on the same menu item does. + if id == "home.borders" + && let Some(preset) = params.get("preset").and_then(Json::as_str) + { + self.grid.last_border = preset.to_string(); + } + self.run_or_alert(id, params); + } keytips::Action::Dialog(name) => self.open_dialog(name, json!({})), + keytips::Action::DialogWith(name, params) => self.open_dialog(name, serde_json::from_str(params).unwrap_or_else(|_| json!({}))), // Reserved sequence: fires silently while its command is missing. When the // command lands in the catalog it goes live with no table edit. keytips::Action::Pending(id) => { diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 24be742a5..76ddd4467 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -550,3 +550,96 @@ fn every_sequence_terminates_without_panicking() { } assert_eq!(SEQUENCES.len(), SEQUENCES.len(), "every sequence ran"); } + +fn borders(h: &Harness, cell: &str) -> gridcraft_engine::model::Borders { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).borders +} + +/// [left, right, top, bottom] line styles of `cell`. +fn sides(h: &Harness, cell: &str) -> [gridcraft_engine::model::BorderStyle; 4] { + let b = borders(h, cell); + [b.left.style, b.right.style, b.top.style, b.bottom.style] +} + +fn selected(range: &str) -> Session { + let mut s = blank(); + s.execute("selection.set", json!({"range": range})).unwrap(); + s +} + +#[test] +fn border_keytips_apply_their_own_preset() { + use gridcraft_engine::model::BorderStyle::{Double, None as N, Thick, Thin}; + // Alt, H, B, letter on A1 alone: each Borders menu letter draws its own preset (they all drew + // a bare bottom border before). Letters observed in Excel's Borders menu. + for (k, want) in [ + (Key::O, [N, N, N, Thin]), + (Key::P, [N, N, Thin, N]), + (Key::L, [Thin, N, N, N]), + (Key::R, [N, Thin, N, N]), + (Key::A, [Thin, Thin, Thin, Thin]), + (Key::S, [Thin, Thin, Thin, Thin]), + (Key::T, [Thick, Thick, Thick, Thick]), + (Key::B, [N, N, N, Double]), + (Key::H, [N, N, N, Thick]), + (Key::D, [N, N, Thin, Thin]), + (Key::C, [N, N, Thin, Thick]), + (Key::U, [N, N, Thin, Double]), + ] { + let mut h = harness(selected("A1")); + sequence(&mut h, &[Key::H, Key::B, k], false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "H,B,{k:?} must run cleanly"); + assert_eq!(sides(&h, "A1"), want, "H,B,{k:?}"); + } +} + +#[test] +fn border_keytip_no_border_clears_a_bordered_cell() { + use gridcraft_engine::model::BorderStyle::None as N; + let mut s = selected("A1"); + s.execute("home.borders", json!({"range": "A1", "preset": "all"})).unwrap(); + let mut h = harness(s); + sequence(&mut h, &[Key::H, Key::B, Key::N], false); + assert_eq!(sides(&h, "A1"), [N, N, N, N], "H,B,N must clear every side, not add a bottom border"); + assert_eq!(h.state().grid.last_border, "none", "the picked preset becomes the Borders button's face"); +} + +#[test] +fn border_keytip_all_borders_covers_every_side_of_a_block() { + use gridcraft_engine::model::BorderStyle::Thin; + let mut h = harness(selected("A1:B2")); + sequence(&mut h, &[Key::H, Key::B, Key::A], false); + for cell in ["A1", "B1", "A2", "B2"] { + assert_eq!(sides(&h, cell), [Thin; 4], "H,B,A must border every side of {cell}"); + } +} + +#[test] +fn border_keytip_outside_borders_skips_the_inside_edges() { + use gridcraft_engine::model::BorderStyle::{None as N, Thin}; + let mut h = harness(selected("A1:B2")); + sequence(&mut h, &[Key::H, Key::B, Key::S], false); + assert_eq!(sides(&h, "A1"), [Thin, N, Thin, N]); + assert_eq!(sides(&h, "B2"), [N, Thin, N, Thin]); +} + +#[test] +fn border_keytip_more_borders_opens_format_cells_on_the_border_tab() { + let mut h = harness(blank()); + sequence(&mut h, &[Key::H, Key::B, Key::M], false); + let d = h.state().dialog.as_ref().expect("H,B,M opens Format Cells"); + assert_eq!(d.tab, "Border"); +} + +#[test] +fn border_keytips_without_a_command_stay_silent() { + // Erase Border (a pen mode), Line Color and Line Style are reserved: no border is drawn. + for k in [Key::E, Key::I, Key::Y] { + let mut h = harness(selected("A1")); + sequence(&mut h, &[Key::H, Key::B, k], false); + assert!(!h.state().session.journal.iter().any(|(id, _)| id.starts_with("home.")), "H,B,{k:?} must not run a command"); + assert_eq!(borders(&h, "A1"), Default::default(), "H,B,{k:?} must not draw a border"); + } +} diff --git a/crates/ui-egui/tests/keytips_generated.rs b/crates/ui-egui/tests/keytips_generated.rs index 653554857..dcf71cde1 100644 --- a/crates/ui-egui/tests/keytips_generated.rs +++ b/crates/ui-egui/tests/keytips_generated.rs @@ -308,7 +308,10 @@ fn keytip_hbd_() { fn keytip_hbe_() { let mut h = harness(blank()); sequence(&mut h, "HBE", false); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBE must journal home.borders"); + assert!(h.state().dialog.is_none(), "HBE must not open a dialog"); + assert!(h.state().message.is_none(), "HBE must not open a message box"); + assert!(h.state().toast.is_none(), "HBE must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "HBE must not journal"); } #[test] @@ -329,7 +332,10 @@ fn keytip_hbh_() { fn keytip_hbi_() { let mut h = harness(blank()); sequence(&mut h, "HBI", false); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBI must journal home.borders"); + assert!(h.state().dialog.is_none(), "HBI must not open a dialog"); + assert!(h.state().message.is_none(), "HBI must not open a message box"); + assert!(h.state().toast.is_none(), "HBI must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "HBI must not journal"); } #[test] @@ -343,7 +349,7 @@ fn keytip_hbl_() { fn keytip_hbm_() { let mut h = harness(blank()); sequence(&mut h, "HBM", false); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBM must journal home.borders"); + assert!(h.state().dialog.is_some(), "HBM must open the formatCells dialog"); } #[test] @@ -406,7 +412,10 @@ fn keytip_hbw_() { fn keytip_hby_() { let mut h = harness(blank()); sequence(&mut h, "HBY", false); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBY must journal home.borders"); + assert!(h.state().dialog.is_none(), "HBY must not open a dialog"); + assert!(h.state().message.is_none(), "HBY must not open a message box"); + assert!(h.state().toast.is_none(), "HBY must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "HBY must not journal"); } #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..209c55d93 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-14 · **Change:** minor (Alt, H, B border keytips apply their own preset) · **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; QAT/Backstage excluded. Keytips can carry fixed params, so each Borders menu letter (Alt, H, B, O/P/L/R/N/A/S/T/B/H/D/C/U) draws its own preset and B, M opens Format Cells on the Border tab; Erase Border, Line Color and Line Style are reserved | | 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-14 | minor | Alt, H, B border keytips apply the preset each letter names in Excel's Borders menu (they all drew a bottom border, so H,B,N No Border added one): keytips.json entries take optional `params`; H,B,M opens Format Cells on the Border tab; Erase Border, Line Color and Line Style stay reserved | | 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 | diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs index 36847222f..11e7773aa 100644 --- a/xtask/src/keytips.rs +++ b/xtask/src/keytips.rs @@ -44,25 +44,35 @@ struct Entry { kind: String, target: String, pending: bool, + /// Fixed params as a compact JSON object (`params` in keytips.json), empty for none. + params: String, excel: String, note: String, } +impl Entry { + /// The probe key: a command and the params its keytip passes. + fn probe_key(&self) -> String { + if self.params.is_empty() { self.target.clone() } else { format!("{} {}", self.target, self.params) } + } +} + /// 0 = ran and journaled, 1 = ran without a journal step, 2 = errored (params -/// missing, disabled, or environment-dependent). +/// missing, disabled, or environment-dependent). Keyed by [`Entry::probe_key`]. type Probe = std::collections::HashMap; #[cfg(feature = "engine")] -fn probe_live_commands(ids: &[String]) -> Probe { +fn probe_live_commands(calls: &[(String, String)]) -> Probe { use gridcraft_engine::Session; let mut map = Probe::new(); - for id in ids { + for (id, params) in calls { if ENV_SENSITIVE.contains(&id.as_str()) { continue; } + let params = if params.is_empty() { serde_json::json!({}) } else { serde_json::from_str(params).unwrap_or_default() }; let mut s = Session::new(); s.new_workbook(); - let status = match s.execute(id, serde_json::json!({})) { + let status = match s.execute(id, params.clone()) { Ok(_) => { if s.journal.iter().any(|(jid, _)| jid == id) { 0 @@ -72,7 +82,8 @@ fn probe_live_commands(ids: &[String]) -> Probe { } Err(_) => 2, }; - map.insert(id.clone(), status); + let key = if params.as_object().is_some_and(|o| !o.is_empty()) { format!("{id} {params}") } else { id.clone() }; + map.insert(key, status); } map } @@ -132,6 +143,13 @@ fn parse_and_probe(source: &str) -> Result<(Vec, Probe), String> { if pending && kind != "command" { return Err(format!("{at}: `pending` is only valid with kind `command`")); } + let params = match e.get("params") { + None => String::new(), + Some(v) if v.is_object() && matches!(kind.as_str(), "command" | "dialog") && !pending => { + serde_json::to_string(v).map_err(|e| format!("{at}: params: {e}"))? + } + Some(_) => return Err(format!("{at}: `params` must be a JSON object, on a non-pending command or dialog")), + }; match kind.as_str() { "tab" => { if target.is_empty() || !TABS.contains(&target.as_str()) { @@ -163,6 +181,7 @@ fn parse_and_probe(source: &str) -> Result<(Vec, Probe), String> { kind, target, pending, + params, excel: e.get("excel").and_then(|v| v.as_str()).unwrap_or("").to_string(), note: e.get("note").and_then(|v| v.as_str()).unwrap_or("").to_string(), }); @@ -170,10 +189,10 @@ fn parse_and_probe(source: &str) -> Result<(Vec, Probe), String> { // JSON order is authoritative and curated: the first entry for a tab is its canonical // badge (legacy aliases come later), and groups stay together. Do not sort. - let live_ids: Vec = entries + let live_calls: Vec<(String, String)> = entries .iter() .filter(|e| e.kind == "command" && !e.pending) - .map(|e| e.target.clone()) + .map(|e| (e.target.clone(), e.params.clone())) .collect::>() .into_iter() .collect(); @@ -187,7 +206,7 @@ fn parse_and_probe(source: &str) -> Result<(Vec, Probe), String> { e.target.clone() }) .collect(); - let mut probe = probe_live_commands(&live_ids); + let mut probe = probe_live_commands(&live_calls); for id in landed { let mut s = gridcraft_engine::Session::new(); s.new_workbook(); @@ -219,7 +238,9 @@ fn render_table(entries: &[Entry]) -> String { let value = match e.kind.as_str() { "tab" => format!("Entry::Tab({:?})", e.target), "command" if e.pending => format!("Entry::Finish(Action::Pending({:?}))", e.target), + "command" if !e.params.is_empty() => format!("Entry::Finish(Action::CommandWith({:?}, {:?}))", e.target, e.params), "command" => format!("Entry::Finish(Action::Command({:?}))", e.target), + "dialog" if !e.params.is_empty() => format!("Entry::Finish(Action::DialogWith({:?}, {:?}))", e.target, e.params), "dialog" => format!("Entry::Finish(Action::Dialog({:?}))", e.target), "control" => format!("Entry::Finish(Action::Control({:?}))", e.target), "continue" => "Entry::Continue".to_string(), @@ -269,7 +290,7 @@ fn render_tests(entries: &[Entry], probe: &Probe) -> String { "sequence(&mut h, {seq:?}, false);\n assert!(h.state().dialog.is_none(), \"{seq} must not open a dialog\");\n assert!(h.state().message.is_none(), \"{seq} must not open a message box\");\n assert!(h.state().toast.is_none(), \"{seq} must not raise a toast\");\n assert!(h.state().session.journal.is_empty(), \"{seq} must not journal\");", seq = e.seq, ), - "command" => match probe.get(&e.target).copied().unwrap_or(2) { + "command" => match probe.get(&e.probe_key()).copied().unwrap_or(2) { 0 => format!( "sequence(&mut h, {seq:?}, false);\n assert!(h.state().session.journal.iter().any(|(id, _)| id == {id:?}), \"{seq} must journal {id}\");", seq = e.seq,