mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-14 03:29:04 +00:00
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:
13 files changed
+1275
-20
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user