Merge fork's border keytips fix into main

This commit is contained in:
Harrison Booth
2026-10-23 16:39:13 -05:00
10 changed files with 258 additions and 70 deletions
+5
View File
@@ -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),
+2 -2
View File
@@ -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,
}
}
@@ -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
+68 -22
View File
@@ -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",
+12
View File
@@ -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) => {
+13 -4
View File
@@ -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]
+93
View File
@@ -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");
}
}