diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index e7c64463de..4e02180017 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -358,6 +358,9 @@ pub struct Entry { /// Menu path ("Object › Arrange") or "Tools". pub group: String, pub is_tool: bool, + /// Keyboard Shortcuts adds the key to the entry's name: another entry has the same group and + /// label ([`distinct_names`]). + pub shows_key: bool, } /// The editor's entries, the Effect menu's effect items and Object › Plug-ins' filter items while @@ -419,20 +422,34 @@ fn build_snapshot(revision: u64) -> Arc { let effects: Arc<[EffectItem]> = build_effect_items().into(); let filters: Arc<[FilterItem]> = build_filter_items().into(); let (head, tail) = fixed_entries(); - let entries: Arc<[Entry]> = head + let mut entries: Vec = head .iter() .cloned() - .chain(effects.iter().map(|e| Entry { key: format!("effect:{}", e.effect), label: e.label.clone(), group: e.group.clone(), is_tool: false })) - .chain(filters.iter().map(|f| Entry { key: format!("plugin:{}", f.id), label: f.label.clone(), group: f.group.clone(), is_tool: false })) + .chain(effects.iter().map(|e| Entry { + key: format!("effect:{}", e.effect), + label: e.label.clone(), + group: e.group.clone(), + is_tool: false, + shows_key: false, + })) + .chain(filters.iter().map(|f| Entry { + key: format!("plugin:{}", f.id), + label: f.label.clone(), + group: f.group.clone(), + is_tool: false, + shows_key: false, + })) .chain(vectorcraft_engine::cmd::plugin::command_specs().into_iter().map(|c| Entry { key: c.id.into(), label: c.label.into(), group: c.menu.join(" › "), is_tool: false, + shows_key: false, })) .chain(tail.iter().cloned()) .collect(); - Arc::new(Snapshot { revision, effects, filters, entries }) + distinct_names(&mut entries); + Arc::new(Snapshot { revision, effects, filters, entries: entries.into() }) } /// Every tool, command and panel the editor can give a shortcut; [`listed`] says which it lists. @@ -446,7 +463,7 @@ fn fixed_entries() -> &'static (Vec, Vec) { static E: OnceLock<(Vec, Vec)> = OnceLock::new(); E.get_or_init(|| { let mut v: Vec = vectorcraft_tools::catalog::all_tools() - .map(|t| Entry { key: format!("tool:{}", t.id), label: t.label.to_string(), group: "Tools".into(), is_tool: true }) + .map(|t| Entry { key: format!("tool:{}", t.id), label: t.label.to_string(), group: "Tools".into(), is_tool: true, shows_key: false }) .collect(); let mut seen = HashSet::new(); // The commands listed only because an item of the menu bar runs them as a key press would @@ -461,13 +478,14 @@ fn fixed_entries() -> &'static (Vec, Vec) { by_the_menu_bar.insert(c.id); } let group = if c.menu.is_empty() { id_group(c.id) } else { c.menu.join(" › ") }; - v.push(Entry { key: c.id.into(), label: c.label.into(), group, is_tool: false }); + v.push(Entry { key: c.id.into(), label: c.label.into(), group, is_tool: false, shows_key: false }); } v.extend(crate::state::all_panels().map(|(id, label)| Entry { key: format!("panel:{id}"), label: label.to_string(), group: "Window".into(), is_tool: false, + shows_key: false, })); // Each effect of the Effect menu comes next, by its own key (#3296): see [`snapshot`]. let mut tail = vec![]; @@ -479,14 +497,16 @@ fn fixed_entries() -> &'static (Vec, Vec) { if !own { by_the_menu_bar.insert(id); } - tail.push(Entry { key: id.to_string(), label: label.to_string(), group: id_group(id), is_tool: false }); + tail.push(Entry { key: id.to_string(), label: label.to_string(), group: id_group(id), is_tool: false, shows_key: false }); } // A command listed only because an item of the menu bar runs it can have the group and // label of another entry. When that entry is an engine command that no item of the menu bar // runs, the command the item runs keeps the name and the other entry is shown in the group // of its id: File › Export Selection… runs `file.exportSelection`, as the menu item does, // and `document.exportSelection` shows as Document › Export Selection…. Otherwise the - // command is left out: Window › Learn stays the Learn panel's entry. + // command is left out: Window › Learn stays the Learn panel's entry. Entries that still + // have the same name, plug-ins included, are given names of their own by + // [`distinct_names`]. let name = |e: &Entry| (e.group.clone(), e.label.clone()); let new_names: HashSet<_> = v.iter().chain(&tail).filter(|e| by_the_menu_bar.contains(e.key.as_str())).map(name).collect(); for e in v.iter_mut().chain(tail.iter_mut()) { @@ -621,9 +641,21 @@ pub(crate) fn shown_group(group: &str) -> String { group.split(" › ").map(|s| tl!(s)).collect::>().join(" › ") } -/// How Keyboard Shortcuts names entry `e`: a tool by its label, the others after their menu. +/// How Keyboard Shortcuts names entry `e`: a tool by its label, the others after their menu, and +/// an entry that [`Entry::shows_key`] with its key after the name. pub(crate) fn shown_name(e: &Entry) -> String { - if e.is_tool || e.group.is_empty() { shown_label(e).to_string() } else { format!("{} › {}", shown_group(&e.group), shown_label(e)) } + name_of(e, &shown_group(&e.group), shown_label(e)) +} + +/// [`shown_name`] in English, for a status or error message, which is translated when shown. +fn english_name(e: &Entry) -> String { + name_of(e, &e.group, &e.label) +} + +/// Entry `e`'s name with its group and label as `group` and `label`. +fn name_of(e: &Entry, group: &str, label: &str) -> String { + let name = if e.is_tool || e.group.is_empty() { label.to_string() } else { format!("{group} › {label}") }; + if e.shows_key { format!("{name} ({})", e.key) } else { name } } /// The group of a command no menu holds: the first part of its id, capitalised (`edit.x` → "Edit"). @@ -632,6 +664,107 @@ fn id_group(id: &str) -> String { c.next().map(|f| f.to_uppercase().collect::() + c.as_str()).unwrap_or_default() } +/// Gives entries that have the same group and label names of their own, and leaves the others as +/// they are. It runs on the whole snapshot, plug-ins included, after [`fixed_entries`] has shown an +/// engine command in the group of its id or left out a command listed only because an item of the +/// menu bar runs it: +/// 1. A command that an item of the menu bar runs, as a key press would run it, is shown under +/// that item's menu path ([`menu_bar_path`]): the four Other Library… commands go under Window › +/// Swatch Libraries, Brush Libraries, Graphic Style Libraries and Symbol Libraries. +/// 2. A command whose id is another command's id with one more part is shown under that command's +/// label: Reset Panel goes under Character Style Options and Paragraph Style Options. +/// 3. Entries that still have the same name show their key after it ([`Entry::shows_key`]), except +/// the one entry an item of the menu bar runs: File › New… stays the New Document dialog's +/// entry, and `file.new` shows as File › New… (file.new). An entry whose name is then the name +/// another entry has with its key (a plug-in named "Blur (plugin:a)") shows its key too. +fn distinct_names(entries: &mut [Entry]) { + // The entries of each name that more than one entry has. + fn shared(entries: &[Entry]) -> Vec> { + let mut by_name: HashMap> = HashMap::new(); + for (i, e) in entries.iter().enumerate() { + by_name.entry(english_name(e)).or_default().push(i); + } + by_name.into_values().filter(|list| list.len() > 1).collect() + } + for i in shared(entries).into_iter().flatten() { + let Some(e) = entries.get_mut(i).filter(|e| !e.is_tool) else { continue }; + if let Some(path) = menu_bar_path(&e.key) { + e.group = path.to_string(); + } else if let Some((parent, _)) = e.key.rsplit_once('.').and_then(|(parent, _)| command_doc(parent)) { + e.group = parent.trim_end_matches('…').to_string(); + } + } + // A pass gives every entry of a shared name but one its key, so a name is shared again only by + // an entry whose label ends with another entry's key; one pass per entry is enough. + for _ in 0..entries.len() { + let lists = shared(entries); + if lists.is_empty() { + break; + } + for list in lists { + let plain: Vec = list.iter().copied().filter(|&i| entries.get(i).is_some_and(|e| !e.shows_key)).collect(); + let on_the_menu_bar: Vec = + plain.iter().copied().filter(|&i| entries.get(i).is_some_and(|e| !e.is_tool && menu_bar_path(&e.key).is_some())).collect(); + // The entry an item of the menu bar runs keeps the plain name, unless another entry + // already shows that name with its key. + let keeps = on_the_menu_bar.first().copied().filter(|_| on_the_menu_bar.len() == 1 && plain.len() == list.len()); + for i in plain { + if let Some(e) = entries.get_mut(i) + && keeps != Some(i) + { + e.shows_key = true; + } + } + } + } +} + +/// The (label, params doc) of engine or UI command `id`. +fn command_doc(id: &str) -> Option<(&'static str, &'static str)> { + vectorcraft_engine::find_command(id).map(|c| (c.label, c.params)).or_else(|| menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| (c.1, c.3))) +} + +/// The (menu path, params) of each item of the menu bar that [`menu_bar_items`] reads, by command +/// id: "Window › Swatch Libraries" for Window › Swatch Libraries › Other Library…. +fn menu_bar_paths() -> &'static HashMap<&'static str, Vec<(String, Value)>> { + fn walk(items: &[menus::Item], path: &mut Vec, out: &mut HashMap<&'static str, Vec<(String, Value)>>) { + for it in items { + match it { + menus::Item::Cmd(label, id, p) if on_the_bar(id, label, p) => out.entry(*id).or_default().push((path.join(" › "), p.clone())), + menus::Item::Sub(name, children) => { + path.push(name.to_string()); + walk(children, path, out); + path.pop(); + } + _ => {} + } + } + } + static PATHS: OnceLock>> = OnceLock::new(); + PATHS.get_or_init(|| { + let mut out = HashMap::new(); + for (name, items) in menus::menu_tree() { + walk(&items, &mut vec![name.to_string()], &mut out); + } + out + }) +} + +/// The menu path of the item of the menu bar that runs command `id` as a key press runs it +/// ([`a_menu_bar_item_runs`]): the first item that runs it with no params, or else the first item, +/// whose params all its items give. +fn menu_bar_path(id: &str) -> Option<&'static str> { + let (_, params) = command_doc(id)?; + let items = menu_bar_paths().get(id).filter(|_| a_menu_bar_item_runs(id, params))?; + chosen_path(items) +} + +/// The menu path [`menu_bar_path`] takes from a command's (menu path, params) items: the first +/// item with no params, or else the first item. +fn chosen_path(items: &[(String, Value)]) -> Option<&str> { + items.iter().find(|(_, p)| is_bare(p)).or(items.first()).map(|(path, _)| path.as_str()) +} + /// Does a command's params doc (`{a?, b?} …`) say it runs with no params, as a shortcut runs it? fn runs_without_params(params: &str) -> bool { params @@ -688,9 +821,7 @@ fn bar_items(tree: &[(&'static str, Vec)]) -> HashMap<&'static str, fn walk(items: &[menus::Item], out: &mut HashMap<&'static str, Vec<(&'static str, Value)>>) { for it in items { match it { - menus::Item::Cmd(label, id, p) if !menus::lists_plugin(id, p) && !menus::shows_a_name(id, label) => { - out.entry(*id).or_default().push((*label, p.clone())) - } + menus::Item::Cmd(label, id, p) if on_the_bar(id, label, p) => out.entry(*id).or_default().push((*label, p.clone())), menus::Item::Sub(_, children) => walk(children, out), _ => {} } @@ -703,6 +834,12 @@ fn bar_items(tree: &[(&'static str, Vec)]) -> HashMap<&'static str, out } +/// Is the menu item with `label` that runs command `id` with params `p` one [`bar_items`] reads: it +/// lists no installed plug-in and shows no name that is user data? +fn on_the_bar(id: &str, label: &str, p: &Value) -> bool { + !menus::lists_plugin(id, p) && !menus::shows_a_name(id, label) +} + fn is_bare(p: &Value) -> bool { p.as_object().is_none_or(|o| o.is_empty()) } @@ -932,7 +1069,7 @@ pub fn assign_in(km: &mut Keymap, key: &str, chord: Option<&str>, force: bool) - .iter() .map(|h| { let k = entry_key(&h.command); - entry(&k).map_or(k, |e| e.label.clone()) + entry(&k).map_or(k, |e| english_name(&e)) }) .collect(); Err(format!("{} is already used by {}", menus::pretty_shortcut(&vc_format(&chord)), names.join(", "))) @@ -1364,12 +1501,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { Cow::Borrowed(_) => view.texts.get(i).cloned().flatten(), Cow::Owned(km) => dialog_shortcuts_with(km, &e.key, view.backtick.as_deref()), }; - let matches = e.label.to_lowercase().contains(&q) - || shown_label(e).to_lowercase().contains(&q) - || e.group.to_lowercase().contains(&q) - || shown_group(&e.group).to_lowercase().contains(&q) - || sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q)); - if !q.is_empty() && !matches { + if !q.is_empty() && !row_matches(e, &q, sc.as_deref()) { continue; } let row = ui.horizontal(|ui| { @@ -1547,6 +1679,18 @@ fn drop_hidden(d: &mut Dialog, overrides: &Overrides) { } } +/// Does the search text `q` (lowercase) find entry `e`, whose shortcut text is `sc`? It does when +/// `e`'s label or group, in English or as shown, the key it shows ([`Entry::shows_key`]) or its +/// shortcut contains `q`. +fn row_matches(e: &Entry, q: &str, sc: Option<&str>) -> bool { + e.label.to_lowercase().contains(q) + || shown_label(e).to_lowercase().contains(q) + || e.group.to_lowercase().contains(q) + || shown_group(&e.group).to_lowercase().contains(q) + || (e.shows_key && e.key.to_lowercase().contains(q)) + || sc.is_some_and(|s| s.to_lowercase().contains(q)) +} + /// Apply a recorded chord to `key` in the working set, warning about (and resolving) conflicts. fn record(d: &mut Dialog, km: &mut Keymap, key: &str, chord: &str) { d.fields.insert("__recording".into(), json!("")); @@ -1554,7 +1698,7 @@ fn record(d: &mut Dialog, km: &mut Keymap, key: &str, chord: &str) { Ok(removed) => { d.fields.insert("set".into(), json!(CUSTOM)); if let Some(first) = removed.first() { - let names: Vec = removed.iter().map(|k| entry(k).map(|e| e.label.clone()).unwrap_or(k.clone())).collect(); + let names: Vec = removed.iter().map(|k| entry(k).map(|e| shown_name(&e)).unwrap_or(k.clone())).collect(); d.fields.insert( "__message".into(), json!(crate::i18n::fmt( @@ -1888,7 +2032,7 @@ mod tests { /// An Export Text line: the row's name as the dialog shows it, a tab and `shortcut`. fn text_row(key: &str, shortcut: &str) -> String { let e = entry(key).unwrap_or_else(|| panic!("no entry {key}")); - if e.is_tool || e.group.is_empty() { format!("{}\t{shortcut}", e.label) } else { format!("{} › {}\t{shortcut}", e.group, e.label) } + format!("{}\t{shortcut}", english_name(&e)) } /// Export Text writes the set's name, then the Tools and the Menu Commands rows in the dialog's @@ -1901,11 +2045,7 @@ mod tests { let text = export_text("VectorCraft Defaults", &ov); let km = keymap_with(&ov); let names = |tools: bool| -> Vec { - entries() - .iter() - .filter(|e| e.is_tool == tools && listed(km.overrides(), e)) - .map(|e| if e.is_tool || e.group.is_empty() { format!("{}\t", e.label) } else { format!("{} › {}\t", e.group, e.label) }) - .collect() + entries().iter().filter(|e| e.is_tool == tools && listed(km.overrides(), e)).map(|e| format!("{}\t", english_name(e))).collect() }; let rows = (names(true), names(false)); (text, km, rows.0, rows.1) @@ -2126,7 +2266,7 @@ mod tests { return; } for (alias, edit) in [("F2", "edit.cut"), ("F3", "edit.copy"), ("F4", "edit.paste")] { - let label = entry(edit).map(|e| e.label.clone()).unwrap(); + let label = entry(edit).map(|e| english_name(&e)).unwrap(); assert_eq!(conflicts_with(&Overrides::new(), alias, "tool:pen"), vec![edit.to_string()], "{alias}"); let mut ov = Overrides::new(); let err = assign(&mut ov, "tool:pen", Some(alias), false).unwrap_err(); @@ -2484,6 +2624,111 @@ mod tests { } } + /// No two entries of Keyboard Shortcuts have the same English name. A command that an item of + /// the menu bar runs takes that item's menu path (Window › Swatch Libraries › Other Library…), a + /// command whose id extends another command's id takes that command's label (Character Style + /// Options › Reset Panel), and the other entries that would share a name show their key, except + /// the one an item of the menu bar runs: File › New… is the New Document dialog's entry, and + /// `file.new` is File › New… (file.new). An entry whose name no other entry has keeps its group. + /// The search finds an entry by the key it shows, and a conflict, in `shortcuts.set` and in the + /// dialog, names the entry that has the shortcut by its whole name. + #[test] + fn no_two_entries_have_the_same_name() { + let all = entries(); + let mut by_name: HashMap> = HashMap::new(); + for e in all.iter() { + by_name.entry(english_name(e)).or_default().push(e.key.as_str()); + } + let shared: Vec<_> = by_name.into_iter().filter(|(_, keys)| keys.len() > 1).collect(); + assert!(shared.is_empty(), "{shared:?}"); + let shown = |id: &str| entry(id).map(|e| (e.group, e.label, e.shows_key)); + for (id, group) in [ + ("window.brushLibrary.other", "Window › Brush Libraries"), + ("window.graphicStyleLibrary.other", "Window › Graphic Style Libraries"), + ("window.swatchLibrary.other", "Window › Swatch Libraries"), + ("window.symbolLibrary.other", "Window › Symbol Libraries"), + ] { + assert_eq!(shown(id), Some((group.into(), "Other Library…".into(), false)), "{id}"); + } + assert_eq!(shown("ui.charStyleOptions.resetPanel"), Some(("Character Style Options".into(), "Reset Panel".into(), false))); + assert_eq!(shown("ui.paraStyleOptions.resetPanel"), Some(("Paragraph Style Options".into(), "Reset Panel".into(), false))); + assert_eq!(shown("file.newDialog"), Some(("File".into(), "New…".into(), false))); + assert_eq!(shown("file.new"), Some(("File".into(), "New…".into(), true))); + assert_eq!(shown("text.fitHeadline"), Some(("Type".into(), "Fit Headline".into(), false))); + assert_eq!(shown("type.fitHeadline"), Some(("Type".into(), "Fit Headline".into(), true))); + for id in ["file.exportInBackground", "file.openInBackground"] { + assert_eq!(shown(id), Some(("File".into(), "Continue in Background".into(), true)), "{id}"); + } + assert_eq!(shown("file.cancelExport"), Some(("File".into(), "Cancel Export".into(), false))); + let e = entry("type.fitHeadline").unwrap(); + assert!(shown_name(&e).ends_with("Fit Headline (type.fitHeadline)"), "{}", shown_name(&e)); + assert!(row_matches(&e, "type.fitheadline", None)); + let e = entry("text.fitHeadline").unwrap(); + assert!(shown_name(&e).ends_with("Fit Headline"), "{}", shown_name(&e)); + assert!(!row_matches(&e, "text.fitheadline", None)); + let mut ov = Overrides::new(); + assign(&mut ov, "window.swatchLibrary.other", Some("Cmd+Alt+Shift+F11"), false).unwrap(); + let err = assign(&mut ov, "window.brushLibrary.other", Some("Cmd+Alt+Shift+F11"), false).unwrap_err(); + assert!(err.ends_with("is already used by Window › Swatch Libraries › Other Library…"), "{err}"); + assign(&mut ov, "type.fitHeadline", Some("Cmd+Alt+Shift+F10"), false).unwrap(); + let err = assign(&mut ov, "text.fitHeadline", Some("Cmd+Alt+Shift+F10"), false).unwrap_err(); + assert!(err.ends_with("is already used by Type › Fit Headline (type.fitHeadline)"), "{err}"); + let mut d = Dialog::new("shortcuts", json!({})); + let mut km = keymap_with(&Overrides::new()); + record(&mut d, &mut km, "type.fitHeadline", "Cmd+Alt+Shift+F10"); + record(&mut d, &mut km, "text.fitHeadline", "Cmd+Alt+Shift+F10"); + assert!(d.str("__message").contains("was already used by Type › Fit Headline (type.fitHeadline) "), "{}", d.str("__message")); + } + + /// Two filter plug-ins with the same name in the same menu show their ids, and a third whose + /// name is one of those names with the id stays apart from it. A filter with that name in + /// another menu, or with a label that differs by its "…", shows no id. + #[test] + fn a_plugin_named_like_another_row_with_its_key_stays_apart() { + let row = |key: &str, label: &str, group: &str| Entry { + key: key.into(), + label: label.into(), + group: group.into(), + is_tool: false, + shows_key: false, + }; + let menu = "Object › Plug-ins"; + let mut rows = vec![ + row("plugin:a", "Foo", menu), + row("plugin:b", "Foo", menu), + row("plugin:c", "Foo (plugin:a)", menu), + row("plugin:d", "Bar", menu), + row("plugin:e", "Foo", "Object › Plug-ins › Other"), + row("plugin:f", "Foo…", menu), + ]; + distinct_names(&mut rows); + let names: Vec = rows.iter().map(english_name).collect(); + assert_eq!( + names, + [ + "Object › Plug-ins › Foo (plugin:a)", + "Object › Plug-ins › Foo (plugin:b)", + "Object › Plug-ins › Foo (plugin:a) (plugin:c)", + "Object › Plug-ins › Bar", + "Object › Plug-ins › Other › Foo", + "Object › Plug-ins › Foo…" + ] + ); + } + + /// A command with items in more than one menu is shown under the path of its first item with + /// no params, or else of its first item: Recolor Artwork… under Edit › Edit Colors, not Edit › + /// Edit Colors › Recolor with Preset. + #[test] + fn a_command_on_several_items_takes_the_path_of_its_item_with_no_params() { + let item = |path: &str, p: Value| (path.to_string(), p); + let items = [item("A › B", json!({"x": 1})), item("A › C", Value::Null), item("A › D", json!({}))]; + assert_eq!(chosen_path(&items), Some("A › C")); + assert_eq!(chosen_path(&[item("A › B", json!({"x": 1})), item("A › C", json!({"x": 2}))]), Some("A › B")); + assert_eq!(chosen_path(&[]), None); + assert_eq!(menu_bar_path("ui.recolorDialog"), Some("Edit › Edit Colors")); + } + /// `shortcuts.set`, `shortcuts.list` and `shortcuts.preset` reject a supplied param of the wrong /// type with an error naming it, and change nothing; an omitted or null param keeps its default. #[test] diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index e002d07adf..c1be55ceb0 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -2982,8 +2982,13 @@ fn a_heavy_filter_plugin_runs_from_its_shortcut_as_from_its_menu_item() { fn a_filter_plugin_row_translates_its_path_and_not_its_name() { let zh = crate::i18n::Lang::from_code("zh-hant").unwrap(); crate::i18n::set_current(zh); - let entry = - |key: &str| crate::shortcut_editor::Entry { key: key.into(), label: "Group".into(), group: "Object › Plug-ins".into(), is_tool: false }; + let entry = |key: &str| crate::shortcut_editor::Entry { + key: key.into(), + label: "Group".into(), + group: "Object › Plug-ins".into(), + is_tool: false, + shows_key: false, + }; let path = format!("{} › {}", crate::i18n::tr(zh, "Object"), crate::i18n::tr(zh, "Plug-ins")); let (filter, command) = (crate::shortcut_editor::shown_name(&entry("plugin:a.b")), crate::shortcut_editor::shown_name(&entry("object.group"))); crate::i18n::set_current(crate::i18n::Lang::EN); diff --git a/docs/mcp.md b/docs/mcp.md index 62a4212472..9932b7b450 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4916,12 +4916,35 @@ is listed as Document › Export Selection…. A shortcut already given to `docu When the other entry is anything else, the command is not listed: `window.learn` is left out, and Window › Learn is the Learn panel's entry. +No two rows of Edit › Keyboard Shortcuts have the same English name. Rows that would have the same group and label +are named this way: + +- A command that an item of the menu bar runs, with no params or with the params all its items give, is listed under + that item's menu path: `window.brushLibrary.other`, `window.graphicStyleLibrary.other`, + `window.swatchLibrary.other` and `window.symbolLibrary.other` are listed as Window › Brush Libraries › Other + Library…, Window › Graphic Style Libraries › Other Library…, Window › Swatch Libraries › Other Library… and + Window › Symbol Libraries › Other Library…. +- A command whose id is another command's id with one more part is listed under that command's label: + `ui.charStyleOptions.resetPanel` and `ui.paraStyleOptions.resetPanel` are listed as Character Style Options › + Reset Panel and Paragraph Style Options › Reset Panel. +- The other rows that still have the same name show their id in parentheses after it. The one row of them that an + item of the menu bar runs keeps the name without its id: File › New… is `file.newDialog` and `file.new` is File › + New… (file.new), and Type › Fit Headline is `text.fitHeadline` and `type.fitHeadline` is Type › Fit Headline + (type.fitHeadline). `file.exportInBackground` and `file.openInBackground` are File › Continue in Background + (file.exportInBackground) and File › Continue in Background (file.openInBackground). + +`shortcuts.list` gives such a row's group in `group` and the command's own label in `label`, so the rows that show +their id have the same `group` and `label` there, and `id` tells them apart. `shortcuts.exportText` writes these +names. When a shortcut is already in use, the message names each row that has it by its whole name, group included, +for every row: a `shortcuts.set` error ends with "is already used by Window › Swatch Libraries › Other Library…". + Each installed filter plug-in's Object › Plug-ins item is its own row, with id `plugin:` (the plug-in's id), its -menu label and no default shortcut, so two filters with the same name have two rows. `shortcuts.set {id: -"plugin:", shortcut}`, `shortcuts.list` and Import Shortcut Set use that id, `shortcuts.exportText` writes one line -for the row under its menu path and label, and the key runs what a click on the item runs, `plugin.dialog {id}`. A -removed plug-in's row goes away and its key runs nothing; its shortcut stays saved and applies again when a plug-in -with the same id is installed. +menu label and no default shortcut, so two filters with the same name have two rows. When the two rows have the same +menu path and label, each shows its id after the name. `shortcuts.set {id: "plugin:", shortcut}`, +`shortcuts.list` and Import Shortcut Set use that id, `shortcuts.exportText` writes one line for the row under its menu +path and label, with its id when the row shows it, and the key runs what a click on the item runs, `plugin.dialog +{id}`. A removed plug-in's row goes away and its key runs nothing; its shortcut stays saved and applies again when a +plug-in with the same id is installed. `shortcuts.exportText {path?}` saves the current set's shortcut list as a UTF-8 text file, as Edit › Keyboard Shortcuts › Export Text… does with the dialog's set as edited so far. The file starts with a "Keyboard Shortcuts" line diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5ec97e34c1..2098359d85 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -929,6 +929,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - On Linux and the BSDs, Preferences › User Interface › VectorCraft Title Bar on Every Desktop (off by default) uses the app bar as the title bar from the next start in the sessions where the desktop would draw the window's frame (X11, Wayland compositors with server-side decorations such as KDE Plasma, a window opened through XWayland). System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it, and `VECTORCRAFT_TITLEBAR` also takes precedence over System Title Bar for the run it is set in. Only the values `app`, `system` and `desktop` take precedence (case-sensitive, surrounding spaces ignored); any other value, such as `unknown`, is ignored and does not override System Title Bar. - Edit › Keyboard Shortcuts lists items of the menu bar and panel menus, as the reference app's [Customize keyboard shortcuts](https://web.archive.org/web/20221205131209id_/https://helpx.adobe.com/illustrator/using/customizing-keyboard-shortcuts.html) page describes for its Menu Commands. A menu command with no default shortcut that needs a value a key press does not give, such as File › Document Setup › Units (`document.setUnits {units}`), is listed only while the user has changed its shortcut. A command stays listed when a menu bar item runs it without a value or when it opens a dialog run without one, as the Variables panel's commands do. + - No two rows of Edit › Keyboard Shortcuts have the same English name (VectorCraft's own rule). A command that an item of the menu bar runs is listed under that item's menu path (Window › Swatch Libraries › Other Library…), a command whose id extends another command's id is listed under that command's label (Character Style Options › Reset Panel), and the rows that still share a name show their id after it, except the one row an item of the menu bar runs (File › New… and File › New… (file.new)). The search finds a row by the id it shows, and a message about a shortcut already in use names the other row with its group. Test `shortcut_editor::tests::no_two_entries_have_the_same_name`. - A shortcut on the key left of 1 runs whatever character that key types, and Edit › Keyboard Shortcuts, the menus in the window, the command palette and tool tips show it as that character once the key has been pressed: `"` on a Turkish Q layout, `~` on a US layout as before. Shortcuts saved as `~` keep working (#4559). - On Windows a maximized window stays inside the work area with the taskbar on any edge (#3242, #3723). - On Windows a maximized window stops one pixel short of each monitor edge where an auto-hidden taskbar sits.