diff --git a/contributors/people.toml b/contributors/people.toml index 89c6db208..197762242 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -21,3 +21,8 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false + +[people.chiranjeevi-max] +real_name = "Chiranjeevi Uddanti" +display_name = "Chiranjeevi U" +sync_github_name = false diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 247a257d4..0433112f4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -38,6 +38,10 @@ pub struct CanvasState { pub want_focus: bool, pub context_issue: Option, pub context_synonyms: Option, + /// The right-click context menu is open (the mini toolbar stands down while it is). + pub context_menu_open: bool, + /// Screen rect of the selection's first line, for the floating mini toolbar. + pub mini_anchor: Option, } impl Default for CanvasState { @@ -60,10 +64,19 @@ impl Default for CanvasState { want_focus: true, context_issue: None, context_synonyms: None, + context_menu_open: false, + mini_anchor: None, } } } +impl CanvasState { + /// True while a mouse drag-select is in progress (the mini toolbar waits for mouse-up). + pub fn drag_selecting(&self) -> bool { + self.dragging + } +} + /// Page positions in content space (points), for the current zoom. pub struct Geometry { pub rects: Vec, @@ -303,13 +316,21 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // Selection. if !app.session.sel.is_collapsed() { let (a, b) = app.session.sel.ordered(); + let mut first: Option = None; for (pi, r) in layout.selection_rects(&app.session.doc, &a, &b, app.session.page_hint) { if let Some(pr) = rects.get(pi) { let scale = layout.pages.get(pi).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale)); let sr = Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale)); painter.rect_filled(sr, 0.0, t.selection); + // Track the topmost rect: the mini toolbar anchors on the selection's first line. + if first.is_none_or(|f| sr.min.y < f.min.y) { + first = Some(sr); + } } } + app.canvas.mini_anchor = first; + } else { + app.canvas.mini_anchor = None; } // Caret. let focused = resp.has_focus() || app.canvas.focused; @@ -370,6 +391,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { app.canvas.context_synonyms = app.session.run("review.thesaurus", &json!({})).ok().and_then(|v| v.get("synonyms").cloned()); } resp.context_menu(|ui| context_menu(app, ui)); + // Stand down while the context menu owns the pointer so the two never double up; + // `context_menu_opened` also covers the click that dismisses the menu. + app.canvas.context_menu_open = resp.context_menu_opened(); if resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::Text); } @@ -380,6 +404,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if let (Some(h), Some(v)) = (hruler, vruler) { rulers(app, ui, h, v, &rects, &layout, geo.scale); } + if let Some(sel) = app.canvas.mini_anchor { + crate::mini_toolbar::show(app, ui.ctx(), sel, area); + } } /// Comment balloons in the markup area right of each page, joined to their anchors. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index a8699b9c7..511284aaf 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -4,7 +4,7 @@ use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::json; use wordcraft_doc::StoryRef; -use crate::theme::{Tokens, medium, regular, semibold}; +use crate::theme::{Tokens, TypeRung, medium, paint_text, regular, semibold, text_width}; use crate::{WordApp, icons}; fn qat_button(ui: &mut Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, enabled: bool) { @@ -110,11 +110,16 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { let (r, resp) = ui.allocate_exact_size(vec2(260.0, 26.0), Sense::click()); ui.painter().rect(r, 6.0, if resp.hovered() { t.input } else { t.ribbon }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(8.0, 5.0), vec2(16.0, 16.0)), "search", t.text_dim, t.accent); - ui.painter().text( - pos2(r.min.x + 30.0, r.center().y), - Align2::LEFT_CENTER, - tl!("Search commands and help"), - regular(12.0), + let ph = tl!("Search commands and help"); + let pfont = TypeRung::Control.regular(); + let ptrack = TypeRung::Control.tracking(); + paint_text( + ui.painter(), + pos2(r.min.x + 30.0, r.center().y - TypeRung::Control.line_height() / 2.0), + Align2::LEFT_TOP, + ph, + &pfont, + ptrack, t.text_dim, ); if resp.clicked() { @@ -135,11 +140,20 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { tl!("Not saved") } ); - let g = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.clone(), semibold(12.5), t.text)); - let w = g.size().x; + let font = TypeRung::Chrome.semibold(); + let track = TypeRung::Chrome.tracking(); + let w = text_width(ui.painter(), &title, &font, track, t.text); let cx = full.center().x; if cx - w / 2.0 > qat_end + 10.0 && cx + w / 2.0 < right_start - 10.0 { - ui.painter().galley(pos2(cx - w / 2.0, full.center().y - g.size().y / 2.0), g, t.text); + paint_text( + ui.painter(), + pos2(cx - w / 2.0, full.center().y - TypeRung::Chrome.line_height() / 2.0), + Align2::LEFT_TOP, + &title, + &font, + track, + t.text, + ); } }); } @@ -160,7 +174,12 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { let l = app.session.layout(); let page = l.caret_on(&app.session.sel.focus, app.session.page_hint).map(|c| c.page + 1).unwrap_or(1); let st = |ui: &mut Ui, s: &str| { - ui.add(egui::Label::new(egui::RichText::new(s).font(regular(11.5)).color(t.text_dim)).sense(Sense::click())) + let font = TypeRung::Caption.regular(); + let track = TypeRung::Caption.tracking(); + let w = text_width(ui.painter(), s, &font, track, t.text_dim); + let (r, resp) = ui.allocate_exact_size(vec2(w + 1.0, TypeRung::Caption.line_height()), Sense::click()); + paint_text(ui.painter(), pos2(r.min.x, r.min.y), Align2::LEFT_TOP, s, &font, track, t.text_dim); + resp }; if st(ui, &crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &page.to_string()), ("pages", &l.pages.len().to_string())])) .on_hover_text(tl!("Go To (⌘⌥G)")) @@ -188,16 +207,22 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { if let Some((msg, at)) = &app.status_msg && crate::now_ms() - at < 6000.0 { - ui.label(egui::RichText::new(msg).font(regular(11.5)).color(t.accent_text)); + let font = TypeRung::Caption.regular(); + let track = TypeRung::Caption.tracking(); + paint_text(ui.painter(), pos2(ui.cursor().min.x, ui.cursor().min.y), Align2::LEFT_TOP, msg, &font, track, t.accent_text); + ui.allocate_space(vec2(text_width(ui.painter(), msg, &font, track, t.accent_text) + 1.0, TypeRung::Caption.line_height())); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.spacing_mut().item_spacing = vec2(4.0, 0.0); let z = (app.canvas.scale / crate::canvas::PX_PER_PT * 100.0).round(); - if ui - .add(egui::Label::new(egui::RichText::new(format!("{z}%")).font(regular(11.5)).color(t.text_dim)).sense(Sense::click())) - .clicked() - { - let _ = app.run("ui.dialog", json!({"name": "zoom"})); + let ztxt = format!("{z}%"); + let zfont = TypeRung::Caption.regular(); + let ztrack = TypeRung::Caption.tracking(); + let zw = text_width(ui.painter(), &ztxt, &zfont, ztrack, t.text_dim); + let (zr, zresp) = ui.allocate_exact_size(vec2(zw + 1.0, TypeRung::Caption.line_height()), Sense::click()); + paint_text(ui.painter(), pos2(zr.min.x, zr.min.y), Align2::LEFT_TOP, &ztxt, &zfont, ztrack, t.text_dim); + if zresp.clicked() { + let _ = app.run("ui.dialog", json!({ "name": "zoom" })); } if small_icon(ui, "plus", tl!("Zoom In")) { let _ = app.run("view.zoomIn", json!({})); @@ -239,7 +264,17 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { ui.painter().rect_filled(r, 3.0, t.hover); } icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(2.0, 2.0), vec2(16.0, 16.0)), "focus", t.icon, t.accent); - ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, focus, regular(11.5), t.text_dim); + let ffont = TypeRung::Caption.regular(); + let ftrack = TypeRung::Caption.tracking(); + paint_text( + ui.painter(), + pos2(r.min.x + 22.0, r.center().y - TypeRung::Caption.line_height() / 2.0), + Align2::LEFT_TOP, + focus, + &ffont, + ftrack, + t.text_dim, + ); if resp.clicked() { let _ = app.run("view.focus", json!({})); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4b3d119b0..e5fef9039 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -73,6 +73,8 @@ fn key_from(name: &str) -> Option { "down" => Some(egui::Key::ArrowDown), "space" => Some(egui::Key::Space), "tab" => Some(egui::Key::Tab), + "alt" | "altleft" | "altgr" => Some(egui::Key::AltLeft), + "altright" => Some(egui::Key::AltRight), "home" => Some(egui::Key::Home), "end" => Some(egui::Key::End), _ => None, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5369484a8..af24063b9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -326,6 +326,26 @@ fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) { fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { match d { Dialog::Font { font, size, bold, italic, underline, strike, sup, sub, small_caps, caps, hidden, color, spacing } => { + // Build the sample text format for the preview pane below. + let preview_size = size.trim().parse::().unwrap_or(12.0).clamp(6.0, 72.0); + let preview_color = wordcraft_doc::Rgb::parse(color).map(crate::theme::c32).unwrap_or_else(|| ui.visuals().text_color()); + let mut fmt = egui::TextFormat { + font_id: egui::FontId::new(preview_size, egui::FontFamily::Proportional), + color: preview_color, + ..Default::default() + }; + if *bold { + fmt.font_id = crate::theme::semibold(preview_size); + } + if *italic { + fmt.italics = true; + } + if *underline { + fmt.underline = egui::Stroke::new(1.0, preview_color); + } + if *strike { + fmt.strikethrough = egui::Stroke::new(1.0, preview_color); + } egui::Grid::new("fontdlg").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { ui.label(tl!("Font:")); ui.text_edit_singleline(font); @@ -358,6 +378,13 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.checkbox(hidden, tl!("Hidden")); ui.end_row(); }); + // Live preview of the chosen font/size/colour/effects. + ui.separator(); + ui.label(egui::RichText::new(tl!("Preview")).small().weak()); + let sample = if *caps { "AaBbYyZz".to_uppercase() } else { "AaBbYyZz".to_string() }; + let mut job = egui::text::LayoutJob::single_section(sample, fmt); + job.wrap.max_width = ui.available_width().min(400.0); + ui.label(job); let (ok, cancel) = buttons(ui, tl!("OK")); if ok { let mut props = json!({ diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index cb3928aee..c4e60e4db 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1006,6 +1006,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { "chevronRight" => pen.line(&[(7.0, 4.0), (13.0, 10.0), (7.0, 16.0)]), "chevronUp" => pen.line(&[(4.0, 13.0), (10.0, 7.0), (16.0, 13.0)]), "chevronDown" => pen.line(&[(4.0, 7.0), (10.0, 13.0), (16.0, 7.0)]), + "chevronDoubleRight" => { + pen.line(&[(4.0, 4.0), (9.0, 10.0), (4.0, 16.0)]); + pen.line(&[(11.0, 4.0), (16.0, 10.0), (11.0, 16.0)]); + } "addins" => { pen.rect(3.0, 3.0, 9.0, 9.0, c); pen.rect(11.0, 3.0, 17.0, 9.0, c); diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 32051c8d2..6903ebada 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -109,8 +109,12 @@ fn dispatch(app: &mut WordApp, key: Key, m: Modifiers) -> bool { false } -/// Events for the focused canvas: text, editing keys, clipboard, IME. +/// Events for the focused canvas: text, editing keys, clipboard, IME. While keytips are showing +/// every key belongs to them (letters pick badges, Escape cancels), so nothing here runs. pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { + if app.ui.keytips != crate::keytips::Phase::Off { + return; + } let events = ctx.input(|i| i.events.clone()); for e in events { match e { diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs new file mode 100644 index 000000000..f8f5c5045 --- /dev/null +++ b/crates/ui-egui/src/keytips.rs @@ -0,0 +1,759 @@ +//! Word-style keytips: Alt (or F10) shows letter badges over ribbon tabs, then over a tab's +//! controls; the badge's command runs on press. 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::new("wc_keytips_phase") +} +pub fn tab_id() -> egui::Id { + egui::Id::new("wc_keytips_tab") +} +pub fn letters_id() -> egui::Id { + egui::Id::new("wc_keytips_letters") +} +pub fn rects_id() -> egui::Id { + egui::Id::new("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, +} + +/// 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. `File` (Backstage only) and `Help` (dialog-only content) have none. +const TAB_LETTERS: [(&str, &str); 11] = [ + ("File", ""), + ("Home", "H"), + ("Insert", "N"), + ("Draw", "D"), + ("Design", "L"), + ("Layout", "P"), + ("References", "R"), + ("Mailings", "M"), + ("Review", "S"), + ("View", "V"), + ("Help", ""), +]; + +/// The letter badge for a tab (empty when it has none). +pub fn tab_letter(tab: &str) -> &'static str { + TAB_LETTERS.iter().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 { + 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 { + 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 { + 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"), + d("Find", "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"), + c("WordArt", "insert.wordArt"), + c("Drop Cap", "insert.dropCap"), + c("Signature Line", "insert.signatureLine"), + c("Date & Time", "insert.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"), + 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("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"), + c("Style: APA", "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("Previous Record", "mailings.previous", serde_json::json!({})), + p("Next Record", "mailings.next", 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"), + ], + "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("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", "view.gridlines"), + c("Properties", "table.properties"), + 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 { + let reserved = reserved_letters(app); + let ctrls = controls(tab); + let n = ctrls.len(); + let mut out: Vec> = vec![None; n]; + let mut used: Vec = 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| -> Vec { + 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(|c| c.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(|c| c.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(|o| o.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)>) { + 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 { + 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: one letter in a small rounded square, high contrast in both themes. +pub fn badge(ui: &Ui, rect: Rect, letter: &str) { + if letter.is_empty() || letter.chars().count() > 1 { + return; + } + let t = Tokens::get(ui.ctx()); + let r = Rect::from_min_size(rect.min + Vec2::new(2.0, 1.0), Vec2::splat(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, semibold(10.0), t.on_accent); +} + +/// Record a tab badge over a tab's real rect, called as each tab is laid out. +pub fn record_tab(rect: Rect, tab: &str, out: &mut Vec<(Rect, String)>) { + let l = tab_letter(tab); + if !l.is_empty() { + 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 (digits, punctuation, arrows) cancels, as in Word. + app.ui.keytips = Phase::Off; + return true; + }; + match app.ui.keytips { + Phase::Tabs => { + let mut hit: Option<&'static str> = None; + for tab in badge_tabs(app) { + if tab_letter(tab) == name { + hit = Some(tab); + break; + } + } + match hit { + Some(tab) => { + let _ = app.run("ui.tab", serde_json::json!({ "tab": tab })); + app.ui.keytips = Phase::Commands; + } + None => app.ui.keytips = Phase::Off, + } + } + Phase::Commands => { + let tab = app.ui.tab.clone(); + let ls = letters_for(app, &tab); + let ctrls = controls(&tab); + let mut hit: Option = 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 => {} + } + } + } + return true; + } + false +} + +/// Alt (or F10) turns keytips on: the first press shows the tab badges. +pub fn activate(app: &mut WordApp) { + if app.ui.keytips == Phase::Off { + app.ui.keytips = Phase::Tabs; + } +} + +use crate::keys; + +/// Keytips are suppressed while the Backstage 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. +pub fn suppressed(app: &WordApp, ctx: &egui::Context) -> bool { + app.ui.backstage || app.dialog.is_some() || (ctx.egui_wants_keyboard_input() && !app.canvas.focused) +} + +/// 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) { + app.ui.keytips = Phase::Off; + 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 = ctx.input(|i| i.events.clone()); + let mut other_key = false; + let mut alt_released = false; + let mut f10 = false; + for e in &events { + if let egui::Event::Key { key, pressed, modifiers, .. } = e { + if is_alt(*key) { + if *pressed && modifiers.any() { + // Alt combined with another modifier is a shortcut, not a keytip chord. + other_key = true; + } else if !*pressed && !modifiers.any() { + alt_released = true; + } + } else if *pressed { + other_key = true; + if *key == egui::Key::F10 && !modifiers.any() { + f10 = true; + } + } + } + } + // Route letter/navigation keys to the keytips while they are showing. + 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); + } + } + if ALT_ARMS && alt_released && !other_key { + app.ui.keytips = if app.ui.keytips == Phase::Tabs { Phase::Off } else { Phase::Tabs }; + } else if f10 { + activate(app); + } + // 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::>(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); + let cmds: Vec<(Rect, String)> = ui.data(|d| d.get_temp::>(rects_id())).unwrap_or_default(); + for (r, l) in tabs.into_iter().chain(cmds) { + badge(ui, r, &l); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> WordApp { + WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()) + } + + #[test] + fn tab_letters_match_word() { + assert_eq!(tab_letter("Home"), "H"); + assert_eq!(tab_letter("Insert"), "N"); + assert_eq!(tab_letter("Design"), "L"); + assert_eq!(tab_letter("Layout"), "P"); + assert_eq!(tab_letter("References"), "R"); + assert_eq!(tab_letter("Mailings"), "M"); + assert_eq!(tab_letter("Review"), "S"); + assert_eq!(tab_letter("View"), "V"); + assert_eq!(tab_letter("File"), ""); + assert_eq!(tab_letter("Help"), ""); + } + + #[test] + fn every_keytip_command_is_registered() { + // The keytip tables name commands by id; a renamed or removed command must fail here, + // not silently do nothing when its badge is pressed. Features not built yet are fine + // when the Word feature catalog lists them (the ribbon shows them too); `ui.*` commands are + // the app's own (`WordApp::run`). + let a = app(); + let catalog: Vec<&str> = wordcraft_engine::catalog::entries().into_iter().map(|e| e.3).collect(); + let mut missing = Vec::new(); + for (tab, _) in TAB_LETTERS { + for ctrl in controls(tab) { + if let Target::Cmd { id, .. } = ctrl.target + && a.session.registry.get(id).is_none() + && !catalog.contains(&id) + && !id.starts_with("ui.") + { + missing.push(format!("{tab}: {id}")); + } + } + } + assert!(missing.is_empty(), "unregistered: {missing:#?}"); + } + + #[test] + fn letters_are_unique_inside_every_tab() { + let a = app(); + let (tabs, cmds) = letters(&a); + for (_, ls) in &cmds { + let mut seen = Vec::new(); + for l in ls { + assert!(!seen.contains(l), "duplicate badge letter {l}"); + seen.push(l.clone()); + } + } + let mut tl = Vec::new(); + for (tab, l) in &tabs { + assert!(!l.is_empty(), "tab {tab} has no badge"); + assert!(!tl.contains(l), "duplicate tab letter {l}"); + tl.push(l.clone()); + } + } + + #[test] + fn keytips_never_reuse_another_commands_shortcut_letter() { + let a = app(); + let reserved = reserved_letters(&a); + let (_, cmds) = letters(&a); + for (tab, ls) in &cmds { + let ctrls = controls(tab); + let ids: Vec = ctrls + .iter() + .map(|c| match &c.target { + Target::Cmd { id, .. } => (*id).to_string(), + _ => "ui.dialog".to_string(), + }) + .collect(); + for (i, l) in ls.iter().enumerate() { + let id = ids.get(i).map(|s| s.as_str()).unwrap_or(""); + if let Some(sc) = shortcut_letter(&a, id) { + assert_eq!(l, &sc.to_string(), "{tab}: {id} should keep its shortcut letter {sc}"); + } else if reserved.contains(&l.chars().next().unwrap_or('?')) { + panic!("{tab}: badge {l} collides with another command's shortcut"); + } + } + } + } + + #[test] + fn keytip_state_machine() { + let mut a = app(); + a.ui.backstage = false; + a.dialog = None; + a.canvas.focused = false; + activate(&mut a); + assert_eq!(a.ui.keytips, Phase::Tabs); + assert!(handle_key(&mut a, egui::Key::Escape)); + assert_eq!(a.ui.keytips, Phase::Off); + activate(&mut a); + assert!(handle_key(&mut a, egui::Key::H)); + assert_eq!(a.ui.tab, "Home"); + assert_eq!(a.ui.keytips, Phase::Commands); + assert!(handle_key(&mut a, egui::Key::F)); + assert_eq!(a.ui.keytips, Phase::Off); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4f56cb6e8..bf268d956 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,8 @@ pub mod dialogs; pub mod i18n; pub mod icons; pub mod keys; +pub mod keytips; +pub mod mini_toolbar; pub mod panes; pub mod previews; pub mod ribbon; @@ -71,6 +73,9 @@ pub struct UiState { pub window: Option, /// Interface language: `auto` (follow the system) or a code from [`i18n::LANGUAGES`]. pub language: String, + /// Keytips (Alt) state; not persisted, it resets each session. + #[serde(skip)] + pub keytips: crate::keytips::Phase, /// View › Switch Modes: show pages dark (white text on black), kept between runs. pub dark_page: bool, } @@ -89,6 +94,7 @@ impl Default for UiState { author: String::new(), window: None, language: i18n::AUTO.into(), + keytips: crate::keytips::Phase::Off, dark_page: false, } } @@ -112,6 +118,8 @@ pub struct WordApp { queued_shots: Vec<(u64, f64, u32)>, pending_shots: Vec<(u64, Option, std::sync::mpsc::Sender, f64)>, pub(crate) synthetic: Vec, + /// Badge rects recorded during layout for the current keytip phase (cleared by `logic`, painted by `show`). + pub(crate) keytip_rects: Vec<(egui::Rect, String)>, styled: bool, /// The CJK face order the installed UI fonts use (Chinese first, or Japanese first). fonts_hans: bool, @@ -142,6 +150,7 @@ impl WordApp { queued_shots: Vec::new(), pending_shots: Vec::new(), synthetic: Vec::new(), + keytip_rects: Vec::new(), styled: false, fonts_hans: false, fonts_frames: 0, @@ -496,6 +505,9 @@ impl WordApp { return; } let t = theme::Tokens::get(&ctx); + // Keytips are read before layout so the frame paints the new state; badges paint after + // the ribbon so they sit on top of it. + crate::keytips::logic(self, &ctx); if self.ui.backstage { backstage::show(self, ui); } else { @@ -508,6 +520,7 @@ impl WordApp { }); } dialogs::show(self, &ctx); + crate::keytips::show(self, &ctx, ui); keys::global_shortcuts(self, &ctx); if let Some(url) = self.canvas.open_url.take() { ctx.open_url(egui::OpenUrl::new_tab(url)); diff --git a/crates/ui-egui/src/mini_toolbar.rs b/crates/ui-egui/src/mini_toolbar.rs new file mode 100644 index 000000000..bd57243ed --- /dev/null +++ b/crates/ui-egui/src/mini_toolbar.rs @@ -0,0 +1,255 @@ +//! Word's floating mini toolbar: the small translucent bar over a non-collapsed text selection +//! with the most-used formatting controls. It mirrors Word's trigger (selection, not caret), +//! position (above, clamped to the viewport, flipping below when tight) and dismissal rules. + +use egui::{Align, CornerRadius, Layout, Order, Rect, Sense, Vec2, pos2}; +use serde_json::{Value, json}; + +use crate::theme::Tokens; +use crate::{WordApp, widgets}; + +/// Height of the bar (a little taller than the ribbon's 22 pt button row). +const BAR_H: f32 = 30.0; +/// Vertical gap between the bar and the selection's first line. +const VGAP: f32 = 8.0; +/// Distance from the selection's left edge at which the bar is centred. +const ANCHOR_INSET: f32 = 4.0; +/// Horizontal padding inside the bar. +const PAD_X: f32 = 4.0; +/// Width of the font and size combos (the ribbon's metrics). +const COMBO_FONT_W: f32 = 150.0; +const COMBO_SIZE_W: f32 = 52.0; +/// Width of one icon button (the ribbon's small-button metric). +const BTN_W: f32 = 24.0; + +/// Fixed bar width, so it never jitters as the selection changes. +pub const BAR_W: f32 = PAD_X * 2.0 + COMBO_FONT_W + COMBO_SIZE_W + 3.0 + 8.0 * BTN_W + 3.0 * 9.0; + +/// Where the bar sits for a selection line and viewport; `None` for degenerate inputs (NaN +/// rects must never reach egui). Pure geometry, unit tested without a window. +pub fn anchor_rect(selection_top: f32, selection_left: f32, viewport: Rect) -> Option { + if !selection_top.is_finite() || !selection_left.is_finite() || !viewport.is_finite() || !viewport.is_positive() { + return None; + } + // Horizontal: centre on the selection's start (a few points in), clamped inside the viewport. + let cx = (selection_left + ANCHOR_INSET).clamp(viewport.left(), viewport.right()); + let x = (cx - BAR_W / 2.0).clamp(viewport.left(), (viewport.right() - BAR_W).max(viewport.left())); + // Vertical: above the selection, unless there is no room, then below it. + let above = selection_top - VGAP - BAR_H; + let y = if above >= viewport.top() { above } else { selection_top + VGAP }; + Some(Rect::from_min_size(pos2(x, y), Vec2::new(BAR_W, BAR_H))) +} + +/// Whether the bar may be shown for the current app state. +pub fn visible(app: &WordApp) -> bool { + // Never over a modal dialog or the Backstage. + if app.dialog.is_some() || app.ui.backstage { + return false; + } + // Never while the right-click context menu is open: it owns the pointer and would double up. + if app.canvas.context_menu_open { + return false; + } + // Never while a drag-select is in progress (Word shows it on mouse-up). + if app.canvas.drag_selecting() { + return false; + } + // Never while keytips are showing: the ribbon owns the letters, and the bar's buttons reuse + // the ribbon's widgets, so they would record duplicate badges. + if app.ui.keytips != crate::keytips::Phase::Off { + return false; + } + // Never for a collapsed caret. + !app.session.sel.is_collapsed() +} + +/// Show the bar: `selection` is the screen rect of the selection's first line, `viewport` the +/// canvas area it must stay inside. +pub fn show(app: &mut WordApp, ctx: &egui::Context, selection: Rect, viewport: Rect) { + if !visible(app) { + return; + } + let Some(bar) = anchor_rect(selection.min.y, selection.min.x, viewport) else { return }; + let t = Tokens::get(ctx); + // The bar sits above the document but below every popup, and is not movable: it is an overlay + // pinned to the selection, so moving it would lose the association. + // Draw the bar pinned above the selection. egui stops a click at the topmost widget, so a + // click on the bar never reaches the document underneath. + let _area = egui::Area::new(egui::Id::new("wc_mini_toolbar")).order(Order::Tooltip).interactable(true).fixed_pos(bar.min); + _area.show(ctx, |ui| { + ui.set_clip_rect(bar); + let painter = ui.painter(); + painter.rect_filled(bar.translate(Vec2::new(0.0, 2.0)), CornerRadius::same(6), t.page_shadow); + painter.rect_filled(bar, CornerRadius::same(6), t.menu); + painter.rect_stroke(bar, CornerRadius::same(6), egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside); + let inner = bar.shrink2(Vec2::new(PAD_X, 0.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(Layout::left_to_right(Align::Center))); + child.spacing_mut().item_spacing = Vec2::new(1.0, 0.0); + content(app, &mut child, &t); + }); +} + +/// The controls, reusing the ribbon's widgets so the bar matches the ribbon exactly. +fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) { + let st: Value = app.session.run("format.state", &json!({})).unwrap_or_default(); + let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); + let align = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props).align).unwrap_or_default(); + + let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string(); + let fams = app.previews.families(); + let prev = app.previews.font_preview_fn(); + if let Some(f) = widgets::combo(ui, "mini_font", COMBO_FONT_W, &font, &fams, Some(&*prev)) { + let _ = app.run("format.font", json!({"name": f})); + } + let size = st.get("size").and_then(Value::as_f64).map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).unwrap_or_default(); + let sizes: Vec = + wordcraft_engine::cmd::format::SIZES.iter().map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).collect(); + if let Some(v) = widgets::combo(ui, "mini_size", COMBO_SIZE_W, &size, &sizes, None) + && let Ok(x) = v.trim().parse::() + { + let _ = app.run("format.size", json!({"size": x})); + } + ui.add_space(3.0); + sep(ui, t); + + widgets::small(ui, app, "bold", None, "Bold", "format.bold", json!({}), flag("bold")); + widgets::small(ui, app, "italic", None, "Italic", "format.italic", json!({}), flag("italic")); + widgets::small(ui, app, "underline", None, "Underline", "format.underline", json!({}), flag("underline")); + widgets::small(ui, app, "strike", None, "Strikethrough", "format.strikethrough", json!({}), flag("strike")); + sep(ui, t); + + let hl = app.canvas.last_highlight.clone(); + widgets::split(ui, app, "highlight", "Text Highlight Color", "format.highlight", json!({"color": hl}), false, None, |ui, app| { + highlight_menu(ui, app); + }); + let fc = app.canvas.last_font_color.clone(); + let sw = wordcraft_doc::Rgb::parse(&fc).map(crate::theme::c32); + widgets::split(ui, app, "fontcolor", "Font Color", "format.color", json!({"color": fc}), false, sw, |ui, app| { + font_color_menu(ui, app); + }); + sep(ui, t); + + use wordcraft_doc::Align as A; + widgets::small(ui, app, "alignLeft", None, "Align Left", "para.alignLeft", json!({}), align == A::Left); + widgets::small(ui, app, "alignCenter", None, "Center", "para.alignCenter", json!({}), align == A::Center); + widgets::small(ui, app, "alignRight", None, "Align Right", "para.alignRight", json!({}), align == A::Right); + widgets::small(ui, app, "justify", None, "Justify", "para.justify", json!({}), align == A::Justify); +} + +/// A hairline divider between the bar's groups. +fn sep(ui: &mut egui::Ui, t: &Tokens) { + let (r, _) = ui.allocate_exact_size(Vec2::new(9.0, 20.0), Sense::hover()); + let p = ui.painter(); + p.vline(r.center().x, r.y_range().shrink(4.0), egui::Stroke::new(1.0, t.border)); +} + +/// Highlight colour grid, matching the ribbon's. +fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) { + egui::Grid::new("mini_hl").spacing(Vec2::new(3.0, 3.0)).show(ui, |ui| { + for (i, h) in wordcraft_doc::props::Highlight::ALL.iter().enumerate().skip(1) { + let Some(c) = h.rgb() else { continue }; + let (r, resp) = ui.allocate_exact_size(Vec2::new(18.0, 18.0), Sense::click()); + ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c.0, c.1, c.2)); + if resp.on_hover_text(h.name()).clicked() { + app.canvas.last_highlight = h.ooxml().to_string(); + let _ = app.run("format.highlight", json!({"color": h.ooxml()})); + ui.close(); + } + if i % 5 == 0 { + ui.end_row(); + } + } + }); + if ui.selectable_label(false, "No Color").clicked() { + let _ = app.run("format.highlight", json!({"color": "none"})); + ui.close(); + } +} + +/// Font colour: Automatic plus the standard colour grid. +fn font_color_menu(ui: &mut egui::Ui, app: &mut WordApp) { + egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { + if ui.selectable_label(false, "Automatic").clicked() { + let _ = app.run("format.color", json!({"color": "auto"})); + ui.close(); + return; + } + let theme = app.session.doc.settings.theme_colors.clone(); + if let Some(hex) = widgets::color_grid(ui, &theme) { + app.canvas.last_font_color = hex.clone(); + let _ = app.run("format.color", json!({"color": hex})); + ui.close(); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vp() -> Rect { + Rect::from_min_size(pos2(100.0, 50.0), Vec2::new(1200.0, 700.0)) + } + + #[test] + fn sits_above_the_selection_when_there_is_room() { + let left = vp().center().x; + let r = anchor_rect(400.0, left, vp()).unwrap_or(Rect::EVERYTHING); + assert_eq!(r.size(), Vec2::new(BAR_W, BAR_H)); + assert!((r.max.y - (400.0 - VGAP)).abs() < 1e-3, "bar bottom should clear the selection by VGAP"); + assert!(r.max.y < 400.0, "the bar must be above the selection"); + assert!((r.center().x - (left + ANCHOR_INSET)).abs() < 1e-3, "bar should be centred on the selection's left edge"); + } + + #[test] + fn centred_unless_the_viewport_forces_a_shift() { + let left = vp().min.x + 20.0; + let r = anchor_rect(400.0, left, vp()).unwrap_or(Rect::EVERYTHING); + assert!((r.min.x - vp().min.x).abs() < 1e-3, "the bar must be pushed onto the viewport's left edge"); + let right = vp().max.x - 20.0; + let r = anchor_rect(400.0, right, vp()).unwrap_or(Rect::EVERYTHING); + assert!((r.max.x - vp().max.x).abs() < 1e-3, "the bar must be pushed onto the viewport's right edge"); + } + + #[test] + fn flips_below_when_there_is_no_room_above() { + let r = anchor_rect(60.0, 300.0, vp()).unwrap_or(Rect::EVERYTHING); + assert!((r.min.y - (60.0 + VGAP)).abs() < 1e-3, "bar top should clear the selection by VGAP below"); + assert!(r.min.y > 60.0, "the bar must be below the selection"); + } + + #[test] + fn never_leaves_the_viewport() { + for left in [0.0, 130.0, 400.0, 2000.0, -500.0] { + let r = anchor_rect(400.0, left, vp()).unwrap_or(Rect::EVERYTHING); + assert!(r.min.x >= vp().min.x - 1e-3, "left {left}: bar left {} out of viewport", r.min.x); + assert!(r.max.x <= vp().max.x + 1e-3, "left {left}: bar right {} out of viewport", r.max.x); + } + } + + #[test] + fn degenerate_inputs_are_rejected_not_crashed() { + assert!(anchor_rect(f32::NAN, 10.0, vp()).is_none()); + assert!(anchor_rect(10.0, f32::NAN, vp()).is_none()); + assert!(anchor_rect(f32::INFINITY, 10.0, vp()).is_none()); + assert!(anchor_rect(10.0, f32::NEG_INFINITY, vp()).is_none()); + assert!(anchor_rect(10.0, 10.0, Rect::EVERYTHING).is_none()); + assert!(anchor_rect(10.0, 10.0, Rect::from_min_size(pos2(0.0, 0.0), Vec2::new(0.0, 100.0))).is_none()); + } + + #[test] + fn a_viewport_narrower_than_the_bar_still_yields_a_rect() { + let r = anchor_rect(10.0, 5.0, Rect::from_min_size(pos2(0.0, 0.0), Vec2::new(20.0, 20.0))); + let r = r.unwrap_or(Rect::EVERYTHING); + assert!(r.width() > 0.0 && r.height() > 0.0, "a tiny viewport must still place a positive-size bar"); + assert!(r.min.x.is_finite() && r.min.y.is_finite()); + } + + #[test] + fn bar_width_covers_exactly_its_controls() { + // The position maths uses BAR_W, so it must equal the width the widgets actually take: + // padding + two combos + a gap + eight 24 pt buttons + three 9 pt dividers. + let expected = PAD_X * 2.0 + COMBO_FONT_W + COMBO_SIZE_W + 3.0 + 8.0 * BTN_W + 3.0 * 9.0; + assert_eq!(BAR_W, expected); + } +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 455c848a4..2cb7c8878 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -32,6 +32,11 @@ pub fn resolve_tab<'a>(current: &'a str, available: &[&str]) -> &'a str { if available.contains(¤t) { current } else { "Home" } } +/// Whether the caret is inside a table (so the contextual tabs and their badges are shown). +pub fn in_table_public(app: &WordApp) -> bool { + app.session.sel.focus.path.cell().is_some() +} + pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // A contextual tab (Table, Picture Format) may be stored while the selection moved away. @@ -61,6 +66,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let shown = tl!(tab); let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x) + 18.0; let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); + if app.ui.keytips == crate::keytips::Phase::Tabs { + crate::keytips::record_tab(r, tab, &mut app.keytip_rects); + } let active = app.ui.tab == tab && !app.ui.backstage; if resp.hovered() && !active { ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 4f0eed1d1..8364215af 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals}; +use egui::{Align2, Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Rect, Stroke, Visuals}; /// WordCraft's app colour (and its darker ink for text on light backgrounds). pub const APP_COLOR: Color32 = Color32::from_rgb(0x3B, 0x5B, 0xDB); @@ -28,6 +28,8 @@ pub struct Tokens { pub checked: Color32, pub accent: Color32, pub accent_text: Color32, + /// Text/icon colour drawn on top of `accent` (e.g. keytip badge letters). + pub on_accent: Color32, pub input: Color32, pub input_border: Color32, pub group_label: Color32, @@ -65,6 +67,7 @@ impl Tokens { checked: Color32::from_rgb(0xDD, 0xE3, 0xF8), accent: APP_COLOR, accent_text: APP_INK, + on_accent: Color32::WHITE, input: Color32::WHITE, input_border: Color32::from_rgb(0xC7, 0xC7, 0xC7), group_label: Color32::from_rgb(0x70, 0x70, 0x70), @@ -93,7 +96,7 @@ impl Tokens { border: Color32::from_rgb(0x3A, 0x3A, 0x3A), border_strong: Color32::from_rgb(0x55, 0x55, 0x55), text: Color32::from_rgb(0xEE, 0xEE, 0xEE), - text_dim: Color32::from_rgb(0xB0, 0xB0, 0xB0), + text_dim: Color32::from_rgb(0xC8, 0xC8, 0xC8), text_disabled: Color32::from_rgb(0x6A, 0x6A, 0x6A), icon: Color32::from_rgb(0xDD, 0xDD, 0xDD), hover: Color32::from_rgb(0x3D, 0x3D, 0x3D), @@ -101,6 +104,7 @@ impl Tokens { checked: Color32::from_rgb(0x2E, 0x3A, 0x66), accent: Color32::from_rgb(0x7A, 0x93, 0xF0), accent_text: Color32::from_rgb(0x9C, 0xB0, 0xF5), + on_accent: Color32::WHITE, input: Color32::from_rgb(0x1E, 0x1E, 0x1E), input_border: Color32::from_rgb(0x58, 0x58, 0x58), group_label: Color32::from_rgb(0xA0, 0xA0, 0xA0), @@ -179,6 +183,84 @@ pub fn regular(size: f32) -> FontId { FontId::proportional(size) } +/// A UI type rung: size, weight pairing, and size-specific letter-spacing (large text is +/// tightened, small text opened). Use these instead of raw `regular(size)`/`semibold(size)`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum TypeRung { + /// Tab strip, title bar, centred window title. + Chrome, + /// Ribbon buttons, menus, dialogs. + Control, + /// Group labels, status bar, tooltips. + Caption, +} + +impl TypeRung { + /// Font size in points. + pub fn size(self) -> f32 { + match self { + TypeRung::Chrome => 12.5, + TypeRung::Control => 11.5, + TypeRung::Caption => 11.0, + } + } + /// Extra letter-spacing in points (em-relative, so it scales with the size). + pub fn tracking(self) -> f32 { + match self { + TypeRung::Chrome => -0.012 * self.size(), + TypeRung::Control => 0.0, + TypeRung::Caption => 0.018 * self.size(), + } + } + /// Line height in points; tighter leading as the text grows. + pub fn line_height(self) -> f32 { + match self { + TypeRung::Chrome => 1.25 * self.size(), + TypeRung::Control => 1.42 * self.size(), + TypeRung::Caption => 1.5 * self.size(), + } + } + + pub fn regular(self) -> FontId { + regular(self.size()) + } + pub fn medium(self) -> FontId { + medium(self.size()) + } + pub fn semibold(self) -> FontId { + semibold(self.size()) + } +} + +/// Width in points of one line of `tracking`-spaced text. `layout_no_wrap` ignores +/// letter-spacing, so non-zero tracking goes through an explicit `LayoutJob`. +pub fn text_width(painter: &egui::Painter, text: &str, font: &FontId, tracking: f32, color: Color32) -> f32 { + if text.is_empty() { + return 0.0; + } + if tracking.abs() <= 1e-6 { + return painter.layout_no_wrap(text.to_string(), font.clone(), color).size().x; + } + painter.layout_job(tracked_job(text, font, tracking, color)).size().x +} + +/// Paint one line of tracked text with the given anchor and return its rect. +pub fn paint_text(painter: &egui::Painter, pos: egui::Pos2, anchor: Align2, text: &str, font: &FontId, tracking: f32, color: Color32) -> Rect { + if tracking.abs() <= 1e-6 { + return painter.text(pos, anchor, text.to_string(), font.clone(), color); + } + let galley = painter.layout_job(tracked_job(text, font, tracking, color)); + let rect = anchor.anchor_size(pos, galley.size()); + painter.galley(rect.min, galley, color); + rect +} + +/// A single-section, no-wrap `LayoutJob` carrying `tracking` (egui 0.36 has no `default_format`). +fn tracked_job(text: &str, font: &FontId, tracking: f32, color: Color32) -> egui::text::LayoutJob { + let format = egui::text::TextFormat { font_id: font.clone(), extra_letter_spacing: tracking, line_height: None, color, ..Default::default() }; + egui::text::LayoutJob::single_section(text.to_string(), format) +} + /// Apply tokens to egui's style. pub fn apply(ctx: &egui::Context, t: &Tokens) { let mut v = if t.dark { Visuals::dark() } else { Visuals::light() }; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index aef0fd8a7..6b16908bd 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -45,6 +45,34 @@ fn tooltip(app: &WordApp, resp: Response, label: &str, id: &str) -> Response { }) } +/// Record a keytip badge over a control's rect when keytips are showing commands. `ui` reaches +/// the phase and badge list through egui memory, so widgets without `&mut WordApp` can record too. +fn keytip_badge(ui: &Ui, rect: Rect, id: &str, name: &str) { + if ui.data(|d| d.get_temp::(crate::keytips::phase_id())) != Some(crate::keytips::Phase::Commands) { + return; + } + let tab = ui.data(|d| d.get_temp::(crate::keytips::tab_id())).unwrap_or_default(); + let controls = crate::keytips::controls_public(&tab); + // Match by command id first; fall back to tooltip prefix, since widget tooltips carry + // extra text ("Paste (⌘V)") the canonical control name does not. + let Some(i) = controls + .iter() + .position(|c| c.id == id && id != "ui.dialog") + .or_else(|| controls.iter().position(|c| c.tip == name)) + .or_else(|| controls.iter().position(|c| name.starts_with(c.tip))) + else { + return; + }; + let letter = ui.data(|d| d.get_temp::>(crate::keytips::letters_id().with(&tab))).and_then(|l| l.get(i).cloned()); + if let Some(l) = letter { + ui.data_mut(|d| { + let mut v: Vec<(Rect, String)> = d.get_temp::>(crate::keytips::rects_id()).unwrap_or_default(); + v.push((rect, l)); + d.insert_temp(crate::keytips::rects_id(), v); + }); + } +} + /// Whether a command exists and is enabled. pub fn enabled(app: &WordApp, id: &str) -> bool { match app.session.registry.get(id) { @@ -77,6 +105,7 @@ pub fn big(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, pa label.split('\n').map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x)).fold(0.0, f32::max); let w = (galley_w + 12.0).max(44.0); let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click()); + keytip_badge(ui, r, id, &label.replace('\n', " ")); let on = enabled(app, id); bg(ui, r, &resp, false, &t); let ic = Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(32.0, 32.0)); @@ -102,6 +131,7 @@ pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, ti let t = Tokens::get(ui.ctx()); let text_w = label.map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x) + 6.0).unwrap_or(0.0); let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w, 22.0), Sense::click()); + keytip_badge(ui, r, id, tip); let on = enabled(app, id); bg(ui, r, &resp, checked, &t); let ic = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(17.0, 17.0)); @@ -132,6 +162,7 @@ pub fn split( let tip = tl!(tip); let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click()); + keytip_badge(ui, r, id, tip); let (ar, aresp) = ui.allocate_exact_size(vec2(11.0, 22.0), Sense::click()); bg(ui, r, &resp, checked, &t); bg(ui, ar, &aresp, false, &t); @@ -171,6 +202,7 @@ pub fn menu_button( .fold(0.0, f32::max); let w = (galley_w + 22.0).max(44.0); let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click()); + keytip_badge(ui, r, "menu", tip); bg(ui, r, &resp, false, &t); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(32.0, 32.0)), icon, t.icon, t.accent); let lines: Vec<&str> = label.unwrap_or("").split('\n').collect(); @@ -184,6 +216,7 @@ pub fn menu_button( } else { let text_w = label.map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x) + 6.0).unwrap_or(0.0); let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w + 10.0, 22.0), Sense::click()); + keytip_badge(ui, r, "menu", tip); bg(ui, r, &resp, false, &t); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(17.0, 17.0)), icon, t.icon, t.accent); if let Some(l) = label { @@ -309,6 +342,7 @@ pub fn combo( ui.spacing_mut().item_spacing = vec2(0.0, 0.0); let te = egui::TextEdit::singleline(&mut text).desired_width(width - 16.0).font(regular(12.0)).margin(vec2(4.0, 3.0)); let resp = ui.add(te); + keytip_badge(ui, resp.rect, "combo", id); if resp.has_focus() { ui.data_mut(|d| d.insert_temp(edit_id, text.clone())); } else {