diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 12e8f2ee..f9b90f38 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,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; @@ -117,6 +118,8 @@ pub struct UiState { /// Comment the Review Previous/Next buttons last landed on: `(slide, index)`. #[serde(skip_serializing_if = "Option::is_none")] pub comment_focus: Option<(usize, usize)>, + /// Quick Access Toolbar buttons, left to right. + pub qat: Vec, } impl Default for UiState { @@ -147,6 +150,7 @@ impl Default for UiState { smartart_drafts: (0, vec![]), grayscale: false, comment_focus: None, + qat: qat::defaults(), } } } @@ -209,6 +213,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.browseThemeDialog", "Browse for Themes…", None, "{}"), ("app.dialog", "Open Dialog", None, "{id}"), ("app.pasteSpecial", "Paste Special…", Some("Cmd+Alt+V"), "{}"), + ("app.qat", "Quick Access Toolbar", None, "{add: command id, params?, label?, icon?} | {remove: id, params?} | {reset: true} → [buttons]"), ("app.links", "Community Links", None, "{}"), ]; @@ -642,6 +647,7 @@ impl SlideApp { self.set_status("Browse for Themes needs the desktop app for now".to_string()); } } + "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)); @@ -1320,6 +1326,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", "app.exportDialog", "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", "app.exportDialog", "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", "app.exportDialog", "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..a0b77dbd --- /dev/null +++ b/crates/ui-egui/src/qat.rs @@ -0,0 +1,175 @@ +//! 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 { + // Home, Save, Export, Undo, Redo, Start from Beginning: the title bar's buttons before it was customizable. + let all = common(); + ["app.home", "file.save", "app.exportDialog", "edit.undo", "edit.redo", "show.fromStart"] + .iter() + .filter_map(|id| all.iter().find(|q| q.id == *id).cloned()) + .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(app.show_start_index(), 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 2e0ec40b..85f98652 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -35,6 +35,11 @@ fn title_geometry(integrated: bool) -> (f32, f32, f32) { } /// 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(), @@ -69,24 +74,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::Export, "Export…", has_doc, false).clicked() { - let _ = app.run("app.exportDialog", json!({})); - } - 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(app.show_start_index(), 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); @@ -264,14 +252,16 @@ 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); - let resp = big_button(ui, icon, label, en); + let r = big_button(ui, icon, label, en); if id == "insert.dateTime" { let name = label.replace('\n', " "); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, en, &name)); + r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, en, &name)); } - if resp.on_hover_text(tip(id, label)).clicked() { - run(app, id, p); + let r = r.on_hover_text(tip(id, label)); + if r.clicked() { + run(app, id, p.clone()); } + crate::qat::ribbon_menu(app, &r, icon, label, id, &p); } /// Photo Album is a split button: the main click starts a new album, the arrow edits one. @@ -297,9 +287,11 @@ fn photo_album_button(app: &mut SlideApp, ui: &mut Ui) { 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 2e8c1c5c..198f209b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -19,7 +19,7 @@ The summary between ` | Area | Parity | |---|---| -| Application shell | 77% | +| Application shell | 81% | | Files | 71% | | Slides | 90% | | Views | 93% | @@ -56,7 +56,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 6db243d9..c0003628 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -51,6 +51,8 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa | 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 | New Window opens one more window on the same presentation (its own slide, live with edits); tabs stay in the first window; no Arrange All | Arrange All | | Quick Access Toolbar customization | yes | no | P1 partial | +| One window per presentation, New Window, Arrange All | yes | tabs in one window; no New Window | 3–5 h | +| 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)