Browse / github.com/storytold/craft-libs / patch
Share contextual keytip letter allocation
Share contextual keytip letter allocation Allocate collision-free contextual menu/tab letters while respecting reserved prefixes and alphabet exhaustion. Makes the reusable part of [WordCraft #3855](https://github.com/storytold/wordcraft/pull/3855) available to other Craft apps. Includes API documentation, integration guidance and regression coverage. Consumer adoption remains separate; applications retain their themes, localization and command adapters. Validation on Windows: workspace formatting, strict Clippy and tests; craft-ui Clippy/tests with all features; craft-ui WASM check with all features. Upstream CI results are tracked
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- fluorine (imported from pull request #280, open)
- Base
2eca9b3a53a98ac52efed2a27353c6a6927ead10- Size
- +102 −1 in 5 files
- Review
- pending
- Shared
- 2026-10-28
- Id
1f150500e95c18201a3c655c
How it went
No other operator has reported using it yet.
Diff
raw · crates/fonts-build/src/tests.rs crates/keymap/README.md crates/keymap/docs/contextual-keytips.md crates/keymap/src/keytips.rs crates/keymap/src/lib.rs
@@ -376,7 +376,10 @@ fn for_ui_embeds_cjk_faces_only_as_cut_interface_faces() { assert_eq!(warnings(&r), Vec::<String>::new()); let r = run(Embed::documents(&["Noto Sans TC UI", "Noto Sans TC"]), Some(&dir), Some("1")); assert_eq!(r.result.as_ref().unwrap(), &["Noto Sans TC Regular", "Noto Sans TC UI Regular"]);- assert!(r.table.contains("fonts/1.ttf"), "{}", r.table);+ // Generated Rust escapes native separators (and Windows' canonical path prefix).+ // Match the selected file's complete include expression on every platform.+ let interface_face = dir.join("fonts/1.ttf").canonicalize().unwrap();+ assert!(r.table.contains(&format!("include_bytes!({interface_face:?})")), "{}", r.table); } #[test]@@ -7,6 +7,8 @@ supplies its command catalog, its file-format readers and its UI. ## Boundaries +For contextual ribbon/menu letter allocation, see [contextual keytips](docs/contextual-keytips.md).+ - Data and logic, plus a shared Keyboard Shortcuts dialog behind the `ui` feature. The apps keep the command catalog (ids, labels, menu paths, defaults), the readers for other applications' files, file dialogs and running the commands.new file mode 100644@@ -0,0 +1,19 @@+# Contextual keytip allocation++`craft_keymap::keytips::allocate` shares the collision-free letter allocation from+[WordCraft #3855](https://github.com/storytold/wordcraft/pull/3855). It is useful for contextual+ribbon tabs and menus in other Craft apps while leaving their command catalogs and Alt-key+handling in each host.++Pass labels in stable order and reserve every fixed tab/search/legacy-menu prefix. Multi-key+sequences reserve their first letter, so a generated single-letter tip cannot shadow them.+Allocation prefers an available letter from the label, then the first free A-Z letter.++Allocate over all possible contextual tabs once, then display the active subset. This keeps+letters stable as selections change. Stable source labels or localized labels are both possible+host policies; non-Latin labels use the A-Z fallback. No transliteration is attempted. Exhausted+letters return `None`, meaning the control stays usable without a generated keytip.++The result covers at most 4096 labels and reports omitted choices; at most 256 characters per+label are scanned. Reserved prefixes are all inspected. This adds no global state, bindings or+app-specific legacy key reservations, and it does not change existing `Keymap` shortcut dispatch.new file mode 100644@@ -0,0 +1,76 @@+//! Deterministic single-letter keytips for contextual menus and tabs.+//!+//! This shares allocation policy, not an application's Alt-key state machine or command catalog.+//! Allocate over the full ordered set of possible contextual tabs, then display its active subset;+//! allocating only visible tabs can change their letters when another tab appears.++const MAX_CHOICES: usize = 4096;+const MAX_LABEL_CHARS: usize = 256;++/// One optional ASCII A-Z letter per considered label, in input order. `None` means all letters+/// are reserved or already assigned; the host should keep the control available without a badge.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct Allocation {+ pub letters: Vec<Option<char>>,+ /// Labels after the first 4096 are not considered. Report this if exposing imported catalogs.+ pub omitted: usize,+}++/// Prefer the first unused ASCII letter in each label, then the first free A-Z letter.+/// Reserved sequences protect their first letter, case-insensitively, including prefixes of+/// multi-key tips. Empty/non-letter prefixes reserve no A-Z key. Assignments never duplicate+/// another result or shadow a reserved prefix. Exhaustion is explicit, not a fabricated key.+///+/// The caller supplies its reserved fixed tabs, search and legacy-menu prefixes. Labels may be+/// localized or stable source labels according to host policy; non-Latin names fall back to A-Z+/// rather than attempting transliteration. At most 4096 choices and 256 characters per label are+/// scanned. All reserved prefixes are inspected so truncation cannot introduce a collision.+pub fn allocate(labels: &[&str], reserved: &[&str]) -> Allocation {+ let mut taken = [false; 26];+ let index = |c: char| {+ if c.is_ascii_alphabetic() { Some((c.to_ascii_uppercase() as u8 - b'A') as usize) } else { None }+ };+ for text in reserved {+ if let Some(i) = text.chars().next().and_then(index) {+ taken[i] = true;+ }+ }+ let mut letters = Vec::with_capacity(labels.len().min(MAX_CHOICES));+ for label in labels.iter().take(MAX_CHOICES) {+ let preferred = label.chars().take(MAX_LABEL_CHARS).filter_map(index).find(|&i| !taken[i]);+ let free = preferred.or_else(|| taken.iter().position(|used| !used));+ letters.push(free.map(|i| {+ taken[i] = true;+ char::from(b'A' + i as u8)+ }));+ }+ Allocation { letters, omitted: labels.len().saturating_sub(MAX_CHOICES) }+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test]+ fn contextual_letters_avoid_fixed_and_legacy_prefixes_even_when_labels_repeat() {+ let allocation = allocate(&["Picture Format", "Shape Format", "Shape Format", "图表"], &["P", "S", "jT", "Q", "fO"]);+ let letters = allocation.letters.iter().flatten().copied().collect::<Vec<_>>();+ assert_eq!(letters.len(), 4);+ let unique = letters.iter().copied().collect::<std::collections::HashSet<_>>();+ assert_eq!(unique.len(), 4);+ assert!(letters.iter().all(|c| !"PSJQF".contains(*c)));+ assert_eq!(allocate(&["Picture Format", "Shape Format", "Shape Format", "图表"], &["P", "S", "jT", "Q", "fO"]), allocation);+ }+ #[test]+ fn exhausted_alphabet_is_explicit_and_large_catalogs_are_reported() {+ let labels = vec!["A"; 5000];+ let allocation = allocate(&labels, &[]);+ assert_eq!(allocation.letters.len(), MAX_CHOICES);+ assert_eq!(allocation.omitted, 5000 - MAX_CHOICES);+ assert_eq!(allocation.letters.iter().filter(|c| c.is_some()).count(), 26);+ let reserved = allocate(+ &["Anything"],+ &["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"],+ );+ assert_eq!(reserved.letters, vec![None]);+ }+}@@ -142,6 +142,7 @@ pub mod editor; pub mod egui; pub mod import; pub mod keymap;+pub mod keytips; pub mod layout; pub mod profiles; pub mod reserved;