UI: keytips, mini-toolbar, ribbon overflow, macOS typography (#43)

* UI polish: keytips, mini-toolbar, ribbon overflow, macOS typography

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Address review: fix ribbon overflow activation, macOS Alt, token colors, cleanup

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Contributors: credit chiranjeevi-max in the About window

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Ribbon overflow: keep main's ‹ › edge cues (#129); keytip tab badges on main's tab strip

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 07:55:20 -04:00
1 parent 130c2dab40
commit a70042a008
13 files changed
+1275 -20

No files matched your search

+5
View File
@@ -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
+27
View File
@@ -38,6 +38,10 @@ pub struct CanvasState {
pub want_focus: bool,
pub context_issue: Option<serde_json::Value>,
pub context_synonyms: Option<serde_json::Value>,
/// 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<Rect>,
}
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<Rect>,
@@ -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<Rect> = 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.
+52 -17
View File
@@ -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!({}));
}
+2
View File
@@ -73,6 +73,8 @@ fn key_from(name: &str) -> Option<egui::Key> {
"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,
+27
View File
@@ -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::<f32>().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!({
+4
View File
@@ -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);
+5 -1
View File
@@ -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 {
+759
View File
@@ -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<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"),
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<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(|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<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: 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<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 => {}
}
}
}
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<egui::Event> = 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::<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);
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);
}
}
#[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<String> = 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);
}
}
+13
View File
@@ -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<window_geometry::WindowGeometry>,
/// 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<String>, std::sync::mpsc::Sender<Value>, f64)>,
pub(crate) synthetic: Vec<egui::Event>,
/// 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));
+255
View File
@@ -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<Rect> {
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<String> =
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::<f64>()
{
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);
}
}
+8
View File
@@ -32,6 +32,11 @@ pub fn resolve_tab<'a>(current: &'a str, available: &[&str]) -> &'a str {
if available.contains(&current) { 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);
+84 -2
View File
@@ -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() };
+34
View File
@@ -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>(crate::keytips::phase_id())) != Some(crate::keytips::Phase::Commands) {
return;
}
let tab = ui.data(|d| d.get_temp::<String>(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::<Vec<String>>(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::<Vec<(Rect, String)>>(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 {