From 88bea399e5904533ef915a45c3ce10b3891e5f87 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 00:04:51 +0300 Subject: [PATCH] UI: customizable Quick Access Toolbar The title bar's quick access buttons become a list the user controls: a Customize menu with PowerPoint's common commands and Reset, right-click any ribbon button to add it, right-click a toolbar button to remove it. The toolbar is saved with the UI state; app.qat scripts it. Co-authored-by: Cursor --- crates/ui-egui/src/lib.rs | 40 +++++++++ crates/ui-egui/src/qat.rs | 170 +++++++++++++++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 33 +++---- docs/parity-checklist.md | 8 +- docs/ui-parity.md | 2 +- 5 files changed, 229 insertions(+), 24 deletions(-) create mode 100644 crates/ui-egui/src/qat.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..a7dbe5aa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod icons; pub mod media; pub mod menus; pub mod panes; +pub mod qat; pub mod ribbon; pub mod selpane; pub mod show; @@ -102,6 +103,8 @@ pub struct UiState { /// Format Shape pane: tab (`fill`, `effects`, `size`, `picture`, `text`). pub format_tab: String, pub grayscale: bool, + /// Quick Access Toolbar buttons, left to right. + pub qat: Vec, } impl Default for UiState { @@ -127,6 +130,7 @@ impl Default for UiState { ui_scale: 1.0, format_tab: "fill".into(), grayscale: false, + qat: qat::defaults(), } } } @@ -168,6 +172,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.saveAsDialog", "Save As…", None, "{}"), ("app.exportDialog", "Export…", None, "{}"), ("app.dialog", "Open Dialog", None, "{id}"), + ("app.qat", "Quick Access Toolbar", None, "{add: command id, params?, label?, icon?} | {remove: id, params?} | {reset: true} → [buttons]"), ("app.links", "Community Links", None, "{}"), ]; @@ -541,6 +546,7 @@ impl SlideApp { "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), "app.saveAsDialog" => self.save_as_dialog("deckcraft"), "app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")), + "app.qat" => return qat::command(self, p), "app.dialog" => { let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; self.dialog = Some(dialogs::Dialog::new(d)); @@ -1142,6 +1148,40 @@ mod tests { sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::>().join("\n")).unwrap_or_default() } + #[test] + fn quick_access_toolbar_adds_removes_resets_and_persists() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let ids = |app: &SlideApp| app.ui.qat.iter().map(|q| q.id.clone()).collect::>(); + assert_eq!(ids(harness.state()), ["app.home", "file.save", "edit.undo", "edit.redo", "show.fromStart"]); + let app = harness.state_mut(); + app.run("app.qat", json!({"add": "insert.table", "label": "Table", "icon": "table"})).unwrap(); + app.run("app.qat", json!({"add": "insert.table"})).unwrap(); + app.run("app.qat", json!({"add": "view.pane", "params": {"pane": "selection"}, "label": "Selection Pane"})).unwrap(); + assert!(app.run("app.qat", json!({"add": "no.such"})).is_err()); + app.run("app.qat", json!({"remove": "edit.redo"})).unwrap(); + assert_eq!(ids(app), ["app.home", "file.save", "edit.undo", "show.fromStart", "insert.table", "view.pane"]); + assert_eq!(app.ui.qat[4].label, "Table"); + // Saved with the rest of the UI state and read back. + let back: UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); + assert_eq!(back.qat, app.ui.qat); + harness.run_steps(2); + let app = harness.state_mut(); + app.run("app.qat", json!({"remove": "view.pane", "params": {"pane": "selection"}})).unwrap(); + assert_eq!(app.ui.qat.len(), 5); + app.run("app.qat", json!({"reset": true})).unwrap(); + assert_eq!(ids(app), ["app.home", "file.save", "edit.undo", "edit.redo", "show.fromStart"]); + // An older settings file without the toolbar gets the defaults. + let old: UiState = serde_json::from_value(json!({"tab": "Home"})).unwrap(); + assert_eq!(old.qat.len(), 5); + } + #[test] fn opening_a_file_with_missing_fonts_shows_a_toast_once() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/qat.rs b/crates/ui-egui/src/qat.rs new file mode 100644 index 00000000..85ab77af --- /dev/null +++ b/crates/ui-egui/src/qat.rs @@ -0,0 +1,170 @@ +//! Quick Access Toolbar: the command buttons at the left of the title bar, which the user can +//! add to (right-click a ribbon button, or the toolbar's Customize menu), remove from and reset. + +use egui::Ui; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::SlideApp; +use crate::icons::Icon; + +/// One toolbar button: a command, its parameters, and how it looks. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct QatItem { + pub id: String, + #[serde(skip_serializing_if = "Value::is_null")] + pub params: Value, + pub label: String, + /// An [`Icon::name`]. + pub icon: String, +} + +impl Default for QatItem { + fn default() -> Self { + QatItem { id: String::new(), params: Value::Null, label: String::new(), icon: Icon::More.name().into() } + } +} + +impl QatItem { + pub fn new(id: &str, label: &str, icon: Icon) -> Self { + QatItem { id: id.into(), params: Value::Null, label: label.into(), icon: icon.name().into() } + } + fn icon(&self) -> Icon { + Icon::from_name(&self.icon).unwrap_or(Icon::More) + } + fn same(&self, id: &str, params: &Value) -> bool { + self.id == id && (self.params == *params || (self.params.is_null() && params.as_object().is_some_and(|o| o.is_empty()))) + } +} + +/// The commands the Customize menu offers as checkboxes, in PowerPoint's order. +pub fn common() -> Vec { + vec![ + QatItem::new("app.home", "Home", Icon::Home), + QatItem::new("file.new", "New", Icon::File), + QatItem::new("app.openDialog", "Open", Icon::Folder), + QatItem::new("file.save", "Save", Icon::Save), + QatItem::new("app.print", "Print", Icon::Print), + QatItem::new("edit.undo", "Undo", Icon::Undo), + QatItem::new("edit.redo", "Redo", Icon::Redo), + QatItem::new("show.fromStart", "Start from Beginning", Icon::PlayFromStart), + QatItem::new("app.find", "Find", Icon::Search), + QatItem::new("review.spelling", "Spelling", Icon::Spelling), + QatItem::new("app.exportDialog", "Export", Icon::Export), + ] +} + +/// The toolbar a new install starts with. +pub fn defaults() -> Vec { + common().into_iter().filter(|q| matches!(q.id.as_str(), "app.home" | "file.save" | "edit.undo" | "edit.redo" | "show.fromStart")).collect() +} + +/// `app.qat`: `{add: id, params?, label?, icon?} | {remove: id, params?} | {reset: true}` → the toolbar. +pub fn command(app: &mut SlideApp, p: &Value) -> Result { + let params = p.get("params").cloned().unwrap_or(Value::Null); + if p.get("reset").and_then(Value::as_bool) == Some(true) { + app.ui.qat = defaults(); + } + if let Some(id) = p.get("add").and_then(Value::as_str) { + if !crate::ribbon::known(id) { + return Err(format!("unknown command `{id}`")); + } + if !app.ui.qat.iter().any(|q| q.same(id, ¶ms)) { + let base = common().into_iter().find(|q| q.id == id); + let label = p.get("label").and_then(Value::as_str).map(String::from).or_else(|| base.as_ref().map(|b| b.label.clone())); + let label = label.or_else(|| deckcraft_engine::find_command(id).map(|c| c.label.to_string())).unwrap_or_else(|| id.to_string()); + let icon = p.get("icon").and_then(Value::as_str).and_then(Icon::from_name).or_else(|| base.map(|b| b.icon())).unwrap_or(Icon::More); + app.ui.qat.push(QatItem { id: id.into(), params: params.clone(), label, icon: icon.name().into() }); + } + } + if let Some(id) = p.get("remove").and_then(Value::as_str) { + app.ui.qat.retain(|q| !q.same(id, ¶ms)); + } + serde_json::to_value(&app.ui.qat).map_err(|e| e.to_string()) +} + +fn run(app: &mut SlideApp, q: &QatItem) { + match q.id.as_str() { + "file.save" => app.save(), + "show.fromStart" => app.start_show(0, false), + "review.spelling" => app.dialog = Some(crate::dialogs::Dialog::new("spelling")), + id => { + let p = if q.params.is_null() { json!({}) } else { q.params.clone() }; + let _ = app.run(id, p); + } + } +} + +fn tip(q: &QatItem) -> String { + let sc = + deckcraft_engine::find_command(&q.id).and_then(|c| c.shortcut).or_else(|| crate::UI_COMMANDS.iter().find(|c| c.0 == q.id).and_then(|c| c.2)); + match sc { + Some(s) => format!("{} ({})", q.label, crate::ribbon::pretty_shortcut(s)), + None => q.label.clone(), + } +} + +/// Draw the toolbar and its Customize menu into a left-to-right row of the title bar. +pub fn show(app: &mut SlideApp, ui: &mut Ui) { + let items = app.ui.qat.clone(); + for q in &items { + let on = q.id == "app.home" && app.backstage_open(); + let en = match q.id.as_str() { + "app.home" => true, + "file.save" | "show.fromStart" | "review.spelling" | "app.find" => app.session.active().is_some(), + id => crate::ribbon::enabled(app, id), + }; + let r = crate::widgets::icon_toggle(ui, q.icon(), &tip(q), en, on); + if r.clicked() { + run(app, q); + } + r.context_menu(|ui| { + if ui.button("Remove from Quick Access Toolbar").clicked() { + let _ = command(app, &json!({"remove": q.id, "params": q.params})); + ui.close(); + } + }); + } + let r = crate::widgets::icon_toggle(ui, Icon::ChevronDown, "Customize Quick Access Toolbar", true, false); + egui::Popup::menu(&r).show(|ui| { + ui.label(egui::RichText::new("Customize Quick Access Toolbar").strong()); + for c in common() { + let mut on = app.ui.qat.iter().any(|q| q.same(&c.id, &Value::Null)); + if ui.checkbox(&mut on, &c.label).changed() { + let key = if on { "add" } else { "remove" }; + let _ = command(app, &json!({key: c.id})); + } + } + let extra: Vec = app.ui.qat.iter().filter(|q| !common().iter().any(|c| q.same(&c.id, &Value::Null))).cloned().collect(); + if !extra.is_empty() { + ui.separator(); + for q in extra { + let mut on = true; + if ui.checkbox(&mut on, &q.label).changed() { + let _ = command(app, &json!({"remove": q.id, "params": q.params})); + } + } + } + ui.separator(); + ui.label(egui::RichText::new("Right-click any ribbon button to add it.").weak()); + if ui.button("Reset to Defaults").clicked() { + let _ = command(app, &json!({"reset": true})); + ui.close(); + } + }); +} + +/// The ribbon buttons' right-click menu: add the button to the toolbar. +pub fn ribbon_menu(app: &mut SlideApp, r: &egui::Response, icon: Icon, label: &str, id: &str, p: &Value) { + r.context_menu(|ui| { + let has = app.ui.qat.iter().any(|q| q.same(id, p)); + let text = if has { "Remove from Quick Access Toolbar" } else { "Add to Quick Access Toolbar" }; + if ui.button(text).clicked() { + let label = label.replace('\n', " "); + let key = if has { "remove" } else { "add" }; + let _ = command(app, &json!({key: id, "params": p, "label": label, "icon": icon.name()})); + ui.close(); + } + }); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..03e8090f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -13,6 +13,11 @@ use crate::widgets::{self, RIBBON_H, big_button, big_button_ex, color_split, dro pub const TITLE_H: f32 = 38.0; /// Is engine command `id` enabled right now? +/// Whether `id` names an engine or UI command. +pub fn known(id: &str) -> bool { + deckcraft_engine::find_command(id).is_some() || crate::UI_COMMANDS.iter().any(|c| c.0 == id) +} + pub fn enabled(app: &SlideApp, id: &str) -> bool { match deckcraft_engine::find_command(id) { Some(c) => (c.enabled)(&app.session).is_ok(), @@ -47,21 +52,7 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { child.spacing_mut().item_spacing.x = 2.0; let has_doc = app.session.active().is_some(); let dirty = app.session.active().is_some_and(|d| d.is_dirty()); - if icon_toggle(&mut child, Icon::Home, "Home", true, app.backstage_open()).clicked() { - let _ = app.run("app.home", json!({})); - } - if icon_toggle(&mut child, Icon::Save, "Save (⌘S)", has_doc, false).clicked() { - app.save(); - } - if icon_toggle(&mut child, Icon::Undo, "Undo (⌘Z)", enabled(app, "edit.undo"), false).clicked() { - run(app, "edit.undo", json!({})); - } - if icon_toggle(&mut child, Icon::Redo, "Redo (⌘Y)", enabled(app, "edit.redo"), false).clicked() { - run(app, "edit.redo", json!({})); - } - if icon_toggle(&mut child, Icon::PlayFromStart, "Play from Start (F5)", has_doc, false).clicked() { - app.start_show(0, false); - } + crate::qat::show(app, &mut child); let title = app.session.active().map(|d| format!("{}{}", d.title(), if dirty { " — Edited" } else { "" })).unwrap_or_else(|| "DeckCraft".into()); ui.painter().text(rect.center(), Align2::CENTER_CENTER, title, theme::font(13.0), t.text); @@ -229,16 +220,20 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { fn big(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) { let en = enabled(app, id); - if big_button(ui, icon, label, en).on_hover_text(tip(id, label)).clicked() { - run(app, id, p); + let r = big_button(ui, icon, label, en).on_hover_text(tip(id, label)); + if r.clicked() { + run(app, id, p.clone()); } + crate::qat::ribbon_menu(app, &r, icon, label, id, &p); } fn small(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) { let en = enabled(app, id); - if small_button(ui, icon, label, en, false).on_hover_text(tip(id, label)).clicked() { - run(app, id, p); + let r = small_button(ui, icon, label, en, false).on_hover_text(tip(id, label)); + if r.clicked() { + run(app, id, p.clone()); } + crate::qat::ribbon_menu(app, &r, icon, label, id, &p); } fn toggle(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, on: bool) { diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..d5399b8f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -3,17 +3,17 @@ > **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The summary between ` -**Weighted breadth parity: 79%** over 191 features. +**Weighted breadth parity: 80%** over 191 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | -| P1 | 65 | 38 | 21 | 6 | 75% | +| P1 | 65 | 39 | 20 | 6 | 75% | | P2 | 54 | 13 | 15 | 26 | 38% | | Area | Parity | |---|---| -| Application shell | 77% | +| Application shell | 81% | | Files | 71% | | Slides | 90% | | Views | 90% | @@ -45,7 +45,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Application shell | Start screen (new, templates/themes, recent) | P0 | D | | Application shell | Ribbon with Home/Insert/Draw/Design/Transitions/Animations/Slide Show/Review/View tabs | P0 | D | | Application shell | Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design) | P0 | P | -| Application shell | Quick Access toolbar / title bar commands | P1 | P | +| Application shell | Quick Access toolbar / title bar commands | P1 | D | | Application shell | Native menu bar (macOS) with every command | P1 | M | | Application shell | Command palette / Tell me search | P1 | D | | Application shell | Keyboard shortcuts for every common command | P0 | D | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..02e25474 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -24,7 +24,7 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa | Command search (Tell Me) | yes | command palette | #79 default entry fails | | Light / dark appearance | follows the system | light or dark; doesn't follow the system (#41) | 1–2 h | | One window per presentation, New Window, Arrange All | yes | tabs in one window; no New Window | 3–5 h | -| Quick Access Toolbar customization | yes | no | P1 partial | +| Quick Access Toolbar customization | yes | yes: Customize menu (common commands, reset), right-click any ribbon button to add, right-click a toolbar button to remove; saved with the UI state | done | ## File tab (Backstage)