mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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, ¶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<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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user