mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-10 20:40:05 -06:00
Review follow-up. item_letter_with compared the compact params string, so a key order or spacing difference between keytips.json and the caller would miss the item. It now takes a serde_json::Value and compares parsed values. Tests add prefixes that must not open the Borders menu (a finished sequence, another group's level, another control) and an unknown preset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
303 lines
14 KiB
Rust
303 lines
14 KiB
Rust
//! The keytip sequence table: every Excel-style Alt access sequence and what it does.
|
|
//!
|
|
//! The matcher in [`super::KeyTips::process`] and the badge painter in `ribbon.rs` both read
|
|
//! [`SEQUENCES`], so a sequence is added in exactly one place: `keytips.json`.
|
|
//!
|
|
//! `keytips.json` is the human-edited source of truth (grouped, commented, one line per
|
|
//! sequence; `pending` reserves sequences for commands that do not exist yet). `generated.rs`
|
|
//! is produced from it by `cargo xtask keytips` — edit the JSON, not the generated file.
|
|
|
|
pub(crate) use super::Action;
|
|
|
|
mod generated;
|
|
pub use generated::SEQUENCES;
|
|
|
|
/// What a fully-matched sequence does.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Entry {
|
|
/// Switch to this ribbon tab and keep the prefix: the tab's own badges take over.
|
|
Tab(&'static str),
|
|
/// Keep the prefix: this sequence is a group-level badge; deeper sequences finish it.
|
|
Continue,
|
|
/// Complete the sequence: run the action and clear the prefix.
|
|
Finish(Action),
|
|
}
|
|
|
|
/// Look up a complete sequence.
|
|
pub fn lookup(sequence: &str) -> Option<Entry> {
|
|
SEQUENCES.iter().find(|(seq, _)| *seq == sequence).map(|(_, entry)| *entry)
|
|
}
|
|
|
|
/// True if `prefix` is a strict prefix of any known sequence (badges wait for more keys).
|
|
pub fn has_deeper(prefix: &str) -> bool {
|
|
SEQUENCES.iter().any(|(seq, _)| seq.len() > prefix.len() && seq.starts_with(prefix))
|
|
}
|
|
|
|
/// The sequence that switches to `tab_name`, if that tab has one (may be a multi-letter unit
|
|
/// like Draw's "JI").
|
|
pub fn tab_sequence(tab_name: &str) -> Option<&'static str> {
|
|
SEQUENCES.iter().find_map(|(seq, entry)| match entry {
|
|
Entry::Tab(name) if *name == tab_name => Some(*seq),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// True if `seq` is a group level: an explicit Continue entry, or an implicit level that only
|
|
/// exists because deeper sequences start with it (like "J" before Draw's "JI").
|
|
pub fn is_group_level(seq: &str) -> bool {
|
|
matches!(lookup(seq), Some(Entry::Continue)) || (lookup(seq).is_none() && has_deeper(seq))
|
|
}
|
|
|
|
fn matches_target(action: &Action, target: &str) -> bool {
|
|
match action {
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// All sequences that finish by running `target` (a command id or `dialog:<name>`).
|
|
pub fn sequences_for(target: &str) -> Vec<&'static str> {
|
|
SEQUENCES
|
|
.iter()
|
|
.filter_map(|(seq, entry)| match entry {
|
|
Entry::Finish(action) if matches_target(action, target) => Some(*seq),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Group-level letters for a menu anchor: sequences one key past `prefix` that continue toward
|
|
/// any of `targets` (the menu's items) — the badge shown on the button that opens the menu.
|
|
pub fn group_extension(prefix: &str, targets: &[&str]) -> Vec<&'static str> {
|
|
let mut out = Vec::new();
|
|
for target in targets {
|
|
for seq in sequences_for(target) {
|
|
if seq.len() <= prefix.len() + 1 || !seq.starts_with(prefix) {
|
|
continue;
|
|
}
|
|
// The intermediate one key past the prefix is the group badge, if it is a
|
|
// group-level sequence (explicit or implicit) rather than the finish itself.
|
|
let candidate = &seq[..prefix.len() + 1];
|
|
if is_group_level(candidate) && !out.contains(&candidate) {
|
|
out.push(candidate);
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The badge for a menu item: its finish sequence exactly one key below the current prefix.
|
|
pub fn item_letter(prefix: &str, target: &str) -> Option<&'static str> {
|
|
sequences_for(target).into_iter().find(|seq| seq.len() == prefix.len() + 1 && seq.starts_with(prefix))
|
|
}
|
|
|
|
/// True when `prefix` is the group level of a menu control: some of `target`'s sequences finish
|
|
/// one key below it (Borders' "HB" before HBA..HBW). The menu opens there, as Excel's does, so its
|
|
/// item letters show.
|
|
pub fn opens_menu(prefix: &str, target: &str) -> bool {
|
|
!prefix.is_empty() && is_group_level(prefix) && sequences_for(target).iter().any(|seq| seq.len() == prefix.len() + 1 && seq.starts_with(prefix))
|
|
}
|
|
|
|
/// The badge for a menu item that runs `target` with `params`: its finish sequence one key below
|
|
/// the prefix. Items sharing one command (the Borders presets) are told apart by their params,
|
|
/// compared as JSON values, so key order and spacing don't matter.
|
|
pub fn item_letter_with(prefix: &str, target: &str, params: &serde_json::Value) -> Option<&'static str> {
|
|
SEQUENCES.iter().find_map(|(seq, entry)| match entry {
|
|
Entry::Finish(Action::CommandWith(id, p))
|
|
if *id == target
|
|
&& seq.len() == prefix.len() + 1
|
|
&& seq.starts_with(prefix)
|
|
&& serde_json::from_str::<serde_json::Value>(p).is_ok_and(|p| p == *params) =>
|
|
{
|
|
Some(*seq)
|
|
}
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// Badge sequences for a command control: every sequence for `target` that continues from the
|
|
/// current prefix (the painter strips the prefix, so the remaining letters show).
|
|
pub fn control_badges(prefix: &str, target: &str) -> Vec<&'static str> {
|
|
if prefix.is_empty() {
|
|
return Vec::new(); // empty prefix shows only tab badges
|
|
}
|
|
sequences_for(target).into_iter().filter(|seq| seq.len() > prefix.len() && seq.starts_with(prefix)).collect()
|
|
}
|
|
|
|
/// The badge for a single control: the rest of its finish sequence when it has exactly one, or
|
|
/// the common group letter when several sequences land on the same control (menu anchors like
|
|
/// Borders, whose HBA..HBW variants all share "HB").
|
|
pub fn control_badge(prefix: &str, target: &str) -> Option<&'static str> {
|
|
if prefix.is_empty() {
|
|
return None; // empty prefix shows only tab badges
|
|
}
|
|
let seqs: Vec<&'static str> = sequences_for(target).into_iter().filter(|seq| seq.len() > prefix.len() && seq.starts_with(prefix)).collect();
|
|
match seqs.as_slice() {
|
|
[] => None,
|
|
[single] => Some(single),
|
|
many => {
|
|
let common = |a: &str, b: &str| a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count();
|
|
let lcp = many.iter().skip(1).fold(many[0].len(), |acc, s| acc.min(common(many[0], s)));
|
|
(lcp > prefix.len()).then_some(&many[0][..lcp])
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The single badge for a menu anchor: the direct finish one key past the prefix, or the
|
|
/// group-level candidate that prefixes the most of `targets`' sequences. Anchors get one
|
|
/// badge, so overlapping groups (e.g. Pictures' "NP" vs SmartArt's "NM") resolve to the
|
|
/// dominant one.
|
|
pub fn anchor_badge(prefix: &str, targets: &[&str]) -> Option<&'static str> {
|
|
if prefix.is_empty() {
|
|
return None; // empty prefix shows only tab badges
|
|
}
|
|
let all: Vec<&'static str> = targets.iter().flat_map(|t| sequences_for(t)).filter(|s| s.len() > prefix.len() && s.starts_with(prefix)).collect();
|
|
if all.is_empty() {
|
|
return None;
|
|
}
|
|
let depth = prefix.len() + 1;
|
|
let mut candidates: Vec<&'static str> = all.iter().filter(|s| s.len() == depth).copied().collect();
|
|
if candidates.is_empty() {
|
|
for s in &all {
|
|
let c = &s[..depth.min(s.len())];
|
|
if is_group_level(c) && !candidates.contains(&c) {
|
|
candidates.push(c);
|
|
}
|
|
}
|
|
}
|
|
let mut best: Option<(&'static str, usize)> = None;
|
|
for c in candidates {
|
|
let score = all.iter().filter(|s| s.starts_with(c)).count();
|
|
if best.is_none_or(|(_, b)| score > b) {
|
|
best = Some((c, score));
|
|
}
|
|
}
|
|
best.map(|(c, _)| c)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn tab_letters_exist_for_standard_tabs() {
|
|
for (tab, letter) in [
|
|
("Home", "H"),
|
|
("Insert", "N"),
|
|
("Page Layout", "P"),
|
|
("Formulas", "M"),
|
|
("Data", "A"),
|
|
("Review", "R"),
|
|
("View", "W"),
|
|
("Draw", "JI"), // two-letter unit, shown as one badge
|
|
] {
|
|
assert_eq!(tab_sequence(tab), Some(letter), "tab {tab}");
|
|
}
|
|
assert_eq!(tab_sequence("Automate"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn sequences_for_finds_command_and_dialog_targets() {
|
|
assert_eq!(sequences_for("home.alignRight"), vec!["HAR"]);
|
|
assert_eq!(sequences_for("dialog:pasteSpecial"), vec!["HVS", "ES"]);
|
|
assert_eq!(sequences_for("home.autofitColumnWidth"), vec!["HOI", "OCA"]);
|
|
}
|
|
|
|
#[test]
|
|
fn implicit_levels_wait_for_deeper_sequences() {
|
|
assert!(has_deeper("J"), "JI is reachable only through the implicit J level");
|
|
assert!(is_group_level("J"), "implicit levels are group levels");
|
|
assert!(is_group_level("HV"), "explicit Continue levels are group levels");
|
|
assert!(!is_group_level("H"), "a terminal tab is not a group level");
|
|
assert!(!is_group_level("HVS"), "a finish is not a group level");
|
|
assert!(!has_deeper("X"));
|
|
}
|
|
|
|
#[test]
|
|
fn group_extension_paints_only_continue_levels() {
|
|
assert_eq!(group_extension("H", &["dialog:pasteSpecial"]), vec!["HV"]);
|
|
assert!(group_extension("", &["dialog:pasteSpecial"]).is_empty(), "tab letters paint on tabs, not buttons");
|
|
assert!(group_extension("E", &["dialog:pasteSpecial"]).is_empty(), "the finish letter paints on the menu item");
|
|
}
|
|
|
|
#[test]
|
|
fn menu_levels_and_item_letters_with_params() {
|
|
use serde_json::json;
|
|
assert!(opens_menu("HB", "home.borders"));
|
|
// Prefixes that are not the Borders menu level don't open it.
|
|
assert!(!opens_menu("H", "home.borders"), "the tab level shows the HB badge, not the menu");
|
|
assert!(!opens_menu("", "home.borders"));
|
|
assert!(!opens_menu("HBA", "home.borders"), "a finished sequence is not a menu level");
|
|
assert!(!opens_menu("HI", "home.borders"), "another group's level");
|
|
assert!(!opens_menu("HB", "home.fillColor"), "the Borders level is not another control's menu");
|
|
// Presets sharing home.borders are told apart by their params.
|
|
assert_eq!(item_letter_with("HB", "home.borders", &json!({"preset": "all"})), Some("HBA"));
|
|
assert_eq!(item_letter_with("HB", "home.borders", &json!({"preset": "bottom"})), Some("HBO"));
|
|
assert_eq!(item_letter_with("HB", "home.borders", &json!({"preset": "nope"})), None);
|
|
assert_eq!(item_letter_with("H", "home.borders", &json!({"preset": "all"})), None);
|
|
// Params compare as JSON values: key order and spacing don't matter.
|
|
let parsed: serde_json::Value = serde_json::from_str("{ \"preset\" : \"all\" }").unwrap();
|
|
assert_eq!(item_letter_with("HB", "home.borders", &parsed), Some("HBA"));
|
|
assert_eq!(
|
|
json!({"b": 1, "a": 2}),
|
|
serde_json::from_str::<serde_json::Value>("{\"a\":2,\"b\":1}").unwrap(),
|
|
"maps compare regardless of order"
|
|
);
|
|
// Every preset in the Borders menu has its letter (Inside Borders is GridCraft's own, J).
|
|
for preset in [
|
|
"bottom",
|
|
"top",
|
|
"left",
|
|
"right",
|
|
"none",
|
|
"all",
|
|
"outside",
|
|
"thickOutside",
|
|
"doubleBottom",
|
|
"thickBottom",
|
|
"topBottom",
|
|
"topThickBottom",
|
|
"topDoubleBottom",
|
|
"inside",
|
|
] {
|
|
assert!(item_letter_with("HB", "home.borders", &json!({"preset": preset})).is_some(), "{preset} has no letter");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn item_letter_is_one_level_deep() {
|
|
assert_eq!(item_letter("HV", "dialog:pasteSpecial"), Some("HVS"));
|
|
assert_eq!(item_letter("HV", "edit.paste"), Some("HVP"));
|
|
assert_eq!(item_letter("H", "dialog:pasteSpecial"), None, "the item badge waits for its group letter");
|
|
assert_eq!(item_letter("HO", "home.autofitColumnWidth"), Some("HOI"));
|
|
}
|
|
|
|
#[test]
|
|
fn control_badges_require_a_known_prefix() {
|
|
assert!(control_badges("", "home.alignRight").is_empty(), "empty prefix shows only tab badges");
|
|
assert_eq!(control_badges("HA", "home.alignRight"), vec!["HAR"]);
|
|
assert!(control_badges("HZ", "home.alignRight").is_empty(), "unknown prefixes show nothing");
|
|
}
|
|
|
|
#[test]
|
|
fn control_badge_shows_the_rest_or_the_group_letter() {
|
|
assert_eq!(control_badge("H", "home.bold"), Some("H1"), "a unique sequence paints its full rest");
|
|
assert_eq!(control_badge("H", "home.fontColor"), Some("HFC"));
|
|
assert_eq!(control_badge("H", "home.borders"), Some("HB"), "many variants collapse to the group letter");
|
|
assert!(control_badge("", "home.bold").is_none(), "empty prefix shows only tab badges");
|
|
assert!(control_badge("HZ", "home.bold").is_none(), "unknown prefixes show nothing");
|
|
assert!(control_badge("H", "view.zoom").is_none(), "diverging sequences at the next letter show nothing");
|
|
}
|
|
|
|
#[test]
|
|
fn anchor_badge_resolves_to_the_dominant_group() {
|
|
assert_eq!(anchor_badge("H", &["edit.paste", "edit.pasteSpecial", "dialog:pasteSpecial"]), Some("HV"));
|
|
assert_eq!(anchor_badge("P", &["pageLayout.orientation"]), Some("PO"), "a direct finish paints itself");
|
|
assert_eq!(anchor_badge("N", &["insert.picture"]), Some("NP"), "NP dominates the lone NM (SmartArt) entry");
|
|
assert_eq!(anchor_badge("H", &["table.style"]), Some("HT"));
|
|
assert!(anchor_badge("", &["edit.paste"]).is_none(), "empty prefix shows only tab badges");
|
|
assert!(anchor_badge("H", &["view.zoom"]).is_none(), "no sequences under this prefix");
|
|
}
|
|
}
|