diff --git a/ROADMAP.md b/ROADMAP.md index 9fcb2a6d7..5366d1023 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | Customize Ribbon and the Quick Access Toolbar: custom tabs and groups, hide and reorder tabs, add or remove toolbar commands (right-click any button, or the toolbar's … menu); kept between runs (#379, #376) | | 2026-10-11 | Customize Keyboard: assign, remove and reset keyboard shortcuts; custom keys override built-ins and are kept between runs (#368) | | 2026-10-11 | Field codes like Word's field keys (#339): show codes (Alt+F9, Shift+F9), edit, unlink, lock and step through fields | | 2026-10-11 | Word's common keyboard shortcuts (#330): 101 → 125 of ~250 | @@ -153,6 +154,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Progress log: Customize Ribbon and the Quick Access Toolbar (#379) | | 2026-10-11 | trivial | Progress log: Customize Keyboard (#368) | | 2026-10-11 | trivial | Word's common keyboard shortcuts (#330): 125 of ~250 | | 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges | diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 6e93158e7..12b964979 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -94,6 +94,11 @@ pub fn specs() -> Vec { r#"{"command"?: id (its keys), "key"?: "Mod+Shift+K" (the command it runs), "assign"?: {"command": id, "key": string}, "remove"?: {"command": id, "key": string}, "reset"?: true (built-in keys only)} → {custom: {assigned: {key: id}, removed: [key]}, …}; no params opens the dialog"#, ) .pure(), + CommandSpec::new("tools.customizeRibbon", "Customize Ribbon", "File › Options", customize_ribbon) + .params( + r#"{"hide"?: tab, "show"?: tab, "newTab"?: {"name"?, "after"?: tab}, "newGroup"?: {"tab", "name"?}, "add"?: {"tab", "group", "command", "after"?: id}, "remove"?: {"tab", "group"?, "command"?}, "rename"?: {"tab", "group"?, "to"}, "move"?: {"tab", "group"?, "command"?, "by": int}, "qatAdd"?: id, "qatRemove"?: id, "qatMove"?: {"command", "by": int}, "reset"?: true | "ribbon" | "qat", "layout"?: object} → {tabs: [{name, custom?, hidden?, groups?: [{name, commands}]}], qat: [id]}; only custom tabs and groups can be renamed or removed (built-in tabs can be hidden and moved); no params opens the dialog"#, + ) + .pure(), CommandSpec::new("review.compare", "Compare", "Review › Compare", compare).params(r#"{"path"?: string, "text"?: string (revised version)}"#), CommandSpec::new("review.combine", "Combine", "Review › Compare", compare).params(r#"{"path"?: string}"#), CommandSpec::new("file.accessibility", "Check Accessibility", "Review › Accessibility", accessibility).pure(), @@ -949,6 +954,86 @@ fn customize_keyboard(s: &mut Session, v: &Value) -> CmdResult { Ok(Value::Object(out)) } +/// Tools › Customize Ribbon: read and change the ribbon's tabs, custom groups and the Quick Access +/// Toolbar ([`crate::RibbonLayout`]). Without params it opens the dialog; with any, it only does +/// what they ask (no dialog). +fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult { + let reg = s.registry.clone(); + let l = &mut s.ribbon; + let e = CmdError::Params; + let str_of = |x: &Value, k: &str| x.get(k).and_then(Value::as_str).map(str::to_string); + let req = |x: &Value, k: &str| p::req_str(x, k).map(str::to_string); + let by = |x: &Value| x.get("by").and_then(Value::as_i64).ok_or_else(|| e("`by` (integer, e.g. -1 or 1) is required".into())); + let mut out = serde_json::Map::new(); + let mut asked = false; + if let Some(x) = v.get("layout") { + asked = true; + *l = crate::RibbonLayout::from_value(x); + l.retain_known(®); + } + if let Some(r) = v.get("reset") { + asked = true; + match (r.as_bool(), r.as_str()) { + (Some(true), _) => *l = crate::RibbonLayout::default(), + (_, Some("ribbon")) => l.reset_ribbon(), + (_, Some("qat")) => l.reset_qat(), + _ => return Err(e("`reset` is true, \"ribbon\" or \"qat\"".into())), + } + } + for (key, hidden) in [("hide", true), ("show", false)] { + if let Some(tab) = p::str(v, key) { + asked = true; + l.set_hidden(tab, hidden).map_err(e)?; + } + } + if let Some(x) = v.get("newTab") { + asked = true; + let name = l.new_tab(str_of(x, "name").as_deref(), str_of(x, "after").as_deref()).map_err(e)?; + out.insert("tab".into(), json!(name)); + } + if let Some(x) = v.get("newGroup") { + asked = true; + let name = l.new_group(&req(x, "tab")?, str_of(x, "name").as_deref()).map_err(e)?; + out.insert("group".into(), json!(name)); + } + if let Some(x) = v.get("add") { + asked = true; + let added = l.add(®, &req(x, "tab")?, &req(x, "group")?, &req(x, "command")?, str_of(x, "after").as_deref()).map_err(e)?; + out.insert("added".into(), json!(added)); + } + if let Some(x) = v.get("remove") { + asked = true; + l.remove(&req(x, "tab")?, str_of(x, "group").as_deref(), str_of(x, "command").as_deref()).map_err(e)?; + } + if let Some(x) = v.get("rename") { + asked = true; + let name = l.rename(&req(x, "tab")?, str_of(x, "group").as_deref(), &req(x, "to")?).map_err(e)?; + out.insert("name".into(), json!(name)); + } + if let Some(x) = v.get("move") { + asked = true; + l.move_by(&req(x, "tab")?, str_of(x, "group").as_deref(), str_of(x, "command").as_deref(), by(x)?).map_err(e)?; + } + if let Some(id) = p::str(v, "qatAdd") { + asked = true; + out.insert("added".into(), json!(l.qat_add(®, id, None).map_err(e)?)); + } + if let Some(id) = p::str(v, "qatRemove") { + asked = true; + out.insert("removed".into(), json!(l.qat_remove(id))); + } + if let Some(x) = v.get("qatMove") { + asked = true; + l.qat_move(&req(x, "command")?, by(x)?).map_err(e)?; + } + out.insert("tabs".into(), serde_json::to_value(l.tabs().as_ref()).unwrap_or(Value::Null)); + out.insert("qat".into(), json!(l.qat())); + if !asked { + s.ui_requests.push(json!({"open": "customizeRibbon"})); + } + Ok(Value::Object(out)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3a394da1d..e9c610134 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -12,6 +12,7 @@ pub mod io; mod io_ext; pub mod keymap; pub mod math_gallery; +pub mod ribbon; pub mod sample; mod session; pub mod speech; @@ -22,6 +23,7 @@ use serde_json::Value; pub use io::Password; pub use keymap::KeyMap; +pub use ribbon::RibbonLayout; pub use session::{ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState}; pub use wordcraft_doc as doc; pub use wordcraft_layout as layout; diff --git a/crates/engine/src/ribbon.rs b/crates/engine/src/ribbon.rs new file mode 100644 index 000000000..06099704e --- /dev/null +++ b/crates/engine/src/ribbon.rs @@ -0,0 +1,505 @@ +//! Customize Ribbon and the Quick Access Toolbar (`tools.customizeRibbon`): which ribbon tabs are +//! shown and in what order, custom tabs, custom groups (on built-in or custom tabs) holding any +//! commands, and the commands on the Quick Access Toolbar. +//! +//! Built-in groups aren't editable: a built-in tab only gains custom groups after its own. The +//! layout is saved with the front end's preferences; reading a saved layout never fails: junk, +//! duplicates and commands this version doesn't have are dropped. + +use std::borrow::Cow; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::Registry; + +/// The ribbon's tabs in their default order. `File` opens Backstage: it is always first and +/// isn't part of the customizable list. +pub const TABS: [&str; 12] = ["File", "Home", "Insert", "Draw", "Design", "Layout", "References", "Mailings", "Review", "View", "Zotero", "Help"]; +/// Tabs that come up with the selection; custom tabs can't take their names. +pub const CONTEXTUAL_TABS: [&str; 5] = ["Table Design", "Table Layout", "Picture Format", "Shape Format", "Equation"]; +/// The Quick Access Toolbar's commands until the user changes them. +pub const DEFAULT_QAT: [&str; 3] = ["file.save", "edit.undo", "edit.redo"]; + +/// Limits on what a (possibly hand-edited) saved layout can hold. +const MAX_TABS: usize = 64; +const MAX_GROUPS: usize = 64; +const MAX_COMMANDS: usize = 200; +const MAX_NAME: usize = 64; +const MAX_ID: usize = 128; + +/// A ribbon tab in the customized order: a built-in tab (which can be hidden and gain custom +/// groups) or a custom one. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct TabEntry { + pub name: String, + #[serde(skip_serializing_if = "is_false")] + pub custom: bool, + #[serde(skip_serializing_if = "is_false")] + pub hidden: bool, + /// Custom groups, after a built-in tab's own groups. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub groups: Vec, +} + +/// A custom group: a name and the commands it shows, in order. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct CustomGroup { + pub name: String, + pub commands: Vec, +} + +/// The user's ribbon and Quick Access Toolbar. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct RibbonLayout { + /// Every tab but File, in order, once the ribbon is customized; empty for the default ribbon. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tabs: Vec, + /// The Quick Access Toolbar's commands; `None` for the default ones ([`DEFAULT_QAT`]). + #[serde(skip_serializing_if = "Option::is_none")] + pub qat: Option>, +} + +fn is_false(b: &bool) -> bool { + !*b +} + +/// A name the user typed: trimmed, without control characters, at most [`MAX_NAME`] characters. +fn clean_name(s: &str) -> Option { + let s: String = s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect(); + let s = s.trim().to_string(); + (!s.is_empty()).then_some(s) +} + +fn same(a: &str, b: &str) -> bool { + a.to_lowercase() == b.to_lowercase() +} + +fn built_in(name: &str) -> bool { + TABS.iter().skip(1).any(|t| *t == name) +} + +/// Names a custom tab can't have. +fn reserved(name: &str) -> bool { + TABS.iter().chain(CONTEXTUAL_TABS.iter()).any(|t| same(t, name)) +} + +fn default_tabs() -> Vec { + TABS.iter().skip(1).map(|t| TabEntry { name: (*t).to_string(), custom: false, hidden: false, groups: Vec::new() }).collect() +} + +/// `base`, or `base 2`, `base 3`… whichever `taken` doesn't have yet. +fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String { + if !taken(base) { + return base.to_string(); + } + (2..1000).map(|n| format!("{base} {n}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) +} + +/// Move the item at `i` by `by` places (clamped to the list). Returns its new index. +fn shift(v: &mut [T], i: usize, by: i64) -> usize { + let last = v.len().saturating_sub(1) as i64; + let to = (i as i64).saturating_add(by).clamp(0, last) as usize; + if to < i { + if let Some(s) = v.get_mut(to..=i) { + s.rotate_right(1); + } + } else if let Some(s) = v.get_mut(i..=to) { + s.rotate_left(1); + } + to +} + +impl RibbonLayout { + /// Read a saved layout, keeping only well-formed entries (never fails). + pub fn from_value(v: &Value) -> RibbonLayout { + let mut out = RibbonLayout::default(); + for t in v.get("tabs").and_then(Value::as_array).into_iter().flatten().take(MAX_TABS) { + let Some(name) = t.get("name").and_then(Value::as_str).and_then(clean_name) else { continue }; + let custom = t.get("custom").and_then(Value::as_bool).unwrap_or(false); + let hidden = t.get("hidden").and_then(Value::as_bool).unwrap_or(false); + let mut groups: Vec = Vec::new(); + for g in t.get("groups").and_then(Value::as_array).into_iter().flatten().take(MAX_GROUPS) { + let Some(gname) = g.get("name").and_then(Value::as_str).and_then(clean_name) else { continue }; + if groups.iter().any(|x| same(&x.name, &gname)) { + continue; + } + let mut commands: Vec = Vec::new(); + for id in g.get("commands").and_then(Value::as_array).into_iter().flatten().take(MAX_COMMANDS).filter_map(Value::as_str) { + if !id.is_empty() && id.len() <= MAX_ID && !commands.iter().any(|c| c == id) { + commands.push(id.to_string()); + } + } + groups.push(CustomGroup { name: gname, commands }); + } + out.tabs.push(TabEntry { name, custom, hidden, groups }); + } + if let Some(q) = v.get("qat").and_then(Value::as_array) { + let mut ids: Vec = Vec::new(); + for id in q.iter().take(MAX_COMMANDS).filter_map(Value::as_str) { + if !id.is_empty() && id.len() <= MAX_ID && !ids.iter().any(|c| c == id) { + ids.push(id.to_string()); + } + } + out.qat = Some(ids); + } + out.normalize(); + out + } + + /// Keep the tab list consistent: built-in tabs once each (missing ones back in their default + /// place), custom tabs with unique, unreserved names; the default ribbon is stored as empty. + fn normalize(&mut self) { + if self.tabs.is_empty() { + return; + } + let mut tabs: Vec = Vec::new(); + for t in std::mem::take(&mut self.tabs) { + let ok = if t.custom { !reserved(&t.name) } else { built_in(&t.name) }; + if ok && !tabs.iter().any(|x| same(&x.name, &t.name)) { + tabs.push(t); + } + } + // Built-in tabs a saved layout lacks (a newer version's) go after the tab before them. + for (i, name) in TABS.iter().enumerate().skip(1) { + if tabs.iter().any(|t| !t.custom && t.name == *name) { + continue; + } + let at = TABS.get(1..i).and_then(|before| before.iter().rev().find_map(|b| tabs.iter().position(|t| !t.custom && t.name == *b))); + let entry = TabEntry { name: (*name).to_string(), custom: false, hidden: false, groups: Vec::new() }; + tabs.insert(at.map_or(0, |p| p + 1), entry); + } + self.tabs = if tabs == default_tabs() { Vec::new() } else { tabs }; + } + + /// The tabs in order (File excluded), customized or not. + pub fn tabs(&self) -> Cow<'_, [TabEntry]> { + if self.tabs.is_empty() { Cow::Owned(default_tabs()) } else { Cow::Borrowed(&self.tabs) } + } + + /// Whether the ribbon is the default one. + pub fn ribbon_is_default(&self) -> bool { + self.tabs.is_empty() + } + + /// The Quick Access Toolbar's commands. + pub fn qat(&self) -> Vec<&str> { + match &self.qat { + Some(q) => q.iter().map(String::as_str).collect(), + None => DEFAULT_QAT.to_vec(), + } + } + + /// Drop commands that don't exist (saved by another version, or junk). + pub fn retain_known(&mut self, reg: &Registry) { + for t in &mut self.tabs { + for g in &mut t.groups { + g.commands.retain(|id| reg.get(id).is_some()); + } + } + if let Some(q) = &mut self.qat { + q.retain(|id| reg.get(id).is_some()); + } + } + + /// Edit the tab list (customizing a default ribbon first), then tidy it up. + fn edit(&mut self, f: impl FnOnce(&mut Vec) -> Result) -> Result { + let mut tabs = self.tabs().into_owned(); + let r = f(&mut tabs)?; + self.tabs = tabs; + self.normalize(); + Ok(r) + } + + fn tab_index(tabs: &[TabEntry], name: &str) -> Result { + tabs.iter().position(|t| t.name == name).ok_or_else(|| format!("no tab `{name}`")) + } + + fn group_index(tab: &TabEntry, name: &str) -> Result { + tab.groups.iter().position(|g| g.name == name).ok_or_else(|| format!("tab `{}` has no custom group `{name}`", tab.name)) + } + + /// Show or hide a tab. + pub fn set_hidden(&mut self, tab: &str, hidden: bool) -> Result<(), String> { + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + if let Some(t) = tabs.get_mut(i) { + t.hidden = hidden; + } + Ok(()) + }) + } + + /// Add a custom tab (with one empty group) after `after`, or at the end. Returns its name. + pub fn new_tab(&mut self, name: Option<&str>, after: Option<&str>) -> Result { + let name = match name { + Some(n) => clean_name(n).ok_or("a tab needs a name")?, + None => "New Tab".to_string(), + }; + self.edit(|tabs| { + if tabs.len() >= MAX_TABS { + return Err("too many tabs".into()); + } + let name = unique(&name, |n| reserved(n) || tabs.iter().any(|t| same(&t.name, n))); + let at = match after { + Some(a) => Self::tab_index(tabs, a)? + 1, + None => tabs.len(), + }; + let group = CustomGroup { name: "New Group".into(), commands: Vec::new() }; + tabs.insert(at, TabEntry { name: name.clone(), custom: true, hidden: false, groups: vec![group] }); + Ok(name) + }) + } + + /// Add a custom group at the end of a tab. Returns its name. + pub fn new_group(&mut self, tab: &str, name: Option<&str>) -> Result { + let name = match name { + Some(n) => clean_name(n).ok_or("a group needs a name")?, + None => "New Group".to_string(), + }; + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + let t = tabs.get_mut(i).ok_or("no such tab")?; + if t.groups.len() >= MAX_GROUPS { + return Err("too many groups on this tab".into()); + } + let name = unique(&name, |n| t.groups.iter().any(|g| same(&g.name, n))); + t.groups.push(CustomGroup { name: name.clone(), commands: Vec::new() }); + Ok(name) + }) + } + + /// Add a command to a custom group (after `after`, or at the end). False when it's already there. + pub fn add(&mut self, reg: &Registry, tab: &str, group: &str, id: &str, after: Option<&str>) -> Result { + if reg.get(id).is_none() { + return Err(format!("unknown command `{id}`")); + } + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + let t = tabs.get_mut(i).ok_or("no such tab")?; + let j = Self::group_index(t, group)?; + let g = t.groups.get_mut(j).ok_or("no such group")?; + if g.commands.iter().any(|c| c == id) { + return Ok(false); + } + if g.commands.len() >= MAX_COMMANDS { + return Err("too many commands in this group".into()); + } + let at = after.and_then(|a| g.commands.iter().position(|c| c == a)).map_or(g.commands.len(), |p| p + 1); + g.commands.insert(at, id.to_string()); + Ok(true) + }) + } + + /// Remove a command from a custom group, a custom group, or a custom tab. + pub fn remove(&mut self, tab: &str, group: Option<&str>, command: Option<&str>) -> Result<(), String> { + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + let Some(group) = group else { + if !tabs.get(i).is_some_and(|t| t.custom) { + return Err(format!("`{tab}` is a built-in tab: hide it instead")); + } + tabs.remove(i); + return Ok(()); + }; + let t = tabs.get_mut(i).ok_or("no such tab")?; + let j = Self::group_index(t, group)?; + match command { + None => { + t.groups.remove(j); + } + Some(id) => { + let g = t.groups.get_mut(j).ok_or("no such group")?; + let k = g.commands.iter().position(|c| c == id).ok_or_else(|| format!("`{group}` doesn't have `{id}`"))?; + g.commands.remove(k); + } + } + Ok(()) + }) + } + + /// Rename a custom tab or a custom group. Returns the new name. + pub fn rename(&mut self, tab: &str, group: Option<&str>, to: &str) -> Result { + let to = clean_name(to).ok_or("the new name is empty")?; + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + match group { + None => { + if !tabs.get(i).is_some_and(|t| t.custom) { + return Err(format!("`{tab}` is a built-in tab and keeps its name")); + } + if reserved(&to) || tabs.iter().enumerate().any(|(k, t)| k != i && same(&t.name, &to)) { + return Err(format!("there is already a tab named `{to}`")); + } + if let Some(t) = tabs.get_mut(i) { + t.name = to.clone(); + } + } + Some(group) => { + let t = tabs.get_mut(i).ok_or("no such tab")?; + let j = Self::group_index(t, group)?; + if t.groups.iter().enumerate().any(|(k, g)| k != j && same(&g.name, &to)) { + return Err(format!("`{tab}` already has a group named `{to}`")); + } + if let Some(g) = t.groups.get_mut(j) { + g.name = to.clone(); + } + } + } + Ok(to) + }) + } + + /// Move a tab, a custom group (within its tab) or a command (within its group) by `by` places. + pub fn move_by(&mut self, tab: &str, group: Option<&str>, command: Option<&str>, by: i64) -> Result<(), String> { + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + let Some(group) = group else { + shift(tabs, i, by); + return Ok(()); + }; + let t = tabs.get_mut(i).ok_or("no such tab")?; + let j = Self::group_index(t, group)?; + match command { + None => { + shift(&mut t.groups, j, by); + } + Some(id) => { + let g = t.groups.get_mut(j).ok_or("no such group")?; + let k = g.commands.iter().position(|c| c == id).ok_or_else(|| format!("`{group}` doesn't have `{id}`"))?; + shift(&mut g.commands, k, by); + } + } + Ok(()) + }) + } + + /// Back to the default ribbon (the Quick Access Toolbar stays). + pub fn reset_ribbon(&mut self) { + self.tabs.clear(); + } + + /// Back to the default Quick Access Toolbar. + pub fn reset_qat(&mut self) { + self.qat = None; + } + + /// Put a command on the Quick Access Toolbar (after `after`, or at the end). False when it's + /// already there. + pub fn qat_add(&mut self, reg: &Registry, id: &str, after: Option<&str>) -> Result { + if reg.get(id).is_none() { + return Err(format!("unknown command `{id}`")); + } + let mut q: Vec = self.qat().into_iter().map(str::to_string).collect(); + if q.iter().any(|c| c == id) { + return Ok(false); + } + if q.len() >= MAX_COMMANDS { + return Err("the Quick Access Toolbar is full".into()); + } + let at = after.and_then(|a| q.iter().position(|c| c == a)).map_or(q.len(), |p| p + 1); + q.insert(at, id.to_string()); + self.qat = Some(q); + Ok(true) + } + + /// Take a command off the Quick Access Toolbar. False when it wasn't there. + pub fn qat_remove(&mut self, id: &str) -> bool { + let mut q: Vec = self.qat().into_iter().map(str::to_string).collect(); + let Some(i) = q.iter().position(|c| c == id) else { return false }; + q.remove(i); + self.qat = Some(q); + true + } + + /// Move a Quick Access Toolbar command by `by` places. + pub fn qat_move(&mut self, id: &str, by: i64) -> Result<(), String> { + let mut q: Vec = self.qat().into_iter().map(str::to_string).collect(); + let i = q.iter().position(|c| c == id).ok_or_else(|| format!("`{id}` isn't on the Quick Access Toolbar"))?; + shift(&mut q, i, by); + self.qat = Some(q); + Ok(()) + } +} + +impl<'de> Deserialize<'de> for RibbonLayout { + /// Lenient: any JSON reads (junk dropped), so a damaged layout never loses the other + /// preferences saved next to it. + fn deserialize>(d: D) -> Result { + Ok(RibbonLayout::from_value(&Value::deserialize(d)?)) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + /// #379: custom tabs and groups, hidden and moved tabs, and the Quick Access Toolbar, changed + /// through the command (as agents do) without opening the dialog. + #[test] + fn layout_changes_through_the_command() { + let mut s = crate::Session::new(wordcraft_doc::Document::new()); + let mut run = |v: Value| s.run("tools.customizeRibbon", &v); + let r = run(json!({"newTab": {"name": "Mine", "after": "Home"}, "hide": "Draw"})).unwrap(); + assert_eq!(r["tab"], "Mine"); + run(json!({"add": {"tab": "Mine", "group": "New Group", "command": "format.bold"}})).unwrap(); + run(json!({"newGroup": {"tab": "Insert", "name": "Extras"}})).unwrap(); + run(json!({"add": {"tab": "Insert", "group": "Extras", "command": "edit.copy"}})).unwrap(); + run(json!({"rename": {"tab": "Mine", "to": "Writing"}})).unwrap(); + let r = run(json!({"move": {"tab": "Writing", "by": -5}, "qatAdd": "format.bold", "qatRemove": "file.save"})).unwrap(); + let tabs = r["tabs"].as_array().unwrap(); + assert_eq!(tabs[0]["name"], "Writing"); + assert_eq!(tabs[0]["groups"][0]["commands"], json!(["format.bold"])); + assert!(tabs.iter().any(|t| t["name"] == "Draw" && t["hidden"] == true)); + assert_eq!(r["qat"], json!(["edit.undo", "edit.redo", "format.bold"])); + // Built-in tabs keep their names and can only be hidden; reserved and unknown names fail. + assert!(run(json!({"rename": {"tab": "Home", "to": "Start"}})).is_err()); + assert!(run(json!({"remove": {"tab": "Home"}})).is_err()); + assert!(run(json!({"rename": {"tab": "Writing", "to": "insert"}})).is_err()); + assert!(run(json!({"add": {"tab": "Writing", "group": "New Group", "command": "no.such"}})).is_err()); + assert!(run(json!({"move": {"tab": "Nope", "by": 1}})).is_err()); + drop(run); + assert!(s.ui_requests.is_empty(), "programmatic calls never open dialogs"); + s.run("tools.customizeRibbon", &json!({"reset": true})).unwrap(); + assert_eq!(s.ribbon, RibbonLayout::default()); + s.run("tools.customizeRibbon", &json!({})).unwrap(); + assert_eq!(s.ui_requests.last(), Some(&json!({"open": "customizeRibbon"}))); + } + + #[test] + fn saved_layouts_round_trip_and_junk_is_ignored() { + let reg = crate::cmd::registry(); + let mut l = RibbonLayout::default(); + l.new_tab(None, None).unwrap(); + l.add(®, "New Tab", "New Group", "format.italic", None).unwrap(); + l.set_hidden("Mailings", true).unwrap(); + l.qat_add(®, "edit.repeat", None).unwrap(); + let back: RibbonLayout = serde_json::from_str(&serde_json::to_string(&l).unwrap()).unwrap(); + assert_eq!(back, l); + + let junk = json!({ + "tabs": [ + {"name": "Home", "groups": [{"name": "G", "commands": ["format.bold", "gone.command", 7, "format.bold"]}]}, + {"name": "Home"}, + {"name": "Bogus"}, + {"name": "Review", "custom": true}, + {"name": " "}, + {"name": "Mine", "custom": true, "hidden": "yes", "groups": "x"}, + 5 + ], + "qat": ["edit.undo", "gone.command", 3, "edit.undo"] + }); + let mut j: RibbonLayout = serde_json::from_value(junk).unwrap(); + j.retain_known(®); + let tabs = j.tabs(); + assert_eq!(tabs.len(), TABS.len(), "11 built-in tabs and Mine: {tabs:?}"); + assert_eq!(tabs[0].groups[0].commands, ["format.bold"]); + assert!(tabs.iter().any(|t| t.custom && t.name == "Mine" && !t.hidden)); + assert_eq!(j.qat(), ["edit.undo"]); + for v in [json!(null), json!(5), json!("x"), json!({"tabs": {"a": 1}, "qat": "file.save"})] { + assert_eq!(serde_json::from_value::(v).unwrap(), RibbonLayout::default()); + } + } +} diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index a0bf2fb8f..32e9738a6 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -255,6 +255,8 @@ pub struct Session { pub prefs: Prefs, /// Custom keyboard shortcuts (`tools.customizeKeyboard`); the front end saves them too. pub keymap: crate::KeyMap, + /// Custom ribbon and Quick Access Toolbar (`tools.customizeRibbon`); the front end saves it too. + pub ribbon: crate::RibbonLayout, /// Editing inside an equation: which one and the caret in it. pub math: Option, /// Equations are typed in LaTeX rather than the linear format. @@ -358,6 +360,7 @@ impl Session { also_selected: Vec::new(), prefs: Prefs::default(), keymap: crate::KeyMap::default(), + ribbon: crate::RibbonLayout::default(), read_aloud: Default::default(), password: None, column: None, @@ -630,6 +633,7 @@ impl Session { read_aloud: _, prefs: _, keymap: _, + ribbon: _, // Equation editing mode, like `view`: not part of the document or its history. math: _, math_latex: _, diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index dc36371e3..65da76050 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -415,6 +415,12 @@ fn options_page(app: &mut WordApp, ui: &mut Ui) { let _ = app.run("tools.customizeKeyboard", json!({})); } }); + ui.horizontal(|ui| { + ui.label(tl!("Ribbon and Quick Access Toolbar:")); + if ui.button(tl!("Customize…")).clicked() { + let _ = app.run("tools.customizeRibbon", json!({})); + } + }); ui.add_space(10.0); ui.label(egui::RichText::new(tl!("Agents")).font(semibold(15.0))); ui.label(tl!("Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control ` for the JSON control channel.")); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e4be4aa94..cf0105729 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -7,7 +7,7 @@ use wordcraft_doc::StoryRef; 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) { +fn qat_button(ui: &mut Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, enabled: bool) -> egui::Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(26.0, 24.0), Sense::click()); if resp.hovered() && enabled { @@ -20,6 +20,66 @@ fn qat_button(ui: &mut Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, e if resp.clicked() && enabled { let _ = app.run(id, json!({})); } + resp +} + +/// Commands the Quick Access Toolbar's menu offers to show or hide with one click (#376). +const QAT_MENU: [&str; 10] = + ["file.new", "file.open", "file.save", "file.print", "edit.undo", "edit.redo", "edit.repeat", "review.spelling", "view.readMode", "insert.table"]; + +/// The Quick Access Toolbar (#379): the user's commands (Save, Undo, Redo until customized), each +/// with a right-click menu, then the menu button that customizes it (#376). +fn quick_access(ui: &mut Ui, app: &mut WordApp) { + let ids: Vec = app.session.ribbon.qat().into_iter().map(str::to_string).collect(); + for id in &ids { + let id = id.as_str(); + let (tip, enabled) = match id { + "edit.undo" => (crate::i18n::prefixed("Undo", app.session.undo_label().unwrap_or("")), app.session.can_undo()), + "edit.redo" => (tl!("Redo").to_string(), app.session.can_redo()), + "file.save" => (tl!("Save").to_string(), true), + _ => (app.session.registry.get(id).map(|s| tl!(s.label).to_string()).unwrap_or_default(), crate::widgets::enabled(app, id)), + }; + let resp = qat_button(ui, app, crate::widgets::command_icon(id), &tip, id, enabled); + crate::widgets::command_menu(&resp, app, id); + } + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(26.0, 24.0), Sense::hover()); + let resp = ui.interact(r, qat_menu_button_id(), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), "more", t.icon, t.accent); + let resp = resp.on_hover_text(tl!("Customize Quick Access Toolbar")); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(220.0); + qat_menu(ui, app); + }); +} + +/// The Quick Access Toolbar's menu button (…), which opens [`qat_menu`]. +pub(crate) fn qat_menu_button_id() -> egui::Id { + egui::Id::new("qat_menu_button") +} + +/// The Quick Access Toolbar's menu: common commands to show or hide, and More Commands… +pub(crate) fn qat_menu(ui: &mut Ui, app: &mut WordApp) { + ui.label(egui::RichText::new(tl!("Customize Quick Access Toolbar")).font(semibold(12.0))); + ui.separator(); + for id in QAT_MENU { + let Some(label) = app.session.registry.get(id).map(|s| tl!(s.label).to_string()) else { continue }; + let on = app.session.ribbon.qat().contains(&id); + let mark = egui::RichText::new("✓"); + let mark = if on { mark } else { mark.color(egui::Color32::TRANSPARENT) }; + if ui.add(egui::Button::new((mark, label)).selected(on)).clicked() { + let key = if on { "qatRemove" } else { "qatAdd" }; + let _ = app.run("tools.customizeRibbon", json!({ key: id })); + } + } + ui.separator(); + if ui.button(tl!("More Commands…")).clicked() { + crate::dialogs_ribbon::open(app, crate::dialogs_ribbon::Page::QuickAccess); + ui.close(); + } } /// Height of the macOS integrated title bar (#225). AppKit draws the traffic lights itself, centred @@ -94,12 +154,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { } } ui.add_space(8.0); - qat_button(ui, app, "save", tl!("Save"), "file.save", true); - let can_undo = app.session.can_undo(); - let can_redo = app.session.can_redo(); - qat_button(ui, app, "undo", &crate::i18n::prefixed("Undo", app.session.undo_label().unwrap_or("")), "edit.undo", can_undo); - qat_button(ui, app, "redo", tl!("Redo"), "edit.redo", can_redo); - qat_button(ui, app, "more", tl!("Customize Quick Access Toolbar"), "ui.dialog", true); + quick_access(ui, app); qat_end = ui.min_rect().max.x; ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Account / community. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 30cce0dae..01a7d3965 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3,7 +3,7 @@ //! Properties, Command search, Paste Special, About, Save Changes, the password to open a //! document and Encrypt with Password, and the mail-merge Recipient List, Insert Merge Field, Find //! Recipient, merge rules, Match Fields and Check for Errors (Customize Keyboard is in -//! `dialogs_keyboard`). Every dialog ends by running a +//! `dialogs_keyboard`, Customize Ribbon in `dialogs_ribbon`). Every dialog ends by running a //! command (or shows one's result), so agents get the same result without the dialog. use egui::{Sense, Ui, vec2}; @@ -259,6 +259,10 @@ pub enum Dialog { CustomizeKeyboard { form: Box, }, + /// Tools › Customize Ribbon and the Quick Access Toolbar (#379). + CustomizeRibbon { + form: Box, + }, } /// The Table Properties dialog's fields. Lengths are in the interface unit @@ -667,6 +671,7 @@ impl Dialog { Dialog::DefineList { .. } => "defineList", Dialog::TrackOptions { .. } => "trackChangesOptions", Dialog::CustomizeKeyboard { .. } => "customizeKeyboard", + Dialog::CustomizeRibbon { .. } => "customizeRibbon", } } @@ -835,6 +840,12 @@ impl Dialog { "defineList" => Dialog::DefineList { form: Box::new(crate::dialogs_lists::ListForm::read(app)) }, "trackChangesOptions" => Dialog::TrackOptions { form: Box::new(crate::dialogs_lists::TrackForm::read(app)) }, "customizeKeyboard" => Dialog::CustomizeKeyboard { form: Box::new(crate::dialogs_keyboard::KeyboardForm::read(app)) }, + "customizeRibbon" => { + Dialog::CustomizeRibbon { form: Box::new(crate::dialogs_ribbon::RibbonForm::read(app, crate::dialogs_ribbon::Page::Ribbon)) } + } + "customizeQuickAccess" => { + Dialog::CustomizeRibbon { form: Box::new(crate::dialogs_ribbon::RibbonForm::read(app, crate::dialogs_ribbon::Page::QuickAccess)) } + } _ => return None, }) } @@ -993,6 +1004,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::DefineList { .. } => "Define New Multilevel List", Dialog::TrackOptions { .. } => "Track Changes Options", Dialog::CustomizeKeyboard { .. } => "Customize Keyboard", + Dialog::CustomizeRibbon { .. } => "Customize Ribbon", }; let id = egui::Id::new(("dialog", title)); let shown = place_dialog(ctx, id, egui::Window::new(tl!(title)).id(id).collapsible(false).resizable(false)).open(&mut open).show(ctx, |ui| { @@ -1834,6 +1846,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { Dialog::DefineList { form } => crate::dialogs_lists::define_list(app, ui, form), Dialog::TrackOptions { form } => crate::dialogs_lists::track_options(app, ui, form), Dialog::CustomizeKeyboard { form } => crate::dialogs_keyboard::customize_keyboard(app, ui, form), + Dialog::CustomizeRibbon { form } => crate::dialogs_ribbon::customize_ribbon(app, ui, form), Dialog::FieldCode { code, edit, message } => { ui.label(tl!("Field codes:")); let r = ui.add(egui::TextEdit::singleline(code).desired_width(320.0).font(egui::TextStyle::Monospace)); diff --git a/crates/ui-egui/src/dialogs_keyboard.rs b/crates/ui-egui/src/dialogs_keyboard.rs index f5a45f598..1c5638594 100644 --- a/crates/ui-egui/src/dialogs_keyboard.rs +++ b/crates/ui-egui/src/dialogs_keyboard.rs @@ -41,7 +41,7 @@ impl KeyboardForm { /// Categories: the ribbon tabs commands live on, in ribbon order, then the other places /// (Navigation, Editing…) alphabetically. -fn categories(app: &WordApp) -> Vec { +pub(crate) fn categories(app: &WordApp) -> Vec { let mut other: Vec = Vec::new(); let mut tabs: Vec<&str> = Vec::new(); for spec in app.session.registry.all() { @@ -59,7 +59,7 @@ fn categories(app: &WordApp) -> Vec { } /// A category's commands as (id, label in the interface language), sorted by label. -fn commands(app: &WordApp, category: &str) -> Vec<(&'static str, String)> { +pub(crate) fn commands(app: &WordApp, category: &str) -> Vec<(&'static str, String)> { let mut out: Vec<(&'static str, String)> = app .session .registry @@ -101,7 +101,7 @@ fn pressed_key(ui: &Ui) -> Option { } /// A bordered, scrolling list of (value, text) rows; returns the clicked value. -fn list(ui: &mut Ui, id: &str, size: egui::Vec2, rows: &[(String, String)], selected: &str) -> Option { +pub(crate) fn list(ui: &mut Ui, id: &str, size: egui::Vec2, rows: &[(String, String)], selected: &str) -> Option { let mut picked = None; egui::Frame::new().stroke(ui.visuals().widgets.noninteractive.bg_stroke).inner_margin(2.0).show(ui, |ui| { ui.set_width(size.x); diff --git a/crates/ui-egui/src/dialogs_ribbon.rs b/crates/ui-egui/src/dialogs_ribbon.rs new file mode 100644 index 000000000..cb73e3718 --- /dev/null +++ b/crates/ui-egui/src/dialogs_ribbon.rs @@ -0,0 +1,478 @@ +//! Tools › Customize Ribbon (#379; File › Options, command search, right-click on the ribbon or +//! the Quick Access Toolbar): two pages. Ribbon: commands by category on the left, the ribbon's +//! tabs with their custom groups on the right; show or hide tabs, add custom tabs and groups, +//! put commands in custom groups, rename, reorder, remove, reset. Quick Access Toolbar: the same +//! command list next to the toolbar's commands. Every change runs `tools.customizeRibbon`, so +//! agents get the same result without the dialog; the layout persists with the preferences. + +use egui::{Ui, vec2}; +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::WordApp; +use crate::dialogs::Dialog; +use crate::dialogs_keyboard::{categories, commands, list}; +use crate::theme::semibold; + +/// The dialog's two pages. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Page { + #[default] + Ribbon, + QuickAccess, +} + +/// An item in the ribbon tree: a tab, one of its custom groups, or a command in a group. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub struct TreeItem { + pub tab: String, + pub group: Option, + pub command: Option, +} + +/// The dialog's state. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RibbonForm { + pub page: Page, + /// The left list: a category (empty for All Commands) and the command picked in it. + pub category: String, + pub command: String, + /// The item picked in the ribbon tree. + pub selected: TreeItem, + /// The command picked in the Quick Access Toolbar list. + pub qat_selected: String, + /// Rename… asked: the name being typed. + pub renaming: Option, + /// Reset asked; waiting for Yes / No. + pub confirm_reset: bool, + /// Why the last change didn't happen. + pub message: String, +} + +impl RibbonForm { + pub fn read(app: &WordApp, page: Page) -> RibbonForm { + let category = categories(app).into_iter().next().unwrap_or_default(); + let command = commands(app, &category).first().map(|(id, _)| (*id).to_string()).unwrap_or_default(); + let selected = TreeItem { tab: app.session.ribbon.tabs().first().map(|t| t.name.clone()).unwrap_or_default(), ..Default::default() }; + RibbonForm { page, category, command, selected, ..Default::default() } + } +} + +/// Open the dialog on a page. +pub fn open(app: &mut WordApp, page: Page) { + app.dialog = Some(Dialog::CustomizeRibbon { form: Box::new(RibbonForm::read(app, page)) }); +} + +/// Run `tools.customizeRibbon`; the result, or why it failed (shown in the dialog). +fn change(app: &mut WordApp, f: &mut RibbonForm, params: Value) -> Option { + match app.session.run("tools.customizeRibbon", ¶ms) { + Ok(v) => { + f.message.clear(); + Some(v) + } + Err(e) => { + f.message = e.to_string(); + None + } + } +} + +fn label_of(app: &WordApp, id: &str) -> String { + app.session.registry.get(id).map_or_else(|| id.to_string(), |s| tl!(s.label).to_string()) +} + +/// The dialog body; true closes it. +pub fn customize_ribbon(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm) -> bool { + ui.horizontal(|ui| { + for (page, name) in [(Page::Ribbon, "Ribbon"), (Page::QuickAccess, "Quick Access Toolbar")] { + if ui.selectable_label(f.page == page, tl!(name)).clicked() && f.page != page { + f.page = page; + f.renaming = None; + f.confirm_reset = false; + f.message.clear(); + } + } + }); + ui.separator(); + let (left, right, h) = (150.0, 230.0, 300.0); + let mut cats: Vec<(String, String)> = categories(app).into_iter().map(|c| (c.clone(), tl!(&c).to_string())).collect(); + cats.push((String::new(), tl!("All Commands").to_string())); + let cmds: Vec<(String, String)> = commands(app, &f.category).into_iter().map(|(id, l)| (id.to_string(), l)).collect(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.label(egui::RichText::new(tl!("Choose a command")).font(semibold(12.5))); + ui.horizontal_top(|ui| { + if let Some(c) = list(ui, "rb_categories", vec2(left, h), &cats, &f.category) { + f.category = c; + f.command = commands(app, &f.category).first().map(|(id, _)| (*id).to_string()).unwrap_or_default(); + } + if let Some(c) = list(ui, "rb_commands", vec2(right, h), &cmds, &f.command) { + f.command = c; + } + }); + }); + ui.add_space(4.0); + match f.page { + Page::Ribbon => ribbon_page(app, ui, f, h), + Page::QuickAccess => qat_page(app, ui, f, h), + } + }); + if let Some(spec) = app.session.registry.get(&f.command) { + ui.label(egui::RichText::new(format!("{} · {}", crate::i18n::location(spec.location), spec.id)).small().weak()); + } + if !f.message.is_empty() { + ui.colored_label(ui.visuals().warn_fg_color, f.message.as_str()); + } + ui.add_space(6.0); + if f.confirm_reset { + let ask = match f.page { + Page::Ribbon => tl!("Remove every custom tab and group and show all tabs again?"), + Page::QuickAccess => tl!("Put the default commands back on the Quick Access Toolbar?"), + }; + ui.label(ask); + ui.horizontal(|ui| { + if ui.button(tl!("Yes")).clicked() { + let what = if f.page == Page::Ribbon { "ribbon" } else { "qat" }; + change(app, f, json!({"reset": what})); + f.selected = TreeItem { tab: app.session.ribbon.tabs().first().map(|t| t.name.clone()).unwrap_or_default(), ..Default::default() }; + f.confirm_reset = false; + } + if ui.button(tl!("No")).clicked() { + f.confirm_reset = false; + } + }); + return false; + } + let mut close = false; + ui.horizontal(|ui| { + let custom = match f.page { + Page::Ribbon => !app.session.ribbon.ribbon_is_default(), + Page::QuickAccess => app.session.ribbon.qat.is_some(), + }; + if ui.add_enabled(custom, egui::Button::new(tl!("Reset…"))).clicked() { + f.confirm_reset = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(tl!("Close")).clicked() { + close = true; + } + }); + }); + close +} + +/// A row in the ribbon tree: the item, its indent level, its text, and (tabs) whether it shows. +struct Row { + item: TreeItem, + depth: u8, + text: String, + shown: Option, +} + +fn tree_rows(app: &WordApp) -> Vec { + let custom = tl!("(Custom)"); + let mut rows = Vec::new(); + for t in app.session.ribbon.tabs().iter() { + let text = if t.custom { format!("{} {custom}", t.name) } else { tl!(&t.name).to_string() }; + rows.push(Row { item: TreeItem { tab: t.name.clone(), group: None, command: None }, depth: 0, text, shown: Some(!t.hidden) }); + for g in &t.groups { + let item = TreeItem { tab: t.name.clone(), group: Some(g.name.clone()), command: None }; + rows.push(Row { item, depth: 1, text: format!("{} {custom}", g.name), shown: None }); + for id in &g.commands { + let item = TreeItem { tab: t.name.clone(), group: Some(g.name.clone()), command: Some(id.clone()) }; + rows.push(Row { item, depth: 2, text: label_of(app, id), shown: None }); + } + } + } + rows +} + +/// The params naming a tree item (`{tab, group?, command?}`). +fn address(item: &TreeItem) -> serde_json::Map { + let mut m = serde_json::Map::new(); + m.insert("tab".into(), json!(item.tab)); + if let Some(g) = &item.group { + m.insert("group".into(), json!(g)); + } + if let Some(c) = &item.command { + m.insert("command".into(), json!(c)); + } + m +} + +fn ribbon_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) { + let rows = tree_rows(app); + if !rows.iter().any(|r| r.item == f.selected) { + f.selected = rows.first().map(|r| r.item.clone()).unwrap_or_default(); + } + let sel = f.selected.clone(); + let tab_custom = app.session.ribbon.tabs().iter().any(|t| t.name == sel.tab && t.custom); + // Add >> / << Remove. + ui.vertical(|ui| { + ui.add_space(h / 2.0 - 10.0); + let can_add = sel.group.is_some() && !f.command.is_empty(); + let add = ui.add_enabled(can_add, egui::Button::new(tl!("Add >>")).min_size(vec2(96.0, 0.0))); + if add.on_disabled_hover_text(tl!("Pick a custom group (New Group makes one)")).clicked() { + let mut m = address(&sel); + m.remove("command"); + m.insert("command".into(), json!(f.command)); + if let Some(after) = &sel.command { + m.insert("after".into(), json!(after)); + } + if change(app, f, json!({"add": m})).is_some() { + f.selected = TreeItem { command: Some(f.command.clone()), ..sel.clone() }; + } + } + let can_remove = sel.group.is_some() || tab_custom; + if ui.add_enabled(can_remove, egui::Button::new(tl!("<< Remove")).min_size(vec2(96.0, 0.0))).clicked() + && change(app, f, json!({"remove": address(&sel)})).is_some() + { + f.selected = match (&sel.group, &sel.command) { + (Some(_), Some(_)) => TreeItem { command: None, ..sel.clone() }, + _ => TreeItem { tab: sel.tab.clone(), ..Default::default() }, + }; + } + }); + ui.add_space(4.0); + ui.vertical(|ui| { + ui.label(egui::RichText::new(tl!("Ribbon tabs")).font(semibold(12.5))); + ui.horizontal_top(|ui| { + tree(app, ui, f, &rows, h); + ui.vertical(|ui| { + ui.add_space(h / 2.0 - 10.0); + for (text, by) in [("Up", -1), ("Down", 1)] { + if ui.add(egui::Button::new(tl!(text)).min_size(vec2(56.0, 0.0))).clicked() { + let mut m = address(&sel); + m.insert("by".into(), json!(by)); + change(app, f, json!({"move": m})); + } + } + }); + }); + ui.horizontal(|ui| { + if ui.button(tl!("New Tab")).clicked() + && let Some(v) = change(app, f, json!({"newTab": {"after": sel.tab}})) + && let Some(tab) = v.get("tab").and_then(Value::as_str) + { + f.selected = TreeItem { tab: tab.to_string(), group: Some("New Group".into()), command: None }; + } + if ui.button(tl!("New Group")).clicked() + && let Some(v) = change(app, f, json!({"newGroup": {"tab": sel.tab}})) + && let Some(g) = v.get("group").and_then(Value::as_str) + { + f.selected = TreeItem { tab: sel.tab.clone(), group: Some(g.to_string()), command: None }; + } + let can_rename = sel.command.is_none() && (sel.group.is_some() || tab_custom); + if ui.add_enabled(can_rename, egui::Button::new(tl!("Rename…"))).clicked() { + f.renaming = Some(sel.group.clone().unwrap_or_else(|| sel.tab.clone())); + } + }); + let mut done = None; + if let Some(name) = &mut f.renaming { + ui.horizontal(|ui| { + ui.label(tl!("Name:")); + let r = ui.add(egui::TextEdit::singleline(name).desired_width(140.0)); + let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if ui.button(tl!("OK")).clicked() || enter { + done = Some(Some(name.clone())); + } + if ui.button(tl!("Cancel")).clicked() { + done = Some(None); + } + }); + } + if let Some(answer) = done { + f.renaming = None; + if let Some(to) = answer { + let mut m = address(&sel); + m.insert("to".into(), json!(to)); + if let Some(v) = change(app, f, json!({"rename": m})) + && let Some(name) = v.get("name").and_then(Value::as_str) + { + f.selected = match &sel.group { + Some(_) => TreeItem { group: Some(name.to_string()), ..sel.clone() }, + None => TreeItem { tab: name.to_string(), ..Default::default() }, + }; + } + } + } + }); +} + +/// The ribbon tree: tabs with a box to show or hide them, their custom groups and commands. +fn tree(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, rows: &[Row], h: f32) { + egui::Frame::new().stroke(ui.visuals().widgets.noninteractive.bg_stroke).inner_margin(2.0).show(ui, |ui| { + ui.set_width(250.0); + ui.set_height(h); + egui::ScrollArea::vertical().id_salt("rb_tree").auto_shrink([false, false]).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| tree_rows_ui(app, ui, f, rows)); + }); + }); +} + +fn tree_rows_ui(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, rows: &[Row]) { + for row in rows { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + // Groups and commands line up under the tab names (past the tabs' boxes). + ui.add_space(if row.depth == 0 { 0.0 } else { 24.0 + f32::from(row.depth.saturating_sub(1)) * 16.0 }); + if let Some(shown) = row.shown { + let mut on = shown; + if ui.checkbox(&mut on, "").changed() { + let key = if on { "show" } else { "hide" }; + change(app, f, json!({ key: row.item.tab })); + } + } + let resp = ui.add(egui::Button::selectable(f.selected == row.item, row.text.as_str())); + if resp.clicked() { + f.selected = row.item.clone(); + f.renaming = None; + } + }); + } +} + +fn qat_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) { + let ids: Vec = app.session.ribbon.qat().into_iter().map(str::to_string).collect(); + if !ids.contains(&f.qat_selected) { + f.qat_selected = ids.first().cloned().unwrap_or_default(); + } + ui.vertical(|ui| { + ui.add_space(h / 2.0 - 10.0); + let can_add = !f.command.is_empty() && !ids.contains(&f.command); + if ui.add_enabled(can_add, egui::Button::new(tl!("Add >>")).min_size(vec2(96.0, 0.0))).clicked() + && change(app, f, json!({"qatAdd": f.command})).is_some() + { + f.qat_selected = f.command.clone(); + } + if ui.add_enabled(!f.qat_selected.is_empty(), egui::Button::new(tl!("<< Remove")).min_size(vec2(96.0, 0.0))).clicked() { + change(app, f, json!({"qatRemove": f.qat_selected})); + } + }); + ui.add_space(4.0); + ui.vertical(|ui| { + ui.label(egui::RichText::new(tl!("Quick Access Toolbar")).font(semibold(12.5))); + ui.horizontal_top(|ui| { + let rows: Vec<(String, String)> = ids.iter().map(|id| (id.clone(), label_of(app, id))).collect(); + if let Some(id) = list(ui, "rb_qat", vec2(250.0, h), &rows, &f.qat_selected) { + f.qat_selected = id; + } + ui.vertical(|ui| { + ui.add_space(h / 2.0 - 10.0); + for (text, by) in [("Up", -1), ("Down", 1)] { + if ui.add_enabled(!f.qat_selected.is_empty(), egui::Button::new(tl!(text)).min_size(vec2(56.0, 0.0))).clicked() { + change(app, f, json!({"qatMove": {"command": f.qat_selected, "by": by}})); + } + } + }); + }); + }); +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use wordcraft_engine::Session; + + use crate::dialogs::Dialog; + use crate::{Services, WordApp}; + + fn app() -> WordApp { + WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()) + } + + /// #379: Tools › Customize Ribbon opens the dialog; a custom tab with a group, a hidden tab and + /// the Quick Access Toolbar survive a restart, and a damaged saved layout doesn't lose the + /// other preferences. + #[test] + fn customize_ribbon_opens_and_the_layout_persists() { + let mut a = app(); + a.run("tools.customizeRibbon", json!({})).unwrap(); + assert!(matches!(a.dialog, Some(Dialog::CustomizeRibbon { .. }))); + a.run("tools.customizeRibbon", json!({"newTab": {"name": "Mine"}, "hide": "Draw", "qatAdd": "format.bold"})).unwrap(); + a.run("tools.customizeRibbon", json!({"add": {"tab": "Mine", "group": "New Group", "command": "format.italic"}})).unwrap(); + let saved = serde_json::to_string(&a.prefs()).unwrap(); + let mut b = app(); + b.apply_prefs(serde_json::from_str(&saved).unwrap()); + assert_eq!(b.session.ribbon, a.session.ribbon); + let tabs = b.session.ribbon.tabs(); + let mine = tabs.iter().find(|t| t.name == "Mine").unwrap(); + assert_eq!(mine.groups[0].commands, ["format.italic"]); + assert!(tabs.iter().any(|t| t.name == "Draw" && t.hidden)); + assert_eq!(b.session.ribbon.qat(), ["file.save", "edit.undo", "edit.redo", "format.bold"]); + + let mut c = app(); + c.apply_prefs(serde_json::from_str(r#"{"tab": "Insert", "ribbon": {"tabs": 3, "qat": ["gone.command", "edit.undo"]}}"#).unwrap()); + assert_eq!(c.ui.tab, "Insert"); + assert!(c.session.ribbon.ribbon_is_default()); + assert_eq!(c.session.ribbon.qat(), ["edit.undo"]); + } + + /// A context with the interface fonts and theme (they take effect from the next frame). + fn ctx() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, crate::theme::Appearance::Light); + ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas(); + ctx + } + + /// One frame at 60 fps with these input events. + fn frame(ctx: &egui::Context, n: &mut u32, events: Vec, draw: impl FnMut(&mut egui::Ui)) { + *n += 1; + let input = egui::RawInput { time: Some(f64::from(*n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; + ctx.run_ui(input, draw).drop_without_applying_deltas(); + } + + /// A click at `pos` over two frames. + fn click(ctx: &egui::Context, n: &mut u32, pos: egui::Pos2, mut draw: impl FnMut(&mut egui::Ui)) { + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(ctx, n, vec![egui::Event::PointerMoved(pos), button(true)], &mut draw); + frame(ctx, n, vec![button(false)], &mut draw); + } + + /// #379: a custom tab shows its group's commands as working buttons, and a hidden tab isn't + /// shown (the ribbon falls back to a visible one). + #[test] + fn custom_tabs_draw_their_commands_and_hidden_tabs_dont_show() { + let ctx = ctx(); + let mut n = 0; + let mut a = app(); + a.run("tools.customizeRibbon", json!({"newTab": {"name": "Mine"}, "hide": "Home"})).unwrap(); + a.run("tools.customizeRibbon", json!({"add": {"tab": "Mine", "group": "New Group", "command": "view.ruler"}})).unwrap(); + a.ui.tab = "Mine".into(); + let ruler = a.session.view.ruler; + frame(&ctx, &mut n, vec![], |ui| crate::ribbon::show(&mut a, ui)); + // The group's one command is a large button at the ribbon's left edge, under the tab strip. + click(&ctx, &mut n, egui::pos2(24.0, 60.0), |ui| crate::ribbon::show(&mut a, ui)); + assert_eq!(a.ui.tab, "Mine"); + assert_ne!(a.session.view.ruler, ruler, "the custom group's button runs its command"); + + a.ui.tab = "Home".into(); + frame(&ctx, &mut n, vec![], |ui| crate::ribbon::show(&mut a, ui)); + assert_eq!(a.ui.tab, "Insert", "a hidden tab isn't shown"); + } + + /// #376: the Quick Access Toolbar's … button opens its menu, and the toolbar shows the user's + /// commands. + #[test] + fn quick_access_menu_opens_and_commands_come_and_go() { + let ctx = ctx(); + let mut n = 0; + let mut a = app(); + frame(&ctx, &mut n, vec![], |ui| crate::chrome::title_bar(&mut a, ui)); + let more = ctx.read_response(crate::chrome::qat_menu_button_id()).unwrap(); + let popup = egui::Popup::default_response_id(&more); + assert!(!egui::Popup::is_id_open(&ctx, popup)); + click(&ctx, &mut n, more.rect.center(), |ui| crate::chrome::title_bar(&mut a, ui)); + assert!(egui::Popup::is_id_open(&ctx, popup), "the … button opens the Customize Quick Access Toolbar menu"); + + a.run("tools.customizeRibbon", json!({"qatAdd": "file.new"})).unwrap(); + assert!(a.session.ribbon.qat().contains(&"file.new")); + a.run("tools.customizeRibbon", json!({"qatRemove": "file.new"})).unwrap(); + assert_eq!(a.session.ribbon.qat(), ["file.save", "edit.undo", "edit.redo"]); + } +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1333559e8..3fd2abc3f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1372,3 +1372,22 @@ Customize Personalizar Keyboard shortcuts: Métodos abreviados de teclado: Customize… Personalizar… + Customize Ribbon Personalizar cinta de opciones + Add to Quick Access Toolbar Agregar a la barra de herramientas de acceso rápido + Remove from Quick Access Toolbar Quitar de la barra de herramientas de acceso rápido + Customize the Ribbon… Personalizar la cinta de opciones… + Customize Quick Access Toolbar… Personalizar barra de herramientas de acceso rápido… + More Commands… Más comandos… + Ribbon Cinta de opciones + (Custom) (Personalizado) + Remove every custom tab and group and show all tabs again? ¿Quitar todas las pestañas y grupos personalizados y volver a mostrar todas las pestañas? + Put the default commands back on the Quick Access Toolbar? ¿Volver a poner los comandos predeterminados en la barra de herramientas de acceso rápido? + Reset… Restablecer… + Add >> Agregar >> + << Remove << Quitar + Pick a custom group (New Group makes one) Elija un grupo personalizado (Nuevo grupo crea uno) + Ribbon tabs Pestañas de la cinta + New Tab Nueva pestaña + New Group Nuevo grupo + Rename… Cambiar nombre… + Ribbon and Quick Access Toolbar: Cinta de opciones y barra de acceso rápido: diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index afddb5899..744f19eb9 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1366,3 +1366,22 @@ Remove Eemalda Remove every custom shortcut and restore the built-in keys? Kas eemaldada kõik kohandatud kiirklahvid ja taastada sisseehitatud klahvid? Reset All… Lähtesta kõik… + (Custom) (Kohandatud) + << Remove << Eemalda + Add >> Lisa >> + Add to Quick Access Toolbar Lisa kiirpääsuribale + Customize Quick Access Toolbar… Kohanda kiirpääsuriba… + Customize Ribbon Kohanda linti + Customize the Ribbon… Kohanda linti… + More Commands… Rohkem käske… + New Group Uus rühm + New Tab Uus vahekaart + Pick a custom group (New Group makes one) Vali kohandatud rühm (nupp Uus rühm loob selle) + Put the default commands back on the Quick Access Toolbar? Kas panna vaikekäsud kiirpääsuribale tagasi? + Remove every custom tab and group and show all tabs again? Kas eemaldada kõik kohandatud vahekaardid ja rühmad ning kuvada uuesti kõik vahekaardid? + Remove from Quick Access Toolbar Eemalda kiirpääsuribalt + Rename… Nimeta ümber… + Reset… Lähtesta… + Ribbon Lint + Ribbon and Quick Access Toolbar: Lint ja kiirpääsuriba: + Ribbon tabs Lindi vahekaardid diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ae38b696b..ca49565c0 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1356,3 +1356,22 @@ Customize ユーザー設定 Keyboard shortcuts: キーボード ショートカット: Customize… ユーザー設定… + Customize Ribbon リボンのユーザー設定 + Add to Quick Access Toolbar クイック アクセス ツール バーに追加 + Remove from Quick Access Toolbar クイック アクセス ツール バーから削除 + Customize the Ribbon… リボンのユーザー設定… + Customize Quick Access Toolbar… クイック アクセス ツール バーのユーザー設定… + More Commands… その他のコマンド… + Ribbon リボン + (Custom) (ユーザー設定) + Remove every custom tab and group and show all tabs again? すべてのユーザー設定タブとグループを削除し、すべてのタブを再表示しますか? + Put the default commands back on the Quick Access Toolbar? クイック アクセス ツール バーを既定のコマンドに戻しますか? + Reset… リセット… + Add >> 追加 >> + << Remove << 削除 + Pick a custom group (New Group makes one) ユーザー設定グループを選んでください (新しいグループで作成できます) + Ribbon tabs リボンのタブ + New Tab 新しいタブ + New Group 新しいグループ + Rename… 名前の変更… + Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3bee39d45..abcffac3c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1368,3 +1368,22 @@ Customize Personalizar Keyboard shortcuts: Atalhos de teclado: Customize… Personalizar… + Customize Ribbon Personalizar Faixa de Opções + Add to Quick Access Toolbar Adicionar à Barra de Ferramentas de Acesso Rápido + Remove from Quick Access Toolbar Remover da Barra de Ferramentas de Acesso Rápido + Customize the Ribbon… Personalizar a Faixa de Opções… + Customize Quick Access Toolbar… Personalizar Barra de Ferramentas de Acesso Rápido… + More Commands… Mais Comandos… + Ribbon Faixa de Opções + (Custom) (Personalizado) + Remove every custom tab and group and show all tabs again? Remover todas as guias e grupos personalizados e mostrar todas as guias de novo? + Put the default commands back on the Quick Access Toolbar? Colocar de volta os comandos padrão na Barra de Ferramentas de Acesso Rápido? + Reset… Redefinir… + Add >> Adicionar >> + << Remove << Remover + Pick a custom group (New Group makes one) Escolha um grupo personalizado (Novo Grupo cria um) + Ribbon tabs Guias da faixa + New Tab Nova Guia + New Group Novo Grupo + Rename… Renomear… + Ribbon and Quick Access Toolbar: Faixa de Opções e Barra de Acesso Rápido: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e6bf6debc..e4f33d5d8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1748,3 +1748,22 @@ preview Title Пример Customize Настроить Keyboard shortcuts: Сочетания клавиш: Customize… Настроить… + Customize Ribbon Настройка ленты + Add to Quick Access Toolbar Добавить на панель быстрого доступа + Remove from Quick Access Toolbar Удалить с панели быстрого доступа + Customize the Ribbon… Настроить ленту… + Customize Quick Access Toolbar… Настроить панель быстрого доступа… + More Commands… Другие команды… + Ribbon Лента + (Custom) (Настраиваемая) + Remove every custom tab and group and show all tabs again? Удалить все настраиваемые вкладки и группы и снова показать все вкладки? + Put the default commands back on the Quick Access Toolbar? Вернуть команды по умолчанию на панель быстрого доступа? + Reset… Сбросить… + Add >> Добавить >> + << Remove << Удалить + Pick a custom group (New Group makes one) Выберите настраиваемую группу (её создаёт «Создать группу») + Ribbon tabs Вкладки ленты + New Tab Создать вкладку + New Group Создать группу + Rename… Переименовать… + Ribbon and Quick Access Toolbar: Лента и панель быстрого доступа: diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index f80853a08..6bab87d69 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1366,3 +1366,22 @@ Customize Prilagođavanje Keyboard shortcuts: Prečice na tastaturi: Customize… Prilagodi… + Customize Ribbon Prilagodi traku + Add to Quick Access Toolbar Dodaj na traku sa alatkama za brzi pristup + Remove from Quick Access Toolbar Ukloni sa trake sa alatkama za brzi pristup + Customize the Ribbon… Prilagodi traku… + Customize Quick Access Toolbar… Prilagodi traku sa alatkama za brzi pristup… + More Commands… Još komandi… + Ribbon Traka + (Custom) (Prilagođeno) + Remove every custom tab and group and show all tabs again? Ukloniti sve prilagođene kartice i grupe i ponovo prikazati sve kartice? + Put the default commands back on the Quick Access Toolbar? Vratiti podrazumevane komande na traku sa alatkama za brzi pristup? + Reset… Resetuj… + Add >> Dodaj >> + << Remove << Ukloni + Pick a custom group (New Group makes one) Izaberite prilagođenu grupu (Nova grupa pravi jednu) + Ribbon tabs Kartice trake + New Tab Nova kartica + New Group Nova grupa + Rename… Preimenuj… + Ribbon and Quick Access Toolbar: Traka i traka sa alatkama za brzi pristup: diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index fa5bc387a..05c641c30 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1370,3 +1370,22 @@ Customize Прилагођавање Keyboard shortcuts: Пречице на тастатури: Customize… Прилагоди… + Customize Ribbon Прилагоди траку + Add to Quick Access Toolbar Додај на траку са алаткама за брзи приступ + Remove from Quick Access Toolbar Уклони са траке са алаткама за брзи приступ + Customize the Ribbon… Прилагоди траку… + Customize Quick Access Toolbar… Прилагоди траку са алаткама за брзи приступ… + More Commands… Још команди… + Ribbon Трака + (Custom) (Прилагођено) + Remove every custom tab and group and show all tabs again? Уклонити све прилагођене картице и групе и поново приказати све картице? + Put the default commands back on the Quick Access Toolbar? Вратити подразумеване команде на траку са алаткама за брзи приступ? + Reset… Ресетуј… + Add >> Додај >> + << Remove << Уклони + Pick a custom group (New Group makes one) Изаберите прилагођену групу (Нова група прави једну) + Ribbon tabs Картице траке + New Tab Нова картица + New Group Нова група + Rename… Преименуј… + Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ: diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 49e935823..98f6fc178 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1365,3 +1365,22 @@ Customize Özelleştir Keyboard shortcuts: Klavye kısayolları: Customize… Özelleştir… + Customize Ribbon Şeridi Özelleştir + Add to Quick Access Toolbar Hızlı Erişim Araç Çubuğuna Ekle + Remove from Quick Access Toolbar Hızlı Erişim Araç Çubuğundan Kaldır + Customize the Ribbon… Şeridi Özelleştir… + Customize Quick Access Toolbar… Hızlı Erişim Araç Çubuğunu Özelleştir… + More Commands… Diğer Komutlar… + Ribbon Şerit + (Custom) (Özel) + Remove every custom tab and group and show all tabs again? Tüm özel sekmeler ve gruplar kaldırılıp tüm sekmeler yeniden gösterilsin mi? + Put the default commands back on the Quick Access Toolbar? Varsayılan komutlar Hızlı Erişim Araç Çubuğuna geri konsun mu? + Reset… Sıfırla… + Add >> Ekle >> + << Remove << Kaldır + Pick a custom group (New Group makes one) Bir özel grup seçin (Yeni Grup bir tane oluşturur) + Ribbon tabs Şerit sekmeleri + New Tab Yeni Sekme + New Group Yeni Grup + Rename… Yeniden Adlandır… + Ribbon and Quick Access Toolbar: Şerit ve Hızlı Erişim Araç Çubuğu: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 193620813..da5a07383 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1368,3 +1368,22 @@ Customize Настроювання Keyboard shortcuts: Сполучення клавіш: Customize… Настроїти… + Customize Ribbon Налаштувати стрічку + Add to Quick Access Toolbar Додати на панель швидкого доступу + Remove from Quick Access Toolbar Видалити з панелі швидкого доступу + Customize the Ribbon… Налаштувати стрічку… + Customize Quick Access Toolbar… Налаштувати панель швидкого доступу… + More Commands… Інші команди… + Ribbon Стрічка + (Custom) (Користувацька) + Remove every custom tab and group and show all tabs again? Видалити всі користувацькі вкладки й групи та знову показати всі вкладки? + Put the default commands back on the Quick Access Toolbar? Повернути стандартні команди на панель швидкого доступу? + Reset… Скинути… + Add >> Додати >> + << Remove << Видалити + Pick a custom group (New Group makes one) Виберіть користувацьку групу (її створює «Нова група») + Ribbon tabs Вкладки стрічки + New Tab Нова вкладка + New Group Нова група + Rename… Перейменувати… + Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d44a127e4..060324d53 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1368,3 +1368,22 @@ Customize 自定义 Keyboard shortcuts: 键盘快捷方式: Customize… 自定义… + Customize Ribbon 自定义功能区 + Add to Quick Access Toolbar 添加到快速访问工具栏 + Remove from Quick Access Toolbar 从快速访问工具栏删除 + Customize the Ribbon… 自定义功能区… + Customize Quick Access Toolbar… 自定义快速访问工具栏… + More Commands… 其他命令… + Ribbon 功能区 + (Custom) (自定义) + Remove every custom tab and group and show all tabs again? 删除所有自定义选项卡和组,并重新显示所有选项卡? + Put the default commands back on the Quick Access Toolbar? 恢复快速访问工具栏的默认命令? + Reset… 重置… + Add >> 添加 >> + << Remove << 删除 + Pick a custom group (New Group makes one) 请选择一个自定义组(可用“新建组”创建) + Ribbon tabs 功能区选项卡 + New Tab 新建选项卡 + New Group 新建组 + Rename… 重命名… + Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 82f5cdfbf..3e66d24bf 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1357,3 +1357,22 @@ Customize 自訂 Keyboard shortcuts: 鍵盤快速鍵: Customize… 自訂… + Customize Ribbon 自訂功能區 + Add to Quick Access Toolbar 新增至快速存取工具列 + Remove from Quick Access Toolbar 從快速存取工具列移除 + Customize the Ribbon… 自訂功能區… + Customize Quick Access Toolbar… 自訂快速存取工具列… + More Commands… 其他命令… + Ribbon 功能區 + (Custom) (自訂) + Remove every custom tab and group and show all tabs again? 移除所有自訂索引標籤和群組,並重新顯示所有索引標籤? + Put the default commands back on the Quick Access Toolbar? 將快速存取工具列還原為預設命令? + Reset… 重設… + Add >> 新增 >> + << Remove << 移除 + Pick a custom group (New Group makes one) 請選擇自訂群組(可用「新增群組」建立) + Ribbon tabs 功能區索引標籤 + New Tab 新增索引標籤 + New Group 新增群組 + Rename… 重新命名… + Ribbon and Quick Access Toolbar: 功能區和快速存取工具列: diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 589667e79..bbc5d5661 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1128,6 +1128,19 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { pen.fcircle(7.5, 10.5, 1.5, Color32::WHITE); pen.fcircle(12.5, 10.5, 1.5, Color32::WHITE); } + // Open: a folder with its flap lifted. + "open" => { + pen.closed(&[(2.5, 5.0), (7.5, 5.0), (9.0, 6.5), (15.5, 6.5), (15.5, 9.0)], c); + pen.line(&[(2.5, 5.0), (2.5, 16.0)]); + pen.closed(&[(2.5, 16.0), (5.0, 9.0), (18.0, 9.0), (15.5, 16.0)], a); + } + // Print: a printer with a sheet coming out. + "print" => { + pen.rect(6.0, 2.5, 14.0, 7.0, c); + pen.rect(2.5, 7.0, 17.5, 14.0, c); + pen.closed(&[(6.0, 11.5), (14.0, 11.5), (14.0, 17.5), (6.0, 17.5)], a); + pen.fcircle(15.0, 9.5, 0.8, c); + } "help" => { pen.circle(10.0, 10.0, 7.0, c); pen.text(10.0, 10.0, 10.0, "?", a, true); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a1111eb17..0c07ca6db 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -23,6 +23,7 @@ pub mod dialogs_insert; pub mod dialogs_keyboard; pub mod dialogs_lists; pub mod dialogs_para; +pub mod dialogs_ribbon; pub mod equation_tab; pub mod file_dialogs; pub mod frame; @@ -130,6 +131,9 @@ pub struct UiState { pub dark_page: bool, /// Custom keyboard shortcuts (Customize Keyboard, #368); the session owns them while running. pub keyboard: wordcraft_engine::KeyMap, + /// Custom ribbon tabs and groups, hidden tabs and the Quick Access Toolbar (Customize Ribbon, + /// #379); the session owns them while running. + pub ribbon: wordcraft_engine::RibbonLayout, } impl Default for UiState { @@ -156,6 +160,7 @@ impl Default for UiState { alt_chord_used: false, dark_page: false, keyboard: wordcraft_engine::KeyMap::default(), + ribbon: wordcraft_engine::RibbonLayout::default(), } } } @@ -298,6 +303,7 @@ impl WordApp { ui.read_aloud_skip_citations = self.session.read_aloud.skip_citations; ui.dark_page = self.session.view.dark_mode; ui.keyboard = self.session.keymap.clone(); + ui.ribbon = self.session.ribbon.clone(); ui } @@ -322,6 +328,9 @@ impl WordApp { self.session.keymap = std::mem::take(&mut self.ui.keyboard); let reg = self.session.registry.clone(); self.session.keymap.retain_known(®); + // So are ribbon and toolbar commands. + self.session.ribbon = std::mem::take(&mut self.ui.ribbon); + self.session.ribbon.retain_known(®); } /// Run a command the user asked for (ribbon, shortcut, Backstage, file drop). New, Open, diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 4812ed9aa..f72d3d8b6 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -7,7 +7,7 @@ use crate::theme::{Tokens, medium, regular, semibold}; use crate::widgets::{CONTENT_H, LABEL_H, big, big_toggle, color_grid, combo, font_combo, group, menu_button, small, split}; use crate::{WordApp, icons}; -pub const TABS: [&str; 12] = ["File", "Home", "Insert", "Draw", "Design", "Layout", "References", "Mailings", "Review", "View", "Zotero", "Help"]; +pub const TABS: [&str; 12] = wordcraft_engine::ribbon::TABS; /// True when the caret/selection touches a picture (#147). pub fn has_picture_selected(s: &wordcraft_engine::Session) -> bool { @@ -62,11 +62,21 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { { app.ui.tab = prev; } - // A contextual tab (Table, Picture Format) may be stored while the selection moved away. + // The tabs shown: File, the user's tabs in their order (Customize Ribbon, #379), then the + // contextual ones. A contextual tab (Table, Picture Format) may be stored while the selection + // moved away, and a tab may have been hidden. + let user_tabs = app.session.ribbon.tabs().into_owned(); + let mut tabs: Vec<(String, bool)> = vec![("File".to_string(), false)]; + tabs.extend(user_tabs.iter().filter(|e| !e.hidden).map(|e| (e.name.clone(), e.custom))); + for ct in contextual_tabs(&app.session) { + if !tabs.iter().any(|(n, _)| n == ct) { + tabs.push((ct.to_string(), false)); + } + } { - let mut tabs: Vec<&str> = TABS.to_vec(); - tabs.extend(contextual_tabs(&app.session)); - let next = resolve_tab(&app.ui.tab, &tabs); + let names: Vec<&str> = tabs.iter().map(|(n, _)| n.as_str()).collect(); + let next = resolve_tab(&app.ui.tab, &names); + let next = if names.contains(&next) { next } else { names.iter().copied().find(|n| *n != "File").unwrap_or(next) }; if next != app.ui.tab { app.ui.tab = next.to_string(); } @@ -78,15 +88,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { .show(ui, |ui| { ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing = vec2(2.0, 0.0); - let mut tabs: Vec<&str> = TABS.to_vec(); - for ct in contextual_tabs(&app.session) { - if !tabs.contains(&ct) { - tabs.push(ct); - } - } - for tab in tabs { - let contextual = tab.starts_with("Table ") || tab.ends_with(" Format") || tab == "Equation"; - let shown = tl!(tab); + for (tab, custom) in &tabs { + let (tab, custom) = (tab.as_str(), *custom); + let contextual = !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab); + // A custom tab's name is the user's own text: shown as typed. + let shown = if custom { tab } else { 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 { @@ -108,6 +114,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if resp.double_clicked() && tab != "File" { let _ = app.run("ui.collapseRibbon", json!({})); } + resp.context_menu(|ui| crate::widgets::customize_menu_items(ui, app)); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Share, Editing mode, Comments. @@ -160,9 +167,20 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin { left: 8, right: 8, top: 4, bottom: 4 }).stroke(Stroke::new(1.0, t.border)), ) .show(ui, |ui| { + // Right-click on the ribbon (outside a button): Customize Ribbon. Registered first, so + // the buttons drawn over it keep their own clicks. + let bg = ui.interact(ui.max_rect(), ui.id().with("ribbon_background"), Sense::click()); + bg.context_menu(|ui| crate::widgets::customize_menu_items(ui, app)); + let entry = user_tabs.iter().find(|e| e.name == app.ui.tab); let scroll = egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).show(ui, |ui| { ui.horizontal_top(|ui| { ui.spacing_mut().item_spacing = vec2(2.0, 2.0); + match entry { + // Every tab hidden: nothing to show. + Some(e) if e.hidden => return, + Some(e) if e.custom => return custom_groups(app, ui, &e.groups), + _ => {} + } match app.ui.tab.as_str() { "Home" => home(app, ui), "Insert" => insert(app, ui), @@ -182,6 +200,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { "Shape Format" => shape_format(app, ui), _ => home(app, ui), } + if let Some(e) = entry { + custom_groups(app, ui, &e.groups); + } }); }); // The bar is hidden, so a row wider than the window would be clipped with no sign. Edge chevrons show @@ -211,6 +232,44 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }); } +/// Custom groups (Customize Ribbon, #379): up to three commands as large buttons, more as small +/// labelled buttons in columns of three. Each shows the command's own icon and label. +fn custom_groups(app: &mut WordApp, ui: &mut Ui, groups: &[wordcraft_engine::ribbon::CustomGroup]) { + for g in groups { + let ids: Vec<(&str, String)> = + g.commands.iter().filter_map(|id| app.session.registry.get(id).map(|s| (s.id, tl!(s.label).to_string()))).collect(); + crate::widgets::group_titled(ui, &g.name, &g.name, None, app, |ui, app| { + if ids.len() <= 3 { + for (id, label) in &ids { + big(ui, app, crate::widgets::command_icon(id), &two_lines(label), id, json!({}), false); + } + return; + } + for column in ids.chunks(3) { + stack(ui, |ui| { + for (id, label) in column { + small(ui, app, crate::widgets::command_icon(id), Some(label), label, id, json!({}), false); + } + }); + } + }); + } +} + +/// A large button's label on two lines when it is long (broken at the space nearest the middle). +fn two_lines(label: &str) -> String { + let n = label.chars().count(); + if n <= 10 { + return label.to_string(); + } + let mid = n / 2; + let best = label.char_indices().filter(|(_, c)| *c == ' ').min_by_key(|(i, _)| label[..*i].chars().count().abs_diff(mid)).map(|(i, _)| i); + match best { + Some(i) => format!("{}\n{}", &label[..i], label[i..].trim_start()), + None => label.to_string(), + } +} + /// A chevron over a ribbon edge that is also a button; `true` on the frame it is clicked. fn edge_cue(ui: &mut Ui, rect: Rect, glyph: &str, hint: &str, t: &Tokens) -> bool { let resp = ui.interact(rect, ui.id().with(glyph), Sense::click()).on_hover_text(hint); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ed7f3f490..d1c1ed7b6 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -136,6 +136,7 @@ fn big_button(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, y += 13.0; } let resp = tooltip(app, resp, &label.replace('\n', " "), id); + command_menu(&resp, app, id); if resp.clicked() && on && !menu { let _ = app.run(id, params); } @@ -158,6 +159,7 @@ pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, ti ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, l, regular(11.5), if on { t.text } else { t.text_disabled }); } let resp = tooltip(app, resp, tip, id); + command_menu(&resp, app, id); if resp.clicked() && on { let _ = app.run(id, params); } @@ -190,6 +192,7 @@ pub fn split( } icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(10.0, 10.0)), "dropdown", t.icon, t.accent); let resp = tooltip(app, resp, tip, id); + command_menu(&resp, app, id); if resp.clicked() { let _ = app.run(id, params); } @@ -252,8 +255,11 @@ pub fn menu_button( /// A ribbon group: content, a centred label below and a divider on the right. pub fn group(ui: &mut Ui, title: &str, launcher: Option<&str>, app: &mut WordApp, add: impl FnOnce(&mut Ui, &mut WordApp)) { // The English title keys the launcher's id; the drawn title is translated. - let id_title = title; - let title = tl!(title); + group_titled(ui, title, tl!(title), launcher, app, add); +} + +/// A ribbon group whose title is shown as is (a custom group's name, Customize Ribbon). +pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&str>, app: &mut WordApp, add: impl FnOnce(&mut Ui, &mut WordApp)) { let t = Tokens::get(ui.ctx()); let label_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.to_string(), regular(11.0), t.group_label).size().x); let start = ui.cursor().min; @@ -284,6 +290,69 @@ pub fn group(ui: &mut Ui, title: &str, launcher: Option<&str>, app: &mut WordApp ui.add_space(6.0); } +/// The icon a command's button shows outside its own ribbon group (custom groups, the Quick +/// Access Toolbar): the one its ribbon button uses, which is usually named after the id's last +/// part. Names the icon set doesn't have draw a lettered tile ([`icons::paint`]). +pub fn command_icon(id: &str) -> &str { + const ICONS: &[(&str, &str)] = &[ + ("arrange.wrap", "wrapText"), + ("draw.highlighter", "highlight"), + ("format.border", "charborder"), + ("format.color", "fontcolor"), + ("format.growFont", "grow"), + ("format.shrinkFont", "shrink"), + ("format.strikethrough", "strike"), + ("format.highlight", "highlight"), + ("insert.signatureLine", "signature"), + ("mailings.envelopes", "envelope"), + ("mailings.insertField", "mergeField"), + ("para.ltr", "textLtr"), + ("para.rtl", "textRtl"), + ("para.bullets", "bullets"), + ("para.numbering", "numbering"), + ("table.insertRowAbove", "insertAbove"), + ("view.commentsPane", "showComments"), + ("view.marks", "pilcrow"), + ("edit.repeat", "redo"), + ("file.new", "blankPage"), + ("tools.customizeRibbon", "more"), + ]; + ICONS.iter().find(|(c, _)| *c == id).map_or_else(|| id.rsplit('.').next().unwrap_or(id), |(_, icon)| *icon) +} + +/// Right-click on a command's button: put it on (or take it off) the Quick Access Toolbar, or +/// open Customize Ribbon (#379). +pub fn command_menu(resp: &Response, app: &mut WordApp, id: &str) { + resp.context_menu(|ui| { + let known = app.session.registry.get(id).is_some(); + let on_qat = app.session.ribbon.qat().contains(&id); + if known && !on_qat && ui.button(tl!("Add to Quick Access Toolbar")).clicked() { + let _ = app.run("tools.customizeRibbon", serde_json::json!({"qatAdd": id})); + ui.close(); + } + if on_qat && ui.button(tl!("Remove from Quick Access Toolbar")).clicked() { + let _ = app.run("tools.customizeRibbon", serde_json::json!({"qatRemove": id})); + ui.close(); + } + if known { + ui.separator(); + } + customize_menu_items(ui, app); + }); +} + +/// "Customize the Ribbon…" and "Customize Quick Access Toolbar…" (context menus). +pub fn customize_menu_items(ui: &mut Ui, app: &mut WordApp) { + if ui.button(tl!("Customize the Ribbon…")).clicked() { + crate::dialogs_ribbon::open(app, crate::dialogs_ribbon::Page::Ribbon); + ui.close(); + } + if ui.button(tl!("Customize Quick Access Toolbar…")).clicked() { + crate::dialogs_ribbon::open(app, crate::dialogs_ribbon::Page::QuickAccess); + ui.close(); + } +} + /// A row of small buttons (used inside a vertical stack). pub fn row(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { ui.horizontal(|ui| { diff --git a/docs/gaps.md b/docs/gaps.md index 5cd413383..21552310b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (#24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The work list. One entry per shortfall, ranked by how much it stops a Word user from switching (**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent @@ -34,7 +34,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 21 | **Mail merge data sources**: no Excel, Outlook/Contacts, email merge; CSV and typed lists only (#247) | | | Office administrators | 6–10 | [target-app-parity.md](target-app-parity.md) | | 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) | | 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) | -| 24 | **Customize Ribbon** and Quick Access Toolbar customization; ~135 of Word's ~250 default shortcuts (#330, #339, #343; Customize Keyboard landed, #368) | | [ui-parity.md](ui-parity.md) | Power users | 7–12 | [ui-parity.md](ui-parity.md) | +| 24 | **Default shortcuts**: ~135 of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and the Quick Access Toolbar #379 landed) | | [ui-parity.md](ui-parity.md) | Power users | 4–7 | [ui-parity.md](ui-parity.md) | | 25 | **In-app AI assistant** (Copilot-style draft, rewrite, summarize) | | Chat PR #178 open | Users expecting Copilot | 15–30 + owner (provider) | [target-app-parity.md](target-app-parity.md) | | 26 | **Minor formats**: Flat OPC XML, Word 2003 XML, MHT, Works/WordPerfect | | | Rare | 20–35 | [file-format-parity.md](file-format-parity.md) | | 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All, Switch Windows | | [parity-checklist.md](parity-checklist.md) (View 25/29) | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) | @@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | #24 narrowed: Customize Ribbon and Quick Access Toolbar customization landed (#379); the default shortcuts remain | | 2026-10-11 | trivial | #24 narrowed: Customize Keyboard landed (#368); Customize Ribbon and the Quick Access Toolbar remain | | 2026-10-11 | trivial | #24: Word's field keys (Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11) (#339) | | 2026-10-10 | trivial | #24: 125 of ~250 keyboard shortcuts after #330 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 9efd367bf..9fd8ee4ee 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**417 of 448 catalog features have commands (93.1%).** +**418 of 448 catalog features have commands (93.3%).** | Tab | Live | Total | |---|---|---| @@ -24,7 +24,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Shape Format | 3 | 6 | | Header & Footer | 5 | 5 | | Editing | 38 | 38 | -| Tools | 4 | 6 | +| Tools | 5 | 6 | ## Missing @@ -80,6 +80,5 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- ### Tools -- Customize › Customize Ribbon (`tools.customizeRibbon`) - Templates › Templates and Add-ins (`tools.templates`) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ee8c1a646..a2c44f362 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (shortcuts: Move Paragraph Up/Down, Promote/Demote, Go Back, Alt+X, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -13,8 +13,8 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation; ribbon overflow (#43, #129). Draw tab: Select, Eraser, Pen, Pencil, Highlighter with colour and thickness (#307); missing: Lasso, Add Pen, Ink to Shape/Math, Ink Replay | Same plus Draw | 85% | (Draw in features) | | Keytips (Alt / ⌃⌥ letters) | ✅ (#43) | ✅ | 85% | 1–2 | | Mini-toolbar on selection | ✅ (#43) | ✅ | 80% | 1–2 | -| Quick Access Toolbar | Undo, Redo, Repeat, AutoSave | customizable | 50% | 2–3 | -| Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. No Customize Ribbon | ✅ | 50% | 3–5 | +| Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Not shown below the ribbon | customizable | 90% | 0.5 | +| Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 | | Keyboard shortcuts | **125** commands bound in `CommandSpec::key`, 144 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11 (#339); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | | Modal dialogs | **36** (`Dialog` enum: Font, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors) plus panes and menus for the rest | ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | ~40% | 20–30 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Accessibility, Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | @@ -37,6 +37,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Customize Ribbon (custom tabs and groups, hidden and reordered tabs) and a customizable Quick Access Toolbar with a working … menu (#379, #376) | | 2026-10-11 | trivial | Customize Keyboard dialog (Tools › Customize Keyboard, File › Options › Keyboard shortcuts): custom keys override built-ins and persist (#368) | | 2026-10-11 | minor | Keyboard shortcuts 101 → 125 commands (#330): Reset Paragraph/Character Formatting, symbols, DATE/TIME/PAGE fields, view switches, Word Count, mail merge, F4 Repeat now reachable | | 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |