Merge PR #563 from NikitkaRa: UI: customizable Quick Access Toolbar

# Conflicts:
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/ribbon.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:16:22 -04:00
5 changed files with 235 additions and 26 deletions
+40
View File
@@ -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<qat::QatItem>,
}
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::<Vec<_>>().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::<Vec<_>>();
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;
+175
View File
@@ -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<QatItem> {
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<QatItem> {
// 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<Value, String> {
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, &params)) {
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, &params));
}
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<QatItem> = 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();
}
});
}
+16 -24
View File
@@ -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) {
+2 -2
View File
@@ -19,7 +19,7 @@ The summary between `<!-- SUMMARY -->
| 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 |
+2
View File
@@ -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)