mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-12 08:53:27 -06:00
* feat(engine): format Brazilian date fields and insert localized dates * feat(ui): persist preferred date and time format * feat(ui): implement Word-style Insert > Date & Time modal * feat(ribbon): open Date & Time dialog from Insert tab * test: Brazilian Word TIME fields respect document defaults after DOCX round-trip * fix(docx): refresh Word date/time fields using their run language * ci: add temporary date/time regression validation workflow * style: apply rustfmt output to insert.rs * style: apply rustfmt output to date_fields.rs * style: apply rustfmt output to dialogs_insert.rs * i18n: reuse translated date-format and language labels * i18n(pt-BR): translate Update automatically in date/time dialog * fix(i18n): translate date dialog options and audit generated date previews * chore: remove temporary date/time regression workflow after focused validation * fix(date): resolve inherited locale and preserve it in fixed text * fix(ui): display date insertion errors without closing the dialog and document state * test: cover inherited language, static date text, and escaped date pictures * test(ui): exercise date language, picture, checkbox, OK, and invalid-value feedback via egui * style: apply rustfmt to date insertion and regression tests * fix(engine): borrow caret position while resolving inherited date language * fix(date): honor selected locale when the date picture is omitted * test(ui): target date dialog language combo instead of ribbon menu * test(date): use raw field instruction literal in locale regression * test(date): match actual single-backslash DATE field instruction * test(ui): select actual date dialog language option by accessible label * fix(i18n): add missing automatic date update translation (Romanian) * fix(i18n): add missing automatic date update translation (Slovenian) * fix(ui): open date_time dialog from keytip, normalize pt tags, and document items * fix(ui): default date_time_update preference to false --------- Co-authored-by: felipegavilanes-source <felipegavilanes@gmail.com>
894 lines
38 KiB
Rust
894 lines
38 KiB
Rust
//! Word-style keytips: Alt (or F10) shows letter badges over ribbon tabs, then over a tab's
|
|
//! controls; numbered badges also reach the customized Quick Access Toolbar. Typing never
|
|
//! reaches the document while active.
|
|
|
|
use egui::{Align2, CornerRadius, Rect, Stroke, Ui, Vec2};
|
|
|
|
use crate::WordApp;
|
|
use crate::theme::{Tokens, semibold};
|
|
|
|
/// egui ids the ribbon publishes its keytip state under (accessors, since `Id::new` isn't const).
|
|
pub fn phase_id() -> egui::Id {
|
|
egui::Id::unique("wc_keytips_phase")
|
|
}
|
|
/// Stable id for the keytips' selected-tab state.
|
|
pub fn tab_id() -> egui::Id {
|
|
egui::Id::unique("wc_keytips_tab")
|
|
}
|
|
/// Stable id for the keytips' letter badges.
|
|
pub fn letters_id() -> egui::Id {
|
|
egui::Id::unique("wc_keytips_letters")
|
|
}
|
|
/// Stable id for the keytips' badge rectangles.
|
|
pub fn rects_id() -> egui::Id {
|
|
egui::Id::unique("wc_keytips_rects")
|
|
}
|
|
|
|
/// A bare Alt press (either Alt key) arms or cancels keytips.
|
|
fn is_alt(key: egui::Key) -> bool {
|
|
matches!(key, egui::Key::AltLeft | egui::Key::AltRight)
|
|
}
|
|
|
|
/// What the keytip state machine is showing.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
pub enum Phase {
|
|
/// Keytips off; the ribbon behaves normally.
|
|
#[default]
|
|
Off,
|
|
/// Badges over the tabs.
|
|
Tabs,
|
|
/// Badges over one tab's groups and buttons.
|
|
Commands,
|
|
/// Partway through a legacy menu key sequence (#1697); the letters so far are in
|
|
/// `keytip_prefix`, and a hint stands in for the badges.
|
|
Legacy,
|
|
/// Badges over the Backstage side menu, after Alt, F (#4960).
|
|
Backstage,
|
|
}
|
|
|
|
/// Which ribbon item a letter is bound to, and what pressing it does.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum Target {
|
|
/// Switch to a tab (and show its command badges).
|
|
Tab(&'static str),
|
|
/// Run a command, optionally with parameters.
|
|
Cmd { id: &'static str, params: serde_json::Value },
|
|
/// Open a dialog by name.
|
|
Dialog { name: &'static str },
|
|
}
|
|
|
|
impl Target {
|
|
fn run(self, app: &mut WordApp) {
|
|
match self {
|
|
Target::Tab(tab) => {
|
|
let _ = app.run("ui.tab", serde_json::json!({ "tab": tab }));
|
|
}
|
|
Target::Cmd { id, params } => {
|
|
let _ = app.run(id, params);
|
|
}
|
|
Target::Dialog { name } => {
|
|
let _ = app.run("ui.dialog", serde_json::json!({ "name": name }));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Word's tab letters, as Word for Windows shows them after Alt (File F, Design G, References S,
|
|
/// Review R, View W, Help Y). File opens the Backstage.
|
|
const TAB_LETTERS: [(&str, &str); 11] = [
|
|
("File", "F"),
|
|
("Home", "H"),
|
|
("Insert", "N"),
|
|
("Draw", "D"),
|
|
("Design", "G"),
|
|
("Layout", "P"),
|
|
("References", "S"),
|
|
("Mailings", "M"),
|
|
("Review", "R"),
|
|
("View", "W"),
|
|
("Help", "Y"),
|
|
];
|
|
|
|
/// Word's contextual table tabs take two letters (Alt, J, T and Alt, J, L), shown in a table.
|
|
const TABLE_TAB_LETTERS: [(&str, &str); 2] = [("Table Design", "JT"), ("Table Layout", "JL")];
|
|
|
|
/// The Search box's badge beside the tab badges (Word's Alt, Q).
|
|
pub const SEARCH_LETTER: &str = "Q";
|
|
|
|
/// Legacy menu keys (#1697): Word 2003 menu sequences Word for Windows still takes after Alt,
|
|
/// observed there. Hidden, as in Word: no badges, not in Customize Keyboard. They start only on
|
|
/// letters no tab keytip or Search uses (see the test).
|
|
fn legacy() -> Vec<(&'static str, Target)> {
|
|
let cmd = |id| Target::Cmd { id, params: serde_json::json!({}) };
|
|
let dialog = |name| Target::Dialog { name };
|
|
vec![
|
|
("VU", cmd("view.fullScreenView")),
|
|
("VP", cmd("view.printLayout")),
|
|
("VW", cmd("view.webLayout")),
|
|
("VO", cmd("view.outline")),
|
|
("VN", cmd("view.draft")),
|
|
("VD", cmd("view.navigationPane")),
|
|
("VL", cmd("view.ruler")),
|
|
("VZ", dialog("zoom")),
|
|
("EL", cmd("select.all")),
|
|
("EF", dialog("find")),
|
|
("EE", dialog("replace")),
|
|
("EG", dialog("goto")),
|
|
("IS", dialog("symbol")),
|
|
("IPF", cmd("insert.picture")),
|
|
("OF", dialog("font")),
|
|
("OP", dialog("paragraph")),
|
|
("TW", cmd("review.wordCount")),
|
|
("AIT", dialog("insertTable")),
|
|
]
|
|
}
|
|
|
|
/// A letter in a legacy menu key sequence: run it once complete, wait while it can still grow,
|
|
/// and ignore a letter that leads nowhere, as Word does.
|
|
fn legacy_key(app: &mut WordApp, name: &str) {
|
|
let keys = format!("{}{name}", app.ui.keytip_prefix);
|
|
let rows = legacy();
|
|
if let Some((_, target)) = rows.iter().find(|(k, _)| *k == keys) {
|
|
app.ui.keytips = Phase::Off;
|
|
app.ui.keytip_prefix.clear();
|
|
target.clone().run(app);
|
|
} else if rows.iter().any(|(k, _)| k.starts_with(&keys)) {
|
|
app.ui.keytips = Phase::Legacy;
|
|
app.ui.keytip_prefix = keys;
|
|
}
|
|
}
|
|
|
|
/// The letter badge for a tab (empty when it has none).
|
|
pub fn tab_letter(tab: &str) -> &'static str {
|
|
TAB_LETTERS.iter().chain(&TABLE_TAB_LETTERS).find(|(t, _)| *t == tab).map(|(_, l)| *l).unwrap_or("")
|
|
}
|
|
|
|
/// The tabs that carry a badge, in display order.
|
|
pub fn badge_tabs(app: &WordApp) -> Vec<&'static str> {
|
|
let mut v: Vec<&'static str> = TAB_LETTERS.iter().filter(|(_, l)| !l.is_empty()).map(|(t, _)| *t).collect();
|
|
if crate::ribbon::in_table_public(app) {
|
|
v.extend_from_slice(&["Table Design", "Table Layout"]);
|
|
}
|
|
v
|
|
}
|
|
|
|
/// Letters Alt+letter already drives elsewhere; only Alt-only (or bare) shortcuts count, so
|
|
/// Mod+/Ctrl+/Shift+ shortcuts like ⌘B don't reserve the alphabet.
|
|
fn reserved_letters(app: &WordApp) -> Vec<char> {
|
|
let mut v = Vec::new();
|
|
for spec in app.session.registry.all() {
|
|
let Some(sc) = spec.shortcut.split(" / ").next() else { continue };
|
|
if sc.contains("Mod+") || sc.contains("Ctrl+") || sc.contains("Shift+") {
|
|
continue;
|
|
}
|
|
let Some(last) = sc.rsplit('+').next() else { continue };
|
|
let mut cs = last.chars();
|
|
if let Some(c) = cs.next()
|
|
&& cs.next().is_none()
|
|
&& c.is_ascii_alphanumeric()
|
|
{
|
|
v.push(c.to_ascii_uppercase());
|
|
}
|
|
}
|
|
v
|
|
}
|
|
|
|
/// A command's shortcut letter, but only for Alt+letter shortcuts (the kind a keytip triggers).
|
|
fn shortcut_letter(app: &WordApp, id: &str) -> Option<char> {
|
|
let sc = app.session.registry.get(id)?.shortcut;
|
|
let sc = sc.split(" / ").next()?;
|
|
if sc.contains("Mod+") || sc.contains("Ctrl+") || sc.contains("Shift+") {
|
|
return None;
|
|
}
|
|
let mut cs = sc.rsplit('+').next()?.chars();
|
|
let c = cs.next()?;
|
|
(cs.next().is_none() && c.is_ascii_alphanumeric()).then(|| c.to_ascii_uppercase())
|
|
}
|
|
|
|
/// One ribbon control: what it is called and what it does.
|
|
struct Control {
|
|
tip: &'static str,
|
|
target: Target,
|
|
}
|
|
|
|
/// A tab's controls in the order they are drawn; mirrors `ribbon.rs`, so badges land in order.
|
|
fn controls(tab: &str) -> Vec<Control> {
|
|
let c = |tip: &'static str, id: &'static str| Control { tip, target: Target::Cmd { id, params: serde_json::json!({}) } };
|
|
let m = c;
|
|
let d = |tip: &'static str, name: &'static str| Control { tip, target: Target::Dialog { name } };
|
|
let p = |tip: &'static str, id: &'static str, params: serde_json::Value| Control { tip, target: Target::Cmd { id, params } };
|
|
match tab {
|
|
"Home" => vec![
|
|
m("Paste", "edit.paste"),
|
|
c("Cut", "edit.cut"),
|
|
c("Copy", "edit.copy"),
|
|
c("Format Painter", "edit.formatPainter"),
|
|
c("Bold", "format.bold"),
|
|
c("Italic", "format.italic"),
|
|
m("Underline", "format.underline"),
|
|
c("Strikethrough", "format.strikethrough"),
|
|
c("Subscript", "format.subscript"),
|
|
c("Superscript", "format.superscript"),
|
|
m("Text Effects and Typography", "format.outline"),
|
|
m("Text Highlight Color", "format.highlight"),
|
|
m("Font Color", "format.color"),
|
|
m("Bullets", "para.bullets"),
|
|
m("Numbering", "para.numbering"),
|
|
m("Multilevel List", "para.multilevel"),
|
|
c("Decrease Indent", "para.outdent"),
|
|
c("Increase Indent", "para.indent"),
|
|
c("Sort", "para.sort"),
|
|
c("Show/Hide ¶", "view.marks"),
|
|
c("Align Left", "para.alignLeft"),
|
|
c("Center", "para.alignCenter"),
|
|
c("Align Right", "para.alignRight"),
|
|
c("Justify", "para.justify"),
|
|
m("Line and Paragraph Spacing", "para.lineSpacing"),
|
|
m("Shading", "para.shading"),
|
|
m("Borders", "para.borders"),
|
|
c("Find", "edit.find"),
|
|
d("Replace", "replace"),
|
|
m("Select", "select.all"),
|
|
c("Dictate", "tools.dictate"),
|
|
c("Editor", "review.spelling"),
|
|
],
|
|
"Insert" => vec![
|
|
m("Cover Page", "insert.coverPage"),
|
|
c("Blank Page", "insert.blankPage"),
|
|
c("Page Break", "insert.pageBreak"),
|
|
m("Table", "insert.table"),
|
|
c("Pictures", "insert.picture"),
|
|
m("Shapes", "insert.shape"),
|
|
c("Icons", "insert.icon"),
|
|
c("SmartArt", "insert.smartArt"),
|
|
c("Chart", "insert.chart"),
|
|
d("Link", "link"),
|
|
d("Bookmark", "bookmark"),
|
|
c("Cross-reference", "insert.crossReference"),
|
|
c("Comment", "review.newComment"),
|
|
m("Header", "insert.header"),
|
|
m("Footer", "insert.footer"),
|
|
m("Page Number", "insert.pageNumber"),
|
|
c("Text Box", "insert.textBox"),
|
|
c("Quick Parts", "insert.quickParts"),
|
|
m("WordArt", "insert.wordArt"),
|
|
m("Drop Cap", "insert.dropCap"),
|
|
c("Signature Line", "insert.signatureLine"),
|
|
d("Date & Time", "dateTime"),
|
|
c("Object", "insert.object"),
|
|
c("Equation", "insert.equation"),
|
|
m("Symbol", "insert.symbol"),
|
|
],
|
|
"Draw" => vec![
|
|
c("Select", "draw.select"),
|
|
c("Lasso", "draw.lasso"),
|
|
c("Eraser", "draw.eraser"),
|
|
c("Pen", "draw.pen"),
|
|
c("Pencil", "draw.pencil"),
|
|
c("Highlighter", "draw.highlighter"),
|
|
m("Add Pen", "draw.addPen"),
|
|
c("Ink to Shape", "draw.inkToShape"),
|
|
c("Ink to Math", "draw.inkToMath"),
|
|
c("Drawing Canvas", "insert.canvas"),
|
|
c("Ink Replay", "draw.replay"),
|
|
],
|
|
"Design" => vec![
|
|
m("Themes", "design.theme"),
|
|
m("Theme Colors", "design.themeColors"),
|
|
m("Theme Fonts", "design.themeFonts"),
|
|
m("Paragraph Spacing", "design.paragraphSpacing"),
|
|
c("Effects", "design.effects"),
|
|
c("Set as Default", "design.setDefault"),
|
|
m("Watermark", "design.watermark"),
|
|
m("Page Color", "design.pageColor"),
|
|
m("Page Borders", "design.pageBorders"),
|
|
],
|
|
"Layout" => vec![
|
|
m("Text Direction", "layout.textDirection"),
|
|
m("Margins", "layout.margins"),
|
|
m("Orientation", "layout.orientation"),
|
|
m("Size", "layout.size"),
|
|
m("Columns", "layout.columns"),
|
|
m("Breaks", "layout.break"),
|
|
m("Line Numbers", "layout.lineNumbers"),
|
|
m("Hyphenation", "layout.hyphenation"),
|
|
c("Position", "arrange.position"),
|
|
c("Wrap Text", "arrange.wrap"),
|
|
c("Bring Forward", "arrange.bringForward"),
|
|
c("Send Backward", "arrange.sendBackward"),
|
|
c("Selection Pane", "arrange.selectionPane"),
|
|
c("Align", "arrange.align"),
|
|
c("Group", "arrange.group"),
|
|
c("Rotate", "arrange.rotate"),
|
|
],
|
|
"References" => vec![
|
|
m("Table of Contents", "references.toc"),
|
|
m("Add Text", "references.addText"),
|
|
c("Update Table", "references.updateToc"),
|
|
c("Insert Footnote", "references.footnote"),
|
|
c("Insert Endnote", "references.endnote"),
|
|
c("Next Footnote", "references.nextFootnote"),
|
|
c("Show Notes", "references.notes"),
|
|
c("Researcher", "references.researcher"),
|
|
c("Insert Citation", "references.citation"),
|
|
c("Manage Sources", "references.sources"),
|
|
m("Citation Style", "references.citationStyle"),
|
|
c("Bibliography", "references.bibliography"),
|
|
c("Insert Caption", "references.caption"),
|
|
c("Insert Table of Figures", "references.tableOfFigures"),
|
|
c("Update Table", "references.updateFigures"),
|
|
c("Cross-reference", "insert.crossReference"),
|
|
c("Mark Entry", "references.markEntry"),
|
|
c("Insert Index", "references.index"),
|
|
c("Update Index", "references.updateIndex"),
|
|
c("Mark Citation", "references.markCitation"),
|
|
c("Insert Table of Authorities", "references.tableOfAuthorities"),
|
|
],
|
|
"Mailings" => vec![
|
|
c("Envelopes", "mailings.envelopes"),
|
|
c("Labels", "mailings.labels"),
|
|
c("Start Mail Merge", "mailings.start"),
|
|
c("Select Recipients", "mailings.recipients"),
|
|
c("Edit Recipient List", "mailings.editRecipients"),
|
|
c("Highlight Merge Fields", "mailings.highlightFields"),
|
|
c("Address Block", "mailings.addressBlock"),
|
|
c("Greeting Line", "mailings.greetingLine"),
|
|
c("Insert Merge Field", "mailings.insertField"),
|
|
c("Rules", "mailings.rules"),
|
|
c("Match Fields", "mailings.matchFields"),
|
|
c("Preview Results", "mailings.preview"),
|
|
p("First Record", "mailings.first", serde_json::json!({})),
|
|
p("Previous Record", "mailings.previous", serde_json::json!({})),
|
|
p("Next Record", "mailings.next", serde_json::json!({})),
|
|
p("Last Record", "mailings.last", serde_json::json!({})),
|
|
c("Find Recipient", "mailings.findRecipient"),
|
|
c("Check for Errors", "mailings.checkErrors"),
|
|
c("Finish & Merge", "mailings.finish"),
|
|
],
|
|
"Review" => vec![
|
|
c("Spelling & Grammar", "review.spelling"),
|
|
c("Thesaurus", "review.thesaurus"),
|
|
d("Word Count", "wordCount"),
|
|
c("Read Aloud", "review.readAloud"),
|
|
c("Check Accessibility", "file.accessibility"),
|
|
c("Translate", "review.translate"),
|
|
c("Language", "review.language"),
|
|
c("New Comment", "review.newComment"),
|
|
c("Delete", "review.deleteComment"),
|
|
c("Previous", "review.previousComment"),
|
|
c("Next", "review.nextComment"),
|
|
c("Show Comments", "view.commentsPane"),
|
|
c("Track Changes", "review.trackChanges"),
|
|
m("Display for Review", "review.markup"),
|
|
c("Reviewing Pane", "review.changes"),
|
|
m("Accept", "review.accept"),
|
|
m("Reject", "review.reject"),
|
|
c("Previous", "review.previousChange"),
|
|
c("Next", "review.nextChange"),
|
|
c("Compare", "review.compare"),
|
|
c("Block Authors", "review.blockAuthors"),
|
|
c("Restrict Editing", "review.restrict"),
|
|
c("Hide Ink", "review.hideInk"),
|
|
],
|
|
"View" => vec![
|
|
c("Read Mode", "view.readMode"),
|
|
c("Print Layout", "view.printLayout"),
|
|
c("Web Layout", "view.webLayout"),
|
|
c("Outline", "view.outline"),
|
|
c("Draft", "view.draft"),
|
|
c("Focus", "view.focus"),
|
|
c("Immersive Reader", "view.immersive"),
|
|
c("Vertical", "view.vertical"),
|
|
c("Side to Side", "view.sideToSide"),
|
|
d("Zoom", "zoom"),
|
|
c("100%", "view.zoom100"),
|
|
c("One Page", "view.onePage"),
|
|
c("Multiple Pages", "view.multiplePages"),
|
|
c("Page Width", "view.pageWidth"),
|
|
c("Switch Modes", "view.darkMode"),
|
|
c("New Window", "view.newWindow"),
|
|
c("Arrange All", "view.arrangeAll"),
|
|
c("Split", "view.split"),
|
|
c("View Side by Side", "view.sideBySide"),
|
|
c("Synchronous Scrolling", "view.syncScroll"),
|
|
m("Switch Windows", "view.switchWindows"),
|
|
c("Full Screen", "view.fullScreen"),
|
|
c("Macros", "tools.macros"),
|
|
],
|
|
"Help" => vec![d("Help", "about"), c("Community Discord", "ui.discord"), d("Commands", "commands")],
|
|
"Table Design" => {
|
|
vec![m("Shading", "table.shading"), m("Borders", "table.borders"), c("Border Painter", "table.borderPainter")]
|
|
}
|
|
"Table Layout" => vec![
|
|
m("Select", "table.selectCell"),
|
|
c("View Gridlines", "table.viewGridlines"),
|
|
c("Properties", "table.properties"),
|
|
c("Draw Table", "table.draw"),
|
|
c("Eraser", "table.eraser"),
|
|
m("Delete", "table.deleteCells"),
|
|
c("Insert Above", "table.insertRowAbove"),
|
|
c("Insert Below", "table.insertRowBelow"),
|
|
c("Insert Left", "table.insertColumnLeft"),
|
|
c("Insert Right", "table.insertColumnRight"),
|
|
c("Merge Cells", "table.merge"),
|
|
c("Split Cells", "table.split"),
|
|
c("Split Table", "table.splitTable"),
|
|
m("AutoFit", "table.autofit"),
|
|
c("Distribute Rows", "table.distributeRows"),
|
|
c("Distribute Columns", "table.distributeColumns"),
|
|
c("Sort", "table.sort"),
|
|
c("Repeat Header Rows", "table.repeatHeader"),
|
|
c("Convert to Text", "table.toText"),
|
|
c("Formula", "table.formula"),
|
|
],
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// The badge letter for every control of a tab, in drawing order.
|
|
/// A command keeps its own `Alt+letter` shortcut letter; the rest take a free name/word
|
|
/// initial, then any free letter — never a reserved or already-assigned one.
|
|
fn letters_for(app: &WordApp, tab: &str) -> Vec<String> {
|
|
let reserved = reserved_letters(app);
|
|
let ctrls = controls(tab);
|
|
let n = ctrls.len();
|
|
let mut out: Vec<Option<String>> = vec![None; n];
|
|
let mut used: Vec<char> = Vec::new();
|
|
|
|
// Candidate letters for a control: its own shortcut letter, then its name's letters, then
|
|
// the initials of its words — all uppercase, alphanumeric only.
|
|
let candidates = |ctl: &Control, own: Option<char>| -> Vec<char> {
|
|
let mut v = Vec::new();
|
|
v.extend(own);
|
|
let words: Vec<&str> = ctl.tip.split_whitespace().filter(|w| w.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())).collect();
|
|
if let Some(w) = words.first() {
|
|
v.extend(w.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_uppercase()));
|
|
}
|
|
if words.len() > 1 {
|
|
v.extend(words.iter().map(|w| w.chars().next().unwrap_or(' ')).filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_uppercase()));
|
|
}
|
|
v
|
|
};
|
|
|
|
let id_of = |ctl: &Control| match &ctl.target {
|
|
Target::Cmd { id, .. } => *id,
|
|
Target::Dialog { .. } => "ui.dialog",
|
|
Target::Tab(_) => "ui.tab",
|
|
};
|
|
|
|
// Pass 1: own shortcut letters. First-come keeps it if two commands somehow share a letter.
|
|
for (i, ctl) in ctrls.iter().enumerate() {
|
|
if let Some(c) = shortcut_letter(app, id_of(ctl))
|
|
&& !used.contains(&c)
|
|
{
|
|
used.push(c);
|
|
out[i] = Some(c.to_string());
|
|
}
|
|
}
|
|
// Pass 2: name-derived letters for everything still unassigned.
|
|
for (i, ctl) in ctrls.iter().enumerate() {
|
|
if out[i].is_some() {
|
|
continue;
|
|
}
|
|
let own = shortcut_letter(app, id_of(ctl));
|
|
let chosen = candidates(ctl, own)
|
|
.into_iter()
|
|
.find(|c| !used.contains(c) && !reserved.contains(c))
|
|
.or_else(|| ('A'..='Z').find(|c| !used.contains(c) && !reserved.contains(c)));
|
|
match chosen {
|
|
Some(c) => {
|
|
used.push(c);
|
|
out[i] = Some(c.to_string());
|
|
}
|
|
None => {
|
|
// Two-letter badge fallback: a tab has at most ~20 controls, so this succeeds;
|
|
// the "?n" form keeps assignment total rather than panicking.
|
|
let two = ('A'..='Z').flat_map(|a| ('A'..='Z').map(move |b| format!("{a}{b}"))).find(|s| {
|
|
let mut ch = s.chars();
|
|
let (a, b) = (ch.next().unwrap_or('?'), ch.next().unwrap_or('?'));
|
|
!out.iter().flatten().any(|o| o == s) && !reserved.contains(&a) && !reserved.contains(&b)
|
|
});
|
|
let two = two.unwrap_or_else(|| format!("?{}", out.iter().flatten().count()));
|
|
used.extend(two.chars());
|
|
out[i] = Some(two);
|
|
}
|
|
}
|
|
}
|
|
out.into_iter().map(Option::unwrap_or_default).collect()
|
|
}
|
|
|
|
/// Pure letter assignment `(tab letters, per-tab control letters)`, for tests without a window.
|
|
#[cfg(test)]
|
|
fn letters(app: &WordApp) -> (Vec<(&'static str, String)>, Vec<(String, Vec<String>)>) {
|
|
let tabs = badge_tabs(app).into_iter().map(|t| (t, tab_letter(t).to_string())).collect();
|
|
let cmds = crate::ribbon::TABS
|
|
.iter()
|
|
.filter(|t| !tab_letter(t).is_empty() || crate::ribbon::in_table_public(app))
|
|
.map(|t| ((*t).to_string(), letters_for(app, t)))
|
|
.collect();
|
|
(tabs, cmds)
|
|
}
|
|
|
|
/// A keytip control as the ribbon needs to see it: its tooltip and its command id.
|
|
pub struct ControlInfo {
|
|
pub tip: &'static str,
|
|
pub id: &'static str,
|
|
}
|
|
|
|
/// A tab's badgeable controls in drawing order, for the widgets to find their own badges.
|
|
pub fn controls_public(tab: &str) -> Vec<ControlInfo> {
|
|
controls(tab)
|
|
.into_iter()
|
|
.map(|c| ControlInfo {
|
|
tip: c.tip,
|
|
id: match &c.target {
|
|
Target::Cmd { id, .. } => id,
|
|
Target::Dialog { .. } => "ui.dialog",
|
|
Target::Tab(_) => "ui.tab",
|
|
},
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// A keytip badge: letters or toolbar numbers, high contrast in both themes.
|
|
pub fn badge(ui: &Ui, rect: Rect, letter: &str) {
|
|
let count = letter.chars().count();
|
|
if count == 0 || count > 4 {
|
|
return;
|
|
}
|
|
let t = Tokens::get(ui.ctx());
|
|
let font = semibold(if count > 3 { 8.5 } else { 10.0 });
|
|
let width = ui.ctx().fonts_mut(|f| f.layout_no_wrap(letter.to_string(), font.clone(), t.on_accent).size().x);
|
|
let r = Rect::from_min_size(rect.min + Vec2::new(2.0, 1.0), Vec2::new((width + 4.0).max(15.0), 15.0));
|
|
let p = ui.painter();
|
|
p.rect_filled(r, CornerRadius::same(3), t.accent);
|
|
p.rect_stroke(r, CornerRadius::same(3), Stroke::new(1.0, t.menu), egui::StrokeKind::Inside);
|
|
p.text(r.center(), Align2::CENTER_CENTER, letter, font, t.on_accent);
|
|
}
|
|
|
|
/// Record a tab badge over a tab's real rect, called as each tab is laid out. After a typed
|
|
/// prefix (J, or Quick Access Toolbar digits) only the badges it can still complete show.
|
|
pub fn record_tab(rect: Rect, tab: &str, prefix: &str, out: &mut Vec<(Rect, String)>) {
|
|
let l = tab_letter(tab);
|
|
if !l.is_empty() && l.starts_with(prefix) {
|
|
out.push((rect, l.to_string()));
|
|
}
|
|
}
|
|
|
|
/// Record a control badge over the control's real rect. `index` is the control's position in
|
|
/// the tab's drawing order, which is exactly the order `letters_for` assigns letters in.
|
|
pub fn record_control(rect: Rect, letter: String, out: &mut Vec<(Rect, String)>) {
|
|
out.push((rect, letter));
|
|
}
|
|
|
|
/// The badge letter for the `index`th control of a tab.
|
|
pub fn letter_for(app: &WordApp, index: usize) -> String {
|
|
letters_for(app, &app.ui.tab).get(index).cloned().unwrap_or_default()
|
|
}
|
|
|
|
/// The number of badgeable controls on a tab (so callers can index them in drawing order).
|
|
pub fn control_count(tab: &str) -> usize {
|
|
controls(tab).len()
|
|
}
|
|
|
|
/// Handle a key while keytips are showing. Returns true when the key was consumed.
|
|
pub fn handle_key(app: &mut WordApp, key: egui::Key) -> bool {
|
|
if app.ui.keytips != Phase::Off {
|
|
match key {
|
|
egui::Key::Escape => {
|
|
app.ui.keytips = Phase::Off;
|
|
}
|
|
k if is_alt(k) => {
|
|
app.ui.keytips = if app.ui.keytips == Phase::Tabs { Phase::Off } else { Phase::Tabs };
|
|
}
|
|
k => {
|
|
let Some(name) = keys::key_name(k).map(|n| n.to_ascii_uppercase()) else {
|
|
// A key with no keytip cancels the mode.
|
|
app.ui.keytips = Phase::Off;
|
|
app.ui.keytip_prefix.clear();
|
|
return true;
|
|
};
|
|
// Quick Access Toolbar badges are all digits; a pending prefix keeps its kind.
|
|
let typed = if app.ui.keytip_prefix.is_empty() { name.as_str() } else { app.ui.keytip_prefix.as_str() };
|
|
if app.ui.keytips == Phase::Tabs && typed.chars().all(|c| c.is_ascii_digit()) {
|
|
qat_key(app, &name);
|
|
return true;
|
|
}
|
|
match app.ui.keytips {
|
|
Phase::Tabs => {
|
|
let name = format!("{}{name}", app.ui.keytip_prefix);
|
|
let tabs = badge_tabs(app);
|
|
let hit = tabs.iter().copied().find(|t| tab_letter(t) == name);
|
|
// J waits for the table tab's second letter.
|
|
if hit.is_none() && tabs.iter().any(|t| tab_letter(t).starts_with(&name)) {
|
|
app.ui.keytip_prefix = name;
|
|
return true;
|
|
}
|
|
match hit {
|
|
Some(tab) => {
|
|
let _ = app.run("ui.tab", serde_json::json!({ "tab": tab }));
|
|
// File opens the Backstage: its pages get the badges (Alt, F, P prints).
|
|
app.ui.keytips = if tab == "File" && app.ui.backstage { Phase::Backstage } else { Phase::Commands };
|
|
}
|
|
// Q is the title bar's Search box, as in Word (#819).
|
|
None if name == SEARCH_LETTER => {
|
|
app.ui.keytips = Phase::Off;
|
|
let _ = app.run("tools.search", serde_json::json!({}));
|
|
}
|
|
// Word 2003's menu keys: off on macOS, where F10 still shows badges.
|
|
None if !cfg!(target_os = "macos") && legacy().iter().any(|(k, _)| k.starts_with(name.as_str())) => {
|
|
legacy_key(app, &name);
|
|
return true;
|
|
}
|
|
None => app.ui.keytips = Phase::Off,
|
|
}
|
|
}
|
|
Phase::Legacy => {
|
|
legacy_key(app, &name);
|
|
return true;
|
|
}
|
|
Phase::Backstage => {
|
|
app.ui.keytips = Phase::Off;
|
|
if let Some(page) = crate::backstage::keytip_page(&name) {
|
|
crate::backstage::go_to_page(app, page);
|
|
}
|
|
}
|
|
Phase::Commands => {
|
|
let tab = app.ui.tab.clone();
|
|
let ls = letters_for(app, &tab);
|
|
let ctrls = controls(&tab);
|
|
let mut hit: Option<Target> = None;
|
|
for (ctl, l) in ctrls.into_iter().zip(ls) {
|
|
if l == name {
|
|
hit = Some(ctl.target);
|
|
break;
|
|
}
|
|
}
|
|
app.ui.keytips = Phase::Off;
|
|
if let Some(t) = hit {
|
|
t.run(app);
|
|
}
|
|
}
|
|
Phase::Off => {}
|
|
}
|
|
}
|
|
}
|
|
app.ui.keytip_prefix.clear();
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// The first nine buttons use one digit. Further buttons use a leading zero and equally wide
|
|
/// numbers, so no complete badge is the prefix of another, even for the maximum-size toolbar.
|
|
pub(crate) fn qat_label(index: usize, count: usize) -> String {
|
|
if index < 9 {
|
|
(index + 1).to_string()
|
|
} else {
|
|
let width = count.saturating_sub(9).to_string().len();
|
|
format!("0{:0width$}", index.saturating_sub(8))
|
|
}
|
|
}
|
|
|
|
fn qat_key(app: &mut WordApp, name: &str) {
|
|
app.ui.keytip_prefix.push_str(name);
|
|
// A hidden toolbar has no badges, so a digit cancels the mode and runs nothing (#1945).
|
|
let ids = if app.ui.quick_access_toolbar { app.session.ribbon.qat() } else { Vec::new() };
|
|
let mut hit = None;
|
|
let mut pending = false;
|
|
for (index, id) in ids.iter().enumerate() {
|
|
let label = qat_label(index, ids.len());
|
|
if label == app.ui.keytip_prefix {
|
|
hit = Some((*id).to_string());
|
|
break;
|
|
}
|
|
pending |= label.starts_with(&app.ui.keytip_prefix);
|
|
}
|
|
if hit.is_some() || !pending {
|
|
app.ui.keytips = Phase::Off;
|
|
app.ui.keytip_prefix.clear();
|
|
if let Some(id) = hit
|
|
&& crate::chrome::qat_enabled(app, &id)
|
|
{
|
|
let _ = app.run(&id, serde_json::json!({}));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Alt (or F10) turns keytips on: the first press shows the tab badges.
|
|
pub fn activate(app: &mut WordApp) {
|
|
if app.ui.keytips_enabled && app.ui.keytips == Phase::Off {
|
|
app.ui.keytip_prefix.clear();
|
|
app.ui.keytips = Phase::Tabs;
|
|
}
|
|
}
|
|
|
|
use crate::keys;
|
|
|
|
/// Keytips are suppressed while the Backstage (except for its own badges, Alt, F) or a modal dialog is open, or while a text field
|
|
/// (not the canvas) has focus; the canvas lets Alt open keytips and `canvas_events` swallows
|
|
/// typing while they show. The Backstage's badges end with it (its back chevron, a mouse Save),
|
|
/// so the next letter is typed and never picks a page that is no longer there.
|
|
pub fn suppressed(app: &WordApp, ctx: &egui::Context) -> bool {
|
|
!app.ui.keytips_enabled
|
|
|| (app.ui.backstage && app.ui.keytips != Phase::Backstage)
|
|
|| (!app.ui.backstage && app.ui.keytips == Phase::Backstage)
|
|
|| app.dialog.is_some()
|
|
|| app.modal_open()
|
|
|| (ctx.egui_wants_keyboard_input() && !app.canvas.focused)
|
|
}
|
|
|
|
/// Native backends report Alt through both key and modifier events, in either order. Track
|
|
/// the transition once; the key-up event can still carry the old `alt: true` modifier state.
|
|
/// A release is a keytip tap only if the Alt modifier was on during the press: AltGr (Right Alt
|
|
/// on Polish, German, Czech… layouts) is a Right Alt key without it on Linux and Ctrl+Alt on
|
|
/// Windows, and types characters instead (#2388).
|
|
fn alt_transition(app: &mut WordApp, pressed: bool, modifiers: egui::Modifiers) -> bool {
|
|
let other_modifier = modifiers.ctrl || modifiers.shift || modifiers.command || modifiers.mac_cmd;
|
|
if pressed {
|
|
if !app.ui.alt_pressed {
|
|
app.ui.alt_chord_used = false;
|
|
app.ui.alt_modifier_seen = false;
|
|
}
|
|
app.ui.alt_pressed = true;
|
|
app.ui.alt_chord_used |= other_modifier;
|
|
app.ui.alt_modifier_seen |= modifiers.alt;
|
|
false
|
|
} else {
|
|
let released = std::mem::take(&mut app.ui.alt_pressed);
|
|
let seen = std::mem::take(&mut app.ui.alt_modifier_seen) || modifiers.alt;
|
|
released && !std::mem::take(&mut app.ui.alt_chord_used) && !other_modifier && seen
|
|
}
|
|
}
|
|
|
|
/// Read keytip input every frame, before layout, so the frame paints the new state. Suppression
|
|
/// cancels keytips so letters reach the text; Alt still reaches the rest of the UI (the
|
|
/// registry's Alt+letter shortcuts are untouched).
|
|
///
|
|
/// Keytips arm on a bare Alt *release* with no key pressed in between — the same chord Word
|
|
/// uses. On macOS they never arm from Alt at all, because Option is how you type accented
|
|
/// characters there and hijacking it would swallow the next letter.
|
|
pub fn logic(app: &mut WordApp, ctx: &egui::Context) {
|
|
if suppressed(app, ctx) || !ctx.input(|i| i.focused) {
|
|
app.ui.keytips = Phase::Off;
|
|
app.ui.keytip_prefix.clear();
|
|
app.ui.alt_pressed = false;
|
|
app.ui.alt_chord_used = false;
|
|
app.ui.alt_key_down = false;
|
|
app.ui.alt_modifier_seen = false;
|
|
app.keytip_rects.clear();
|
|
ctx.data_mut(|d| {
|
|
d.insert_temp(phase_id(), Phase::Off);
|
|
d.remove::<Vec<(Rect, String)>>(rects_id());
|
|
});
|
|
return;
|
|
}
|
|
// On macOS Option+key types accents, so Alt must never arm keytips there.
|
|
const ALT_ARMS: bool = !cfg!(target_os = "macos");
|
|
let events: Vec<egui::Event> = ctx.input(|i| i.events.clone());
|
|
// Alt pressed or let go while dragging (an object, a selection) is part of the drag (Alt
|
|
// moves an object freely), not a keytip tap.
|
|
let dragging = app.canvas.obj_drag.is_some() || ctx.input(|i| i.pointer.any_down());
|
|
let mut other_key = false;
|
|
let mut alt_released = false;
|
|
let mut f10 = false;
|
|
for e in &events {
|
|
match e {
|
|
egui::Event::Key { key, pressed, modifiers, .. } => {
|
|
if is_alt(*key) {
|
|
app.ui.alt_key_down = *pressed;
|
|
// Left Alt is never AltGr, whatever modifiers the event carries.
|
|
let modifiers = egui::Modifiers { alt: modifiers.alt || *key == egui::Key::AltLeft, ..*modifiers };
|
|
alt_released |= alt_transition(app, *pressed, modifiers);
|
|
} else if *pressed {
|
|
other_key = true;
|
|
app.ui.alt_chord_used = true;
|
|
if *key == egui::Key::F10 && !modifiers.any() {
|
|
f10 = true;
|
|
}
|
|
}
|
|
}
|
|
// Alt off while an Alt key is still down (AltGr on Linux turns Level 3 Shift on, not
|
|
// Alt): the key's release ends the press, not this.
|
|
egui::Event::ModifiersChanged(modifiers) if !app.ui.alt_key_down || modifiers.alt => {
|
|
alt_released |= alt_transition(app, modifiers.alt, *modifiers);
|
|
}
|
|
egui::Event::PointerButton { pressed: true, .. } => {
|
|
other_key = true;
|
|
app.ui.alt_chord_used = true;
|
|
}
|
|
_ => {}
|
|
}
|
|
if dragging && app.ui.alt_pressed {
|
|
app.ui.alt_chord_used = true;
|
|
}
|
|
}
|
|
// Route letter/navigation keys to the keytips while they are showing.
|
|
let mut consumed = Vec::new();
|
|
for e in &events {
|
|
if let egui::Event::Key { key, pressed: true, modifiers, .. } = e
|
|
&& app.ui.keytips != Phase::Off
|
|
&& !is_alt(*key)
|
|
&& (*key != egui::Key::F10 || modifiers.any())
|
|
&& handle_key(app, *key)
|
|
{
|
|
consumed.push(*key);
|
|
}
|
|
}
|
|
// A badge can turn the mode off before canvas_events runs. Consume both the key and its
|
|
// text event now, or the digit/letter would also be typed into the document (#620).
|
|
if !consumed.is_empty() {
|
|
ctx.input_mut(|i| {
|
|
i.events.retain(|e| match e {
|
|
egui::Event::Key { key, pressed: true, .. } => !consumed.contains(key),
|
|
egui::Event::Text(_) => false,
|
|
_ => true,
|
|
});
|
|
});
|
|
}
|
|
if ALT_ARMS && alt_released && !other_key {
|
|
app.ui.keytips = if app.ui.keytips == Phase::Tabs { Phase::Off } else { Phase::Tabs };
|
|
app.ui.keytip_prefix.clear();
|
|
} else if f10 {
|
|
activate(app);
|
|
}
|
|
if app.ui.keytips == Phase::Off {
|
|
app.ui.keytip_prefix.clear();
|
|
}
|
|
// Publish the state the ribbon widgets read to place their badges, and clear last frame's.
|
|
ctx.data_mut(|d| {
|
|
d.insert_temp(phase_id(), app.ui.keytips);
|
|
d.insert_temp(tab_id(), app.ui.tab.clone());
|
|
if matches!(app.ui.keytips, Phase::Commands) {
|
|
d.insert_temp(letters_id().with(&app.ui.tab), letters_for(app, &app.ui.tab));
|
|
d.insert_temp(rects_id(), Vec::<(egui::Rect, String)>::new());
|
|
} else {
|
|
d.remove::<Vec<(egui::Rect, String)>>(rects_id());
|
|
}
|
|
});
|
|
app.keytip_rects.clear();
|
|
}
|
|
|
|
/// Paint the badges for the current phase over the ribbon. Called after the ribbon is laid out,
|
|
/// so every badge sits exactly on its own control and can never overlap another one.
|
|
pub fn show(app: &mut WordApp, ctx: &egui::Context, ui: &mut Ui) {
|
|
if app.ui.keytips == Phase::Off || suppressed(app, ctx) {
|
|
return;
|
|
}
|
|
// Tab badges were recorded during layout; command badges were published to egui memory by
|
|
// the widgets themselves (they own their rects).
|
|
let tabs = std::mem::take(&mut app.keytip_rects);
|
|
if app.ui.keytips == Phase::Legacy {
|
|
legacy_hint(app, ui, tabs.iter().map(|(r, _)| *r).reduce(|a, b| a.union(b)));
|
|
return;
|
|
}
|
|
let cmds: Vec<(Rect, String)> = ui.data(|d| d.get_temp::<Vec<(Rect, String)>>(rects_id())).unwrap_or_default();
|
|
for (r, l) in tabs.into_iter().chain(cmds) {
|
|
badge(ui, r, &l);
|
|
}
|
|
}
|
|
|
|
/// Where the legacy hint hangs from: the tab row, or, with no ribbon showing (Focus, Full Screen
|
|
/// View), the top of the window under the slim bar, so Alt, V still says it's waiting.
|
|
fn legacy_hint_anchor(tabs: Option<Rect>, window: Rect) -> Rect {
|
|
tabs.unwrap_or_else(|| Rect::from_min_size(window.left_top(), Vec2::new(0.0, crate::chrome::SCREEN_BAR_HEIGHT)))
|
|
}
|
|
|
|
/// The hint under the tab row while a legacy menu key sequence waits for its next letter.
|
|
fn legacy_hint(app: &WordApp, ui: &Ui, tabs: Option<Rect>) {
|
|
let tabs = legacy_hint_anchor(tabs, ui.ctx().content_rect());
|
|
let t = Tokens::get(ui.ctx());
|
|
let keys: Vec<String> = app.ui.keytip_prefix.chars().map(String::from).collect();
|
|
let text = crate::i18n::fmt(tl!("Old menu keys: Alt, {keys}. Keep typing, or press Esc to cancel."), &[("keys", &keys.join(", "))]);
|
|
let font = egui::FontId::proportional(12.0);
|
|
let galley = ui.ctx().fonts_mut(|f| f.layout_no_wrap(text, font, t.text));
|
|
let r = Rect::from_min_size(tabs.left_bottom() + Vec2::new(4.0, 2.0), galley.size() + Vec2::new(16.0, 10.0));
|
|
let p = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("wc_legacy_keys")));
|
|
p.rect_filled(r, CornerRadius::same(4), t.menu);
|
|
p.rect_stroke(r, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
|
p.galley(r.min + Vec2::new(8.0, 5.0), galley, t.text);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|