diff --git a/ROADMAP.md b/ROADMAP.md index 9ba8709d..da06359b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -155,6 +155,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) | | 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values | | 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values | +| 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-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. | | 2026-10-13 | `formulas.evaluate` (`gridcraft-cli eval`, MCP `evaluate_formula`) refuses what entering the formula in a cell refuses, with the reason (`=SUM(1;2)`: "expected `,` or `)`"), instead of `#NAME?` for any formula that doesn't parse | | 2026-10-13 | Colour menus: Fill Color, Font Color and the Format Cells pickers end with Recent Colors (the last ten custom colours) and More Colors… (hue/saturation area, RGB, hex box, preview, OK/Cancel) (#928) | diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index eda675e7..356d7189 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 5415b9d2..d14af141 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 2f7c64a9..d94128de 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -38,40 +38,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 a788529d..96bdc075 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": [ @@ -203,36 +204,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", @@ -245,78 +259,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", @@ -328,9 +373,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 67635935..14c44233 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1392,7 +1392,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/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index fe70a5c9..d3cb84cf 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -316,7 +316,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] @@ -337,7 +340,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] @@ -351,7 +357,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] @@ -414,7 +420,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/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 92cd8a23..44f4dc32 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -680,3 +680,96 @@ fn font_dropdown_highlight_follows_the_filtered_rows() { h.run_steps(3); assert!(h.query_all_by_label("Georgia").count() >= 1, "an emptied filter lists every font again"); } + +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/docs/ui-parity.md b/docs/ui-parity.md index 4795fb19..5d5f1efe 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -86,7 +86,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; letters also work with left Alt held (Alt+H, O, A), as in Excel; 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; each Borders letter (H, B, …) draws its own preset, H, B, M opens Format Cells on the Border tab, and Erase Border, Line Color and Line Style are reserved; QAT/Backstage excluded | | Requests | — | #110 (more shortcuts and hot keys), #33 (sequential Alt; initial support landed in #43) | ### Shortcut audit (#110) @@ -203,6 +203,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-10-15 | minor | Number-format named colors use their RGB values; indexed `[Color1]`–`[Color56]` resolve through the shared legacy palette in grid and PDF (#1452) | | 2026-10-14 | minor | Spill-child formula bar uses the anchor formula with disabled theme text and blocks formula-bar edit entry; selection and recalculation restore ordinary behavior | | 2026-10-14 | minor | PivotTable source-field list fills the pane width so its scrollbar stays at the list's right edge | +| 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-13 | minor | Dragging a sheet tab with Ctrl held (Cmd or Option on macOS) drops a copy of the sheet where the tab is released, with the copy cursor (#971) | | 2026-10-13 | trivial | More Colors… and Recent Colors (#928) | | 2026-10-13 | minor | Grid drags keep their own cursor over selection borders, fill handles and outside the grid; hover resumes on release (#936) | diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs index f6df981b..efbd4594 100644 --- a/xtask/src/keytips.rs +++ b/xtask/src/keytips.rs @@ -49,25 +49,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()) || UI_DIALOG_COMMANDS.iter().any(|(c, _)| c == id) { 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 @@ -77,7 +87,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 } @@ -137,6 +148,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()) { @@ -168,6 +186,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(), }); @@ -175,10 +194,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(); @@ -192,7 +211,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(); @@ -224,7 +243,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(), @@ -285,7 +306,7 @@ fn render_tests(entries: &[Entry], probe: &Probe) -> String { 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,