Keyboard Shortcuts: each Object › Plug-ins filter has a row of its own

Squashed from #6246 onto main.
This commit is contained in:
dotCooCoo
2026-11-09 03:14:41 +07:00
committed by bflatastic
parent 25ecee0c40
commit 85a933ad4f
14 changed files with 861 additions and 188 deletions
+4 -1
View File
@@ -65,9 +65,12 @@ pub fn approved(plugin: &Plugin) -> bool {
/// Installs `plugin`, replacing one with the same id.
pub fn install(mut plugin: Plugin) -> Arc<Plugin> {
// The revision moves while the write lock is held, so a cache that reads the new revision and
// then the plug-ins finds the new plug-in among them.
let mut plugins = PLUGINS.write().unwrap_or_else(|e| e.into_inner());
plugin.set_serial(REVISION.fetch_add(1, Ordering::Relaxed) + 1);
let p = Arc::new(plugin);
PLUGINS.write().unwrap_or_else(|e| e.into_inner()).insert(p.id().to_string(), p.clone());
plugins.insert(p.id().to_string(), p.clone());
p
}
+21 -11
View File
@@ -19,7 +19,7 @@
//! at first when a code is not known), and `__query` and `__attention` (the filter of the rows shown).
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::sync::OnceLock;
use std::sync::{Arc, Mutex};
use craft_keymap::import::namematch::{Candidate, Matcher, Proposal};
use craft_keymap::import::{MAX_IMPORT_TEXT, MAX_PATH_SEGMENTS};
@@ -363,10 +363,12 @@ fn single_key(chord: &str) -> bool {
}
/// What a name can stand for: Keyboard Shortcuts' entries, by their English labels and the labels in
/// the interface language.
/// the interface language. A filter plug-in's row is left out: a row of the file reaches it only
/// when the user picks it as the target.
pub fn candidates() -> Vec<Candidate> {
shortcut_editor::entries()
.iter()
.filter(|e| !e.key.starts_with("plugin:"))
.map(|e| {
let shown = tl!(&e.label);
let labels = if shown == e.label { vec![e.label.clone()] } else { vec![e.label.clone(), shown.to_string()] };
@@ -375,10 +377,17 @@ pub fn candidates() -> Vec<Candidate> {
.collect()
}
/// Keyboard Shortcuts' entry `key`.
fn entry(key: &str) -> Option<&'static Entry> {
static BY_KEY: OnceLock<HashMap<&'static str, &'static Entry>> = OnceLock::new();
BY_KEY.get_or_init(|| shortcut_editor::entries().iter().map(|e| (e.key.as_str(), e)).collect()).get(key).copied()
/// Keyboard Shortcuts' entry `key`, among the entries for the plug-ins installed now.
fn entry(key: &str) -> Option<Entry> {
type Index = (Arc<[Entry]>, HashMap<String, usize>);
static BY_KEY: Mutex<Option<Index>> = Mutex::new(None);
let entries = shortcut_editor::entries();
let mut by = BY_KEY.lock().unwrap_or_else(|e| e.into_inner());
if by.as_ref().is_none_or(|(e, _)| !Arc::ptr_eq(e, &entries)) {
let index = entries.iter().enumerate().map(|(i, e)| (e.key.clone(), i)).collect();
*by = Some((entries, index));
}
by.as_ref().and_then(|(e, index)| index.get(key).and_then(|&i| e.get(i)).cloned())
}
/// A tool, command or panel that Keyboard Shortcuts can give a shortcut.
@@ -388,7 +397,7 @@ fn known(key: &str) -> bool {
/// How Keyboard Shortcuts names entry `e`: a tool by its label, the others after their menu.
fn shown(e: &Entry) -> String {
if e.is_tool || e.group.is_empty() { tl!(&e.label).to_string() } else { format!("{} › {}", tl!(&e.group), tl!(&e.label)) }
shortcut_editor::shown_name(e)
}
/// What a row's target shows.
@@ -396,7 +405,7 @@ fn target_label(target: Option<&str>) -> String {
match target {
None => tl!("Choose…").to_string(),
Some("") => tl!("No equivalent").to_string(),
Some(t) => entry(t).map_or_else(|| t.to_string(), shown),
Some(t) => entry(t).map_or_else(|| t.to_string(), |e| shown(&e)),
}
}
@@ -934,6 +943,7 @@ pub(super) fn target_picker(ui: &mut egui::Ui, i: usize, r: &Row) -> Option<Stri
let option = |ui: &mut egui::Ui, key: &str, label: &str| {
ui.add(egui::Button::selectable(r.target.as_deref() == Some(key), label)).clicked().then(|| key.to_string())
};
let all = shortcut_editor::entries();
widgets::combo_menu(ui, ("shortcut-import-target", i), &current, TARGET_W, |menu| {
menu.height(height).show_rows_with_header(
|ui| {
@@ -946,12 +956,12 @@ pub(super) fn target_picker(ui: &mut egui::Ui, i: usize, r: &Row) -> Option<Stri
let mut picked = option(ui, "", tl!("No equivalent"));
let list: Vec<&Entry> = if q.is_empty() {
for e in r.choices.iter().filter_map(|c| entry(c)) {
picked = option(ui, &e.key, &shown(e)).or(picked);
picked = option(ui, &e.key, &shown(&e)).or(picked);
}
ui.separator();
shortcut_editor::entries().iter().collect()
all.iter().collect()
} else {
shortcut_editor::entries().iter().filter(|e| shown(e).to_lowercase().contains(&q) || e.key.to_lowercase().contains(&q)).collect()
all.iter().filter(|e| shown(e).to_lowercase().contains(&q) || e.key.to_lowercase().contains(&q)).collect()
};
ui.data_mut(|d| d.insert_temp(header_id, ui.min_rect().height()));
(list, picked)
@@ -596,6 +596,30 @@ fn long_names_match_and_apply() {
assert_eq!((out["applied"].clone(), ov), (json!(1), json!({"edit.copy": "Cmd+Alt+Shift+F11"})));
}
/// A filter plug-in's row takes an imported shortcut when the user picks it, stored under its
/// `plugin.dialog {id}` key. A file's row with the plug-in's name does not match it by itself, and
/// a removed plug-in is not a target.
#[test]
fn an_imported_shortcut_reaches_a_filter_plugin_picked_as_its_target() {
use vectorcraft_plugins::wat as tpl;
let id = "test.UI.import_filter-one";
let key = format!("plugin:{id}");
let manifest = format!(r#"{{"id":"{id}","name":"Acme Import Filter","version":"1","kind":"filter","params":{{}}}}"#);
vectorcraft_plugins::registry::install_bytes(&wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap()).unwrap();
assert!(shortcut_editor::entry(&key).is_some(), "a target the picker lists");
assert!(super::shortcut_import::candidates().iter().all(|c| c.id != key), "a name alone does not match it");
let mut app = app();
let (out, ov) = apply_rows(&mut app, "defaults", &[("Anything", "Menus", &key, "Cmd+Alt+Shift+F11")]);
assert_eq!(out["applied"], 1);
assert_eq!(ov, json!({ key.as_str(): "Cmd+Alt+Shift+F11" }), "not a binding of plugin.dialog without its id");
let reloaded = crate::keymap::stored::from_value(ov);
assert_eq!(reloaded.get(&crate::keymap::stored_key(&key)).map(<[String]>::len), Some(1), "read back under the filter's id");
vectorcraft_plugins::registry::remove(id);
assert!(shortcut_editor::entries().iter().all(|e| e.key != key), "a removed filter is no target");
}
/// A search in a row's target picker that matches nothing shrinks its menu to the search field
/// and "No equivalent"; clearing the search lists every entry again, and the menu grows back to
/// its height before the search (egui#5225). The search field stays where it was throughout.
+143 -52
View File
@@ -3,16 +3,18 @@
//! user's overrides.
//!
//! Entry keys are VectorCraft's everywhere outside this module: a command id, `tool:<id>`, or
//! `panel:<id>` for the Window menu item showing a panel (`window.panel {panel}`). Chord texts
//! outside it are VectorCraft's too (`Cmd+Shift+]`, `Comma`, `Period`, `~` for the backtick key,
//! `=` for Plus with modifiers). Inside an [`Overrides`] keys and texts are the crate's.
//! `panel:<id>` for the Window menu item showing a panel (`window.panel {panel}`), `effect:<id>`
//! for an Effect menu item, or `plugin:<id>` for a filter plug-in's Object › Plug-ins item
//! (`plugin.dialog {id}`). Chord texts outside it are VectorCraft's too (`Cmd+Shift+]`, `Comma`,
//! `Period`, `~` for the backtick key, `=` for Plus with modifiers). Inside an [`Overrides`] keys
//! and texts are the crate's.
use std::sync::{OnceLock, RwLock};
use std::sync::{Arc, Mutex, RwLock};
use craft_keymap::{Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Lookup, Mods, Overrides, Platform, Scope, Scopes};
use serde_json::{Value, json};
use crate::shortcut_editor::{self, Entry};
use crate::shortcut_editor::{self, EffectItem, Entry, Snapshot};
/// Menu commands whose label flips between Show and Hide (see `menus::dynamic_label`).
pub const TOGGLES: &[&str] = &[
@@ -38,37 +40,95 @@ pub const TOGGLES: &[&str] = &[
/// The command an entry key runs.
pub fn command_of(key: &str) -> CommandRef {
if key.starts_with("effect:") {
return command_in(key, &shortcut_editor::effect_items());
}
command_in(key, &[])
}
/// The command an entry key runs while the Effect menu holds `effects`.
fn command_in(key: &str, effects: &[EffectItem]) -> CommandRef {
if let Some(panel) = key.strip_prefix("panel:") {
return CommandRef::with_params("window.panel", json!({ "panel": panel }));
}
// An Effect menu item (#3296): what its item runs, its dialog unless it has no options.
// An Effect menu item (#3296): what its item runs, its dialog unless it has no options. An
// effect the menu does not hold (a plug-in removed for now) is named by its dialog.
if let Some(effect) = key.strip_prefix("effect:") {
let command = shortcut_editor::effect_items().iter().find(|e| e.effect == effect).map_or("effect.dialog", |e| e.command);
let command = effects.iter().find(|e| e.effect == effect).map_or("effect.dialog", |e| e.command);
return CommandRef::with_params(command, json!({ "effect": effect }));
}
// A filter plug-in's Object › Plug-ins item, installed or not: what its item runs.
if let Some(id) = key.strip_prefix("plugin:") {
return CommandRef::with_params("plugin.dialog", json!({ "id": id }));
}
CommandRef::new(key)
}
/// The entry key of a command: `panel:<id>` for `window.panel {panel}`.
/// `overrides` with each Effect menu item's shortcut under the command its item runs while the
/// menu holds `effects`: a shortcut saved while a plug-in effect was missing, or while it had
/// options it no longer has, stays that effect's.
pub(crate) fn effect_keys_in(overrides: &Overrides, effects: &[EffectItem]) -> Overrides {
let mut out = overrides.clone();
for (k, texts) in overrides.iter() {
let entry = entry_of_stored(k);
if !entry.starts_with("effect:") {
continue;
}
let now = command_in(&entry, effects).key();
if now != k && out.get(&now).is_none() {
out.remove(k);
let _ = out.insert(now, texts.to_vec());
}
}
out
}
/// The entry key of a command: `panel:<id>` for `window.panel {panel}`, `effect:<id>` for an
/// Effect menu item and `plugin:<id>` for `plugin.dialog {id}`.
pub fn entry_key(command: &CommandRef) -> String {
match (command.id.as_str(), command.params.get("panel").and_then(Value::as_str)) {
("window.panel", Some(panel)) => format!("panel:{panel}"),
("effect.dialog" | "effect.apply", _) if command.params.get("effect").and_then(Value::as_str).is_some() => {
format!("effect:{}", command.params.get("effect").and_then(Value::as_str).unwrap_or_default())
}
("plugin.dialog", _) if command.params.get("id").and_then(Value::as_str).is_some() => {
format!("plugin:{}", command.params.get("id").and_then(Value::as_str).unwrap_or_default())
}
_ => command.key(),
}
}
/// The [`Overrides`] key of an entry key.
pub fn stored_key(key: &str) -> String {
if key.starts_with("panel:") || key.starts_with("effect:") { command_of(key).key() } else { key.to_string() }
if key.starts_with("panel:") || key.starts_with("effect:") || key.starts_with("plugin:") { command_of(key).key() } else { key.to_string() }
}
/// An Effect menu item's [`Overrides`] key under the command its item does not run now
/// (`effect.apply` for one that opens its dialog, and the other way round).
fn other_stored_key(key: &str) -> Option<String> {
let effect = key.strip_prefix("effect:")?;
let other = if command_of(key).id == "effect.apply" { "effect.dialog" } else { "effect.apply" };
Some(CommandRef::with_params(other, json!({ "effect": effect })).key())
}
/// The texts of an entry's override, saved under its [`stored_key`] or, for an Effect menu item,
/// under its [`other_stored_key`].
pub(crate) fn stored_texts<'a>(overrides: &'a Overrides, key: &str) -> Option<&'a [String]> {
overrides.get(&stored_key(key)).or_else(|| other_stored_key(key).and_then(|k| overrides.get(&k)))
}
/// Removes an entry's override, under either key an Effect menu item's can have.
pub(crate) fn remove_override(overrides: &mut Overrides, key: &str) {
overrides.remove(&stored_key(key));
if let Some(k) = other_stored_key(key) {
overrides.remove(&k);
}
}
/// The entry key of an [`Overrides`] key.
pub fn entry_of_stored(key: &str) -> String {
CommandRef::parse_key(key)
.filter(|c| matches!(c.id.as_str(), "window.panel" | "effect.dialog" | "effect.apply"))
.filter(|c| matches!(c.id.as_str(), "window.panel" | "effect.dialog" | "effect.apply" | "plugin.dialog"))
.map_or_else(|| key.to_string(), |c| entry_key(&c))
}
@@ -132,7 +192,7 @@ fn vc_binding(text: &str) -> Option<Binding> {
/// Every listed entry as the crate sees it, in dispatch priority: commands (engine, then UI),
/// panels, tools. Each tool is its own group: tools never cycle.
fn catalogue(p: Platform) -> Vec<Bindable> {
fn catalogue(snapshot: &Snapshot, p: Platform) -> Vec<Bindable> {
let rank = |e: &Entry| {
if e.is_tool {
2
@@ -142,14 +202,14 @@ fn catalogue(p: Platform) -> Vec<Bindable> {
0
}
};
let mut entries: Vec<&Entry> = shortcut_editor::entries().iter().collect();
let mut entries: Vec<&Entry> = snapshot.entries.iter().collect();
entries.sort_by_key(|e| rank(e));
entries
.into_iter()
.map(|e| {
let mut path: Vec<String> = e.group.split(" › ").filter(|s| !s.is_empty()).map(str::to_string).collect();
path.push(e.label.clone());
let mut b = Bindable::new(command_of(&e.key), e.label.clone(), Scope::app()).with_path(path);
let mut b = Bindable::new(command_in(&e.key, &snapshot.effects), e.label.clone(), Scope::app()).with_path(path);
if let Some(tool) = e.key.strip_prefix("tool:") {
b = b.with_kind(Kind::Tool { group: Some(tool.to_string()) });
}
@@ -208,34 +268,54 @@ pub fn note_os(ctx: &egui::Context) {
}
}
/// The value for `p` in `cells`, made on first use. Each platform has its own cell, because the
/// web build learns the browser's platform only after the first frame.
pub(crate) fn per_platform<T>(cells: &'static [OnceLock<T>; 3], p: Platform, make: impl FnOnce() -> T) -> &'static T {
let [mac, windows, other] = cells;
let cell = match p {
Platform::Mac => mac,
Platform::Windows => windows,
_ => other,
};
cell.get_or_init(make)
/// The keymap with the default shortcuts for [`platform`] and the plug-ins installed now.
pub fn template() -> Arc<Keymap> {
template_for(&shortcut_editor::snapshot())
}
/// The keymap with the default shortcuts for [`platform`], built once per platform.
pub fn template() -> &'static Keymap {
static TEMPLATES: [OnceLock<Keymap>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()];
/// The keymap with the default shortcuts for [`platform`] and the entries of `snapshot`, built
/// once per platform and plug-in revision. Each platform has its own cell, because the web build
/// learns the browser's platform only after the first frame.
pub(crate) fn template_for(snapshot: &Snapshot) -> Arc<Keymap> {
/// A template and the plug-in revision it was built for.
type Built = Option<(u64, Arc<Keymap>)>;
static TEMPLATES: Mutex<[Built; 3]> = Mutex::new([None, None, None]);
let p = platform();
per_platform(&TEMPLATES, p, || keymap_for(p))
let at = match p {
Platform::Mac => 0,
Platform::Windows => 1,
_ => 2,
};
let cached = TEMPLATES.lock().unwrap_or_else(|e| e.into_inner())[at].clone();
if let Some((_, km)) = cached.filter(|(revision, _)| *revision == snapshot.revision) {
return km;
}
let km = Arc::new(keymap_for(p, snapshot));
let mut cells = TEMPLATES.lock().unwrap_or_else(|e| e.into_inner());
if cells[at].as_ref().is_none_or(|(revision, _)| *revision <= snapshot.revision) {
cells[at] = Some((snapshot.revision, km.clone()));
}
km
}
/// The default keymap of `p`. The chords the operating system takes can't be assigned.
pub(crate) fn keymap_for(p: Platform) -> Keymap {
Keymap::new(catalogue(p), Scopes::new(), p).with_reserved(craft_keymap::reserved::system(p))
/// The default keymap of `p` with the entries of `snapshot`. The chords the operating system
/// takes can't be assigned.
pub(crate) fn keymap_for(p: Platform, snapshot: &Snapshot) -> Keymap {
Keymap::new(catalogue(snapshot, p), Scopes::new(), p).with_reserved(craft_keymap::reserved::system(p))
}
/// The keymap with `overrides` applied.
pub fn keymap_with(overrides: &Overrides) -> Keymap {
let defaults = template();
defaults.clone().with_overrides(edit_alias_overrides(overrides, defaults.platform()))
keymap_in(&shortcut_editor::snapshot(), overrides)
}
/// The keymap of `snapshot`'s entries with `overrides` applied, each Effect menu item's shortcut
/// under the command its item runs now.
pub(crate) fn keymap_in(snapshot: &Snapshot, overrides: &Overrides) -> Keymap {
let defaults = template_for(snapshot);
let moved = effect_keys_in(overrides, &snapshot.effects);
let resolved = edit_alias_overrides(&moved, defaults.platform());
defaults.as_ref().clone().with_overrides(resolved)
}
/// When a saved override assigns F2, F3 or F4 elsewhere without changing its edit command,
@@ -277,7 +357,7 @@ pub fn lookup(km: &Keymap, chord: &Chord) -> Option<CommandRef> {
pub fn effective_with(km: &Keymap, key: &str) -> Option<String> {
let command = command_of(key);
if km.bindable(&command).is_none() {
return match km.overrides().get(&stored_key(key)) {
return match stored_texts(km.overrides(), key) {
Some(texts) => texts.first().filter(|t| !t.trim().is_empty()).map(|t| crate_to_vc(t)),
None => shortcut_editor::default_of(key).map(str::to_string),
};
@@ -297,6 +377,9 @@ pub fn effective_with(km: &Keymap, key: &str) -> Option<String> {
/// Sets an entry's shortcut in VectorCraft's terms: `""` for none.
pub fn set_override(overrides: &mut Overrides, key: &str, text: &str) {
let texts = if text.trim().is_empty() { vec![] } else { vec![vc_to_crate(text)] };
if let Some(k) = other_stored_key(key) {
overrides.remove(&k);
}
// Only an over-long key or text is refused; such an entry could not be stored anyway.
let _ = overrides.insert(stored_key(key), texts);
}
@@ -312,11 +395,11 @@ pub fn overrides_from(pairs: &[(&str, &str)]) -> Overrides {
/// An entry's stored shortcut in VectorCraft's terms (`""` for none), when it has an override.
pub fn override_text(overrides: &Overrides, key: &str) -> Option<String> {
overrides.get(&stored_key(key)).map(|t| t.first().map(|t| crate_to_vc(t)).unwrap_or_default())
stored_texts(overrides, key).map(|t| t.first().map(|t| crate_to_vc(t)).unwrap_or_default())
}
pub fn has_override(overrides: &Overrides, key: &str) -> bool {
overrides.get(&stored_key(key)).is_some()
stored_texts(overrides, key).is_some()
}
/// Serde for [`Overrides`] in VectorCraft's own form, which every earlier version reads: a map
@@ -428,7 +511,7 @@ mod tests {
#[test]
fn curvature_keeps_shift_tilde_and_plus_is_one_key() {
let km = template();
let km = &*template();
let curvature = vc_chord("Shift+~").unwrap();
assert_eq!(curvature, Chord::parse("Shift+`").unwrap());
assert_eq!(lookup(km, &curvature), Some(CommandRef::new("tool:curvature")));
@@ -448,17 +531,21 @@ mod tests {
#[test]
fn the_default_shortcuts_do_not_conflict() {
assert_eq!(template().conflicts(), vec![]);
assert_eq!(template().problems(), vec![]);
assert_eq!(template().catalog().len(), shortcut_editor::entries().len(), "every entry is bindable");
// One snapshot: tests running beside this one install and remove plug-ins.
let snapshot = shortcut_editor::snapshot();
let template = template_for(&snapshot);
assert_eq!(template.conflicts(), vec![]);
assert_eq!(template.problems(), vec![]);
assert_eq!(template.catalog().len(), snapshot.entries.len(), "every entry is bindable");
// A default the bridge can't read would show in the menus but never run.
let unbound: Vec<(&str, &str)> = shortcut_editor::entries()
let unbound: Vec<(&str, &str)> = snapshot
.entries
.iter()
.filter_map(|e| shortcut_editor::default_of(&e.key).map(|d| (e.key.as_str(), d)))
.filter(|(key, d)| {
let mut expected: Vec<Binding> = vc_binding(d).into_iter().collect();
expected.extend(default_aliases(key, platform()).iter().filter_map(|alias| vc_binding(alias)));
expected != template().default_bindings(&command_of(key))
expected != template.default_bindings(&command_in(key, &snapshot.effects))
})
.collect();
assert_eq!(unbound, vec![], "every default text becomes the entry's binding");
@@ -471,10 +558,10 @@ mod tests {
let defaults = template().default_bindings(&command);
assert_eq!(defaults, vec![vc_binding(plain).unwrap(), vc_binding(shifted).unwrap()], "{key}");
assert_eq!(
shortcut_editor::entry(key).map(|e| e.label.as_str()),
shortcut_editor::entry(key).as_ref().map(|e| e.label.as_str()),
Some(if key == "paint.lastColor" { "Apply Last Color" } else { "Apply Last Gradient" })
);
assert_eq!(effective_with(template(), key).as_deref(), Some(plain));
assert_eq!(effective_with(&template(), key).as_deref(), Some(plain));
let mut overrides = Overrides::new();
set_override(&mut overrides, key, "Shift+;");
assert_eq!(keymap_with(&overrides).bindings(&command), vec![vc_binding("Shift+;").unwrap()]);
@@ -487,7 +574,7 @@ mod tests {
#[test]
fn edit_function_keys_follow_platform_and_reset_restores_both_bindings() {
for p in [Platform::Windows, Platform::Linux, Platform::Mac] {
let km = keymap_for(p);
let km = keymap_for(p, &shortcut_editor::snapshot());
for (key, primary, alternate) in [("edit.cut", "Cmd+X", "F2"), ("edit.copy", "Cmd+C", "F3"), ("edit.paste", "Cmd+V", "F4")] {
let command = command_of(key);
let mut expected = vec![vc_binding(primary).unwrap()];
@@ -514,16 +601,16 @@ mod tests {
for (alias, edit, primary) in [("F2", "edit.cut", "Cmd+X"), ("F3", "edit.copy", "Cmd+C"), ("F4", "edit.paste", "Cmd+V")] {
let mut saved = Overrides::new();
set_override(&mut saved, "tool:pen", alias);
let assigned = keymap_for(p).with_overrides(edit_alias_overrides(&saved, p));
let assigned = keymap_for(p, &shortcut_editor::snapshot()).with_overrides(edit_alias_overrides(&saved, p));
assert_eq!(lookup(&assigned, &vc_chord(alias).unwrap()), Some(command_of("tool:pen")), "{p:?} saved {alias} assignment");
assert_eq!(lookup(&assigned, &vc_chord(primary).unwrap()), Some(command_of(edit)), "{p:?} primary {edit} binding");
shortcut_editor::reset_one(&mut saved, "tool:pen");
let reset = keymap_for(p).with_overrides(edit_alias_overrides(&saved, p));
let reset = keymap_for(p, &shortcut_editor::snapshot()).with_overrides(edit_alias_overrides(&saved, p));
assert_eq!(lookup(&reset, &vc_chord(alias).unwrap()), (!p.is_mac()).then_some(command_of(edit)), "{p:?} reset saved {alias}");
}
let mut unknown = Overrides::new();
set_override(&mut unknown, "missing.command", "F2");
let km = keymap_for(p).with_overrides(edit_alias_overrides(&unknown, p));
let km = keymap_for(p, &shortcut_editor::snapshot()).with_overrides(edit_alias_overrides(&unknown, p));
assert_eq!(lookup(&km, &vc_chord("F2").unwrap()), (!p.is_mac()).then_some(command_of("edit.cut")), "{p:?} unknown saved entry");
}
}
@@ -531,10 +618,12 @@ mod tests {
/// The chords the operating system takes are refused, and no default uses one.
#[test]
fn system_shortcuts_are_refused() {
let snapshot = shortcut_editor::snapshot();
for (p, os_chord) in [(Platform::Mac, "Cmd+Tab"), (Platform::Windows, "Alt+F4"), (Platform::Linux, "Ctrl+Alt+F2")] {
let mut km = keymap_for(p);
let mut km = keymap_for(p, &snapshot);
assert_eq!((km.conflicts(), km.problems()), (vec![], vec![]), "{p:?}");
let reserved: Vec<String> = shortcut_editor::entries()
let reserved: Vec<String> = snapshot
.entries
.iter()
.filter(|e| km.bindings(&command_of(&e.key)).iter().any(|b| km.is_reserved(&b.trigger.chord)))
.map(|e| e.key.clone())
@@ -545,7 +634,7 @@ mod tests {
// A saved override on a reserved chord keeps the default.
let mut ov = Overrides::new();
let _ = ov.insert(stored_key("edit.undo"), vec![os_chord.to_string()]);
let km = keymap_for(p).with_overrides(ov);
let km = keymap_for(p, &snapshot).with_overrides(ov);
assert_eq!(km.bindings(&command_of("edit.undo")), km.default_bindings(&command_of("edit.undo")), "{p:?}");
}
}
@@ -557,7 +646,8 @@ mod tests {
assert_eq!(entry_key(&c), "panel:layers");
assert_eq!(entry_of_stored(&stored_key("panel:layers")), "panel:layers");
assert_eq!(stored_key("tool:pen"), "tool:pen");
let paths = &template().bindable(&c).unwrap().paths;
let template = template();
let paths = &template.bindable(&c).unwrap().paths;
assert_eq!(paths, &vec![vec!["Window".to_string(), "Layers".to_string()]], "import matches panels by name");
}
@@ -569,7 +659,8 @@ mod tests {
for id in TOGGLES {
let label = crate::menus::dynamic_label(&app, id, "");
assert!(label.starts_with("Show ") || label.starts_with("Hide "), "{id}: {label}");
let listed = template().bindable(&CommandRef::new(*id));
let template = template();
let listed = template.bindable(&CommandRef::new(*id));
assert!(listed.is_none_or(|b| b.is_toggle), "{id}");
}
}
+5 -1
View File
@@ -1,4 +1,4 @@
use super::state::{SUBJECT_COMMANDS, has_subject, waits_for_ime};
use super::state::{SUBJECT_COMMANDS, enabled, has_subject, waits_for_ime};
use super::{Value, VectorcraftApp, json};
/// What a menu click runs: items whose label ends with "…" and that carry default params open a
@@ -138,6 +138,10 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
if waits_for_ime(app, id) {
return;
}
// A shortcut or a palette pick on a filter plug-in does nothing while its menu item is dimmed.
if id == "plugin.dialog" && !enabled(app, id) {
return;
}
if SUBJECT_COMMANDS.contains(&id)
&& p.get("ids").is_none_or(|ids| ids.as_array().is_some_and(Vec::is_empty))
&& p.get("id").is_none()
+3 -1
View File
@@ -212,11 +212,13 @@ pub(super) fn unlock_item_label(app: &VectorcraftApp, id: &str, p: &Value, lang:
}
/// The shortcut a menu item shows: its command's (items without params), a panel's for the
/// Window menu's `window.panel {panel}` items, or an effect's for the Effect menu's items.
/// Window menu's `window.panel {panel}` items, an effect's for the Effect menu's items, or a filter
/// plug-in's for Object › Plug-ins' `plugin.dialog {id}` items.
pub fn item_shortcut(id: &str, p: &Value) -> Option<&'static str> {
match (id, p.get("panel").and_then(Value::as_str)) {
("window.panel", Some(panel)) => crate::shortcut_editor::panel_shortcut(panel),
("effect.dialog" | "effect.apply", _) => p.get("effect").and_then(Value::as_str).and_then(crate::shortcut_editor::effect_shortcut),
("plugin.dialog", _) => p.get("id").and_then(Value::as_str).and_then(crate::shortcut_editor::filter_shortcut),
// The Artboard tool's canvas menu passes the point its Paste goes to.
("edit.paste", _) => shortcut_of(id),
// That menu's Cut and Copy: with the tool chosen, the keys run `edit.cut` and `edit.copy`, which do the same.
+76 -17
View File
@@ -16,7 +16,8 @@ pub fn items() -> Vec<(String, String, String)> {
let label = if path.is_empty() { c.label.to_string() } else { format!("{path} › {}", c.label) };
items.push((label, c.id.to_string(), menus::shortcut_of(c.id).unwrap_or("").to_string()));
}
for c in menus::UI_COMMANDS {
// `plugin.dialog` runs nothing without an id: [`build`] lists each filter instead.
for c in menus::UI_COMMANDS.iter().filter(|c| c.0 != "plugin.dialog") {
items.push((c.1.to_string(), c.0.to_string(), menus::shortcut_of(c.0).unwrap_or("").to_string()));
}
for tool in vectorcraft_tools::catalog::all_tools() {
@@ -32,7 +33,7 @@ fn shown_label(lang: crate::i18n::Lang, label: &str) -> String {
}
/// One palette entry, with what it shows and what a query matches worked out once.
struct Entry {
pub(crate) struct Entry {
id: String,
shortcut: String,
/// The label in the UI language.
@@ -59,20 +60,47 @@ fn entries(ctx: &egui::Context, lang: crate::i18n::Lang) -> Arc<Vec<Entry>> {
{
return v;
}
let built: Arc<Vec<Entry>> = Arc::new(
items()
.into_iter()
.map(|(label, id, shortcut)| {
let shown = shown_label(lang, &label);
let haystack = format!("{}\n{}\n{}", label.to_lowercase(), shown.to_lowercase(), id.to_lowercase());
Entry { id, shortcut, shown, haystack }
})
.collect(),
);
let built: Arc<Vec<Entry>> = Arc::new(build(lang));
ctx.data_mut(|d| d.insert_temp::<Cached>(id, (key, built.clone())));
built
}
/// The palette's entries in `lang`: [`items`], then one per filter plug-in's Object › Plug-ins
/// item (`plugin:<id>`), whose name is not translated.
fn build(lang: crate::i18n::Lang) -> Vec<Entry> {
let entry = |label: String, shown: String, id: String, shortcut: String| {
let haystack = format!("{}\n{}\n{}", label.to_lowercase(), shown.to_lowercase(), id.to_lowercase());
Entry { id, shortcut, shown, haystack }
};
let mut out: Vec<Entry> = items()
.into_iter()
.map(|(label, id, shortcut)| {
let shown = shown_label(lang, &label);
entry(label, shown, id, shortcut)
})
.collect();
out.extend(crate::shortcut_editor::filter_items().iter().map(|f| {
let shortcut = crate::shortcut_editor::filter_shortcut(&f.id).unwrap_or("").to_string();
let label = format!("{} › {}", f.group, f.label);
let shown = format!("{} › {}", shown_label(lang, &f.group), f.label);
entry(label, shown, format!("plugin:{}", f.id), shortcut)
}));
out
}
/// Runs the palette result `id`: selects a tool, or invokes a command with the params its entry
/// stands for.
pub(crate) fn run_pick(app: &mut VectorcraftApp, id: &str) {
if let Some(tool) = id.strip_prefix("tool:") {
app.select_tool(tool);
} else if id.starts_with("plugin:") {
let command = crate::keymap::command_of(id);
menus::invoke(app, &command.id, command.params);
} else {
menus::invoke(app, id, json!({}));
}
}
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
if !app.ui.palette_open {
return;
@@ -137,11 +165,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
ctx.data_mut(|d| d.insert_temp(sel_id, (sel, q)));
if let Some(id) = run {
app.ui.palette_open = false;
if let Some(tool) = id.strip_prefix("tool:") {
app.select_tool(tool);
} else {
menus::invoke(app, &id, json!({}));
}
run_pick(app, &id);
}
}
@@ -264,4 +288,39 @@ mod tests {
app.ui.palette_query = "zoom in".into();
assert_eq!(frame(&mut app, vec![]), 0);
}
/// Each filter plug-in has one result, also when two share a name: its menu path translates,
/// the plug-in's name does not, and a pick runs that filter (a dialog for one with parameters,
/// nothing with no document open).
#[test]
fn each_filter_plugin_is_a_result_of_its_own() {
use vectorcraft_plugins::wat as tpl;
let (one, two) = ("test.UI.palette_filter-one", "test.UI.palette_filter-two");
let (key_one, key_two) = (format!("plugin:{one}"), format!("plugin:{two}"));
let install = |id: &str, params: &str| {
let manifest = format!(r#"{{"id":"{id}","name":"Group","version":"1","kind":"filter","params":{params}}}"#);
vectorcraft_plugins::registry::install_bytes(&wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap()).unwrap();
};
install(one, "{}");
install(two, r#"{"wiggle":{"type":"bool"}}"#);
let zh = Lang::from_code("zh-hant").unwrap();
let built = build(zh);
let of = |key: &str| built.iter().filter(|e| e.id == key).collect::<Vec<_>>();
assert_eq!((of(&key_one).len(), of(&key_two).len()), (1, 1));
assert!(built.iter().all(|e| e.id != "plugin.dialog"), "no result without a filter's id");
let path = format!("{} › {}", crate::i18n::tr(zh, "Object"), crate::i18n::tr(zh, "Plug-ins"));
assert_eq!(of(&key_two)[0].shown, format!("{path} › Group…"));
assert_eq!(of(&key_one)[0].shown, format!("{path} › Group"));
assert!(of(&key_two)[0].haystack.contains("object › plug-ins › group…"));
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
run_pick(&mut app, &key_two);
assert!(app.ui.dialog.is_none(), "no document: the dimmed item's pick does nothing");
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
run_pick(&mut app, &key_two);
let d = app.ui.dialog.take().expect("the filter's dialog opens");
assert_eq!((d.kind.as_str(), d.str("__plugin")), ("plugin", two.to_string()));
vectorcraft_plugins::registry::remove(one);
vectorcraft_plugins::registry::remove(two);
}
}
+329 -100
View File
@@ -65,13 +65,17 @@ pub(crate) use ui_mirror;
/// The keymap with the user's overrides, and the effective text of every entry they change.
pub(crate) struct Mirror {
pub keymap: Keymap,
source_overrides: Overrides,
text: HashMap<String, Option<&'static str>>,
/// The overrides this was built from, as the UI state holds them.
source: Overrides,
/// The plug-in revision of the entries the keymap holds ([`Snapshot::revision`]).
revision: u64,
}
impl Mirror {
fn new(overrides: Overrides) -> Mirror {
let keymap = crate::keymap::keymap_with(&overrides);
let snapshot = snapshot();
let keymap = crate::keymap::keymap_in(&snapshot, &overrides);
let text = overrides
.iter()
.map(|(k, _)| {
@@ -80,20 +84,41 @@ impl Mirror {
(key, t)
})
.collect();
Mirror { keymap, source_overrides: overrides, text }
Mirror { keymap, text, source: overrides, revision: snapshot.revision }
}
/// Was this built from `overrides` for the platform and the plug-ins in effect now?
fn is_for(&self, overrides: &Overrides) -> bool {
self.source == *overrides && self.revision == menus::plugin_revision() && self.keymap.platform() == crate::keymap::platform()
}
}
ui_mirror!(fn store() -> Option<Arc<Mirror>>);
/// The mirror: the keymap in effect.
/// The mirror: the keymap in effect. One the plug-ins installed or removed since it was built
/// left behind is built again, with the overrides it was built from.
pub(crate) fn current() -> Arc<Mirror> {
static DEFAULTS: [OnceLock<Arc<Mirror>>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()];
store()
.read()
.ok()
.and_then(|m| m.clone())
.unwrap_or_else(|| crate::keymap::per_platform(&DEFAULTS, crate::keymap::platform(), || Arc::new(Mirror::new(Overrides::new()))).clone())
static DEFAULT: Mutex<Option<Arc<Mirror>>> = Mutex::new(None);
if let Some(m) = store().read().ok().and_then(|m| m.clone()) {
if m.is_for(&m.source) {
return m;
}
let fresh = Arc::new(Mirror::new(m.source.clone()));
if let Ok(mut s) = store().write()
&& s.as_ref().is_some_and(|s| Arc::ptr_eq(s, &m))
{
*s = Some(fresh.clone());
}
return fresh;
}
let none = Overrides::new();
let cached = DEFAULT.lock().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(m) = cached.filter(|m| m.is_for(&none)) {
return m;
}
let fresh = Arc::new(Mirror::new(none));
*DEFAULT.lock().unwrap_or_else(|e| e.into_inner()) = Some(fresh.clone());
fresh
}
/// Leak-once interning for override strings (bounded by the chords a user ever assigns).
@@ -113,7 +138,7 @@ pub fn sync(ui: &UiState) {
let same = store()
.read()
.map(|m| match &*m {
Some(m) => m.source_overrides == ui.shortcut_overrides && m.keymap.platform() == crate::keymap::platform(),
Some(m) => m.is_for(&ui.shortcut_overrides),
None => ui.shortcut_overrides.is_empty(),
})
.unwrap_or(false);
@@ -334,9 +359,54 @@ pub struct Entry {
pub is_tool: bool,
}
/// The editor's entries, the Effect menu's effect items and Object › Plug-ins' filter items while
/// the installed plug-ins are those of one [`menus::plugin_revision`].
pub(crate) struct Snapshot {
/// The plug-in revision read before the menus were walked.
pub revision: u64,
pub effects: Arc<[EffectItem]>,
pub filters: Arc<[FilterItem]>,
pub entries: Arc<[Entry]>,
}
/// The entries for the plug-ins installed now: rebuilt when a plug-in is installed or removed, so
/// a live-effect or filter plug-in installed later has its row, and one removed has none.
pub(crate) fn snapshot() -> Arc<Snapshot> {
static LAST: Mutex<Option<Arc<Snapshot>>> = Mutex::new(None);
let revision = menus::plugin_revision();
let last = LAST.lock().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(s) = last.filter(|s| s.revision == revision) {
return s;
}
// Built without the lock held: the Effect menu is walked anew.
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
.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(tail.iter().cloned())
.collect();
let s = Arc::new(Snapshot { revision, effects, filters, entries });
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
// A newer snapshot another thread stored meanwhile stays.
if last.as_ref().is_none_or(|l| l.revision <= revision) {
*last = Some(s.clone());
}
s
}
/// Every tool, command and panel the editor can give a shortcut; [`listed`] says which it lists.
pub fn entries() -> &'static [Entry] {
static E: OnceLock<Vec<Entry>> = OnceLock::new();
pub fn entries() -> Arc<[Entry]> {
snapshot().entries.clone()
}
/// The entries no plug-in changes: (tools, commands and panels; UI commands). The Effect menu's
/// items and Object › Plug-ins' filters go between them.
fn fixed_entries() -> &'static (Vec<Entry>, Vec<Entry>) {
static E: OnceLock<(Vec<Entry>, Vec<Entry>)> = OnceLock::new();
E.get_or_init(|| {
let mut v: Vec<Entry> = vectorcraft_tools::catalog::all_tools()
.map(|t| Entry { key: format!("tool:{}", t.id), label: t.label.to_string(), group: "Tools".into(), is_tool: true })
@@ -362,22 +432,17 @@ pub fn entries() -> &'static [Entry] {
group: "Window".into(),
is_tool: false,
}));
// Each effect of the Effect menu, by its own key: they all run `effect.dialog` (#3296).
v.extend(effect_items().iter().map(|e| Entry {
key: format!("effect:{}", e.effect),
label: e.label.clone(),
group: e.group.clone(),
is_tool: false,
}));
// Each effect of the Effect menu comes next, by its own key (#3296): see [`snapshot`].
let mut tail = vec![];
for (id, label, sc, params) in menus::UI_COMMANDS {
// Rename has no default key, and a key press names the selection: it needs no `id`.
let key_runs_it = !sc.is_empty() || params.starts_with("{}") || params.starts_with("{path") || *id == "ui.rename";
if !seen.insert(id) || !key_runs_it {
continue;
}
v.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 });
}
v
(v, tail)
})
}
@@ -391,38 +456,41 @@ pub struct EffectItem {
pub group: String,
}
/// The Effect menu's effect items, in menu order, each once.
pub fn effect_items() -> &'static [EffectItem] {
static E: OnceLock<Vec<EffectItem>> = OnceLock::new();
E.get_or_init(|| {
fn walk(items: &[menus::Item], path: &mut Vec<String>, seen: &mut HashSet<String>, out: &mut Vec<EffectItem>) {
for it in items {
match it {
menus::Item::Cmd(label, id @ ("effect.dialog" | "effect.apply"), p) => {
if let Some(effect) = p.get("effect").and_then(serde_json::Value::as_str)
&& seen.insert(effect.to_string())
{
out.push(EffectItem { effect: effect.to_string(), command: id, label: label.to_string(), group: path.join(" › ") });
}
/// The Effect menu's effect items, in menu order, each once, with the live-effect plug-ins
/// installed now.
pub fn effect_items() -> Arc<[EffectItem]> {
snapshot().effects.clone()
}
/// Walks the Effect menu for [`effect_items`].
fn build_effect_items() -> Vec<EffectItem> {
fn walk(items: &[menus::Item], path: &mut Vec<String>, seen: &mut HashSet<String>, out: &mut Vec<EffectItem>) {
for it in items {
match it {
menus::Item::Cmd(label, id @ ("effect.dialog" | "effect.apply"), p) => {
if let Some(effect) = p.get("effect").and_then(serde_json::Value::as_str)
&& seen.insert(effect.to_string())
{
out.push(EffectItem { effect: effect.to_string(), command: id, label: label.to_string(), group: path.join(" › ") });
}
menus::Item::Sub(name, children) => {
path.push(name.to_string());
walk(children, path, seen, out);
path.pop();
}
_ => {}
}
menus::Item::Sub(name, children) => {
path.push(name.to_string());
walk(children, path, seen, out);
path.pop();
}
_ => {}
}
}
let mut out = vec![];
let mut seen = HashSet::new();
for (name, items) in menus::menu_tree() {
if name == "Effect" {
walk(&items, &mut vec![name.to_string()], &mut seen, &mut out);
}
}
let mut out = vec![];
let mut seen = HashSet::new();
for (name, items) in menus::menu_tree() {
if name == "Effect" {
walk(&items, &mut vec![name.to_string()], &mut seen, &mut out);
}
out
})
}
out
}
/// Effective shortcut of the Effect menu item of `effect` (`effect:<id>`).
@@ -430,6 +498,74 @@ pub fn effect_shortcut(effect: &str) -> Option<&'static str> {
effective(&format!("effect:{effect}"))
}
/// One filter plug-in's Object › Plug-ins item (`plugin.dialog {id}`) a shortcut can be given: the
/// plug-in's id, its label (the plug-in's name, with an ellipsis when it opens a dialog) and its
/// menu path.
#[derive(Clone, Debug)]
pub struct FilterItem {
pub id: String,
pub label: String,
pub group: String,
}
/// Object › Plug-ins' filter items, in menu order, each once, with the filter plug-ins installed
/// now.
pub fn filter_items() -> Arc<[FilterItem]> {
snapshot().filters.clone()
}
/// Walks the Object menu for [`filter_items`].
fn build_filter_items() -> Vec<FilterItem> {
fn walk(items: &[menus::Item], path: &mut Vec<String>, seen: &mut HashSet<String>, out: &mut Vec<FilterItem>) {
for it in items {
match it {
menus::Item::Cmd(label, "plugin.dialog", p) => {
if let Some(id) = p.get("id").and_then(serde_json::Value::as_str)
&& seen.insert(id.to_string())
{
out.push(FilterItem { id: id.to_string(), label: label.to_string(), group: path.join(" › ") });
}
}
menus::Item::Sub(name, children) => {
path.push(name.to_string());
walk(children, path, seen, out);
path.pop();
}
_ => {}
}
}
}
let mut out = vec![];
let mut seen = HashSet::new();
for (name, items) in menus::menu_tree() {
if name == "Object" {
walk(&items, &mut vec![name.to_string()], &mut seen, &mut out);
}
}
out
}
/// Effective shortcut of filter plug-in `id`'s Object › Plug-ins item (`plugin:<id>`).
pub fn filter_shortcut(id: &str) -> Option<&'static str> {
effective(&format!("plugin:{id}"))
}
/// Entry `e`'s label in the UI language. A plug-in's own name is not translated.
pub(crate) fn shown_label(e: &Entry) -> &str {
let plugin = e.key.starts_with("plugin:") || e.key.strip_prefix("effect:").and_then(vectorcraft_plugins::effect::plugin_id).is_some();
if plugin { &e.label } else { tl!(&e.label) }
}
/// A menu path in the UI language, one segment at a time.
pub(crate) fn shown_group(group: &str) -> String {
group.split(" › ").map(|s| tl!(s)).collect::<Vec<_>>().join(" › ")
}
/// How Keyboard Shortcuts names entry `e`: a tool by its label, the others after their menu.
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)) }
}
/// The group of a command no menu holds: the first part of its id, capitalised (`edit.x` → "Edit").
fn id_group(id: &str) -> String {
let mut c = id.split('.').next().unwrap_or_default().chars();
@@ -447,7 +583,7 @@ fn runs_without_params(params: &str) -> bool {
/// Does the editor list `e` under `overrides`? An entry that [`needs_a_value`] is listed only while
/// the user has given it a shortcut.
fn listed(overrides: &Overrides, e: &Entry) -> bool {
!needs_a_value(&e.key) || overrides.get(&crate::keymap::stored_key(&e.key)).is_some_and(|t| !t.is_empty())
!needs_a_value(&e.key) || crate::keymap::stored_texts(overrides, &e.key).is_some_and(|t| !t.is_empty())
}
/// Commands whose params are all optional that do nothing, or fail, when a key press runs them
@@ -583,17 +719,19 @@ fn names_a_required_field(params: &str) -> bool {
false
}
pub fn entry(key: &str) -> Option<&'static Entry> {
entries().iter().find(|e| e.key == key)
pub fn entry(key: &str) -> Option<Entry> {
entries().iter().find(|e| e.key == key).cloned()
}
/// Entries (other than `except`) whose effective shortcut equals `chord`.
pub fn conflicts_with(overrides: &Overrides, chord: &str, except: &str) -> Vec<String> {
let Some(c) = vc_chord(chord) else { return vec![] };
let km = keymap_with(overrides);
let snapshot = snapshot();
let km = crate::keymap::keymap_in(&snapshot, overrides);
let p = km.platform();
let want = Trigger::single(c).effective(p);
entries()
snapshot
.entries
.iter()
.filter(|e| e.key != except)
.filter(|e| km.bindings(&command_of(&e.key)).iter().any(|b| b.trigger.effective(p) == want))
@@ -699,7 +837,7 @@ pub fn assign_in(km: &mut Keymap, key: &str, chord: Option<&str>, force: bool) -
}
pub fn reset_one(overrides: &mut Overrides, key: &str) {
overrides.remove(&crate::keymap::stored_key(key));
crate::keymap::remove_override(overrides, key);
}
/// Text shown for all active chords in a dialog row, with the backtick key shown as `typed`
@@ -742,14 +880,15 @@ pub fn import_json(v: &Value) -> Result<(String, Overrides), String> {
/// row per line with the name and the shortcuts as the dialog shows them, separated by a tab. A
/// row with no shortcut ends after the tab.
pub fn export_text(set: &str, overrides: &Overrides) -> String {
let km = keymap_with(overrides);
let snapshot = snapshot();
let km = crate::keymap::keymap_in(&snapshot, overrides);
let backtick = backtick_text();
let shown_set = if PRESETS.contains(&set) || set == CUSTOM { tl!(set) } else { set };
let mut out = format!("{}\n{} {shown_set}\n", tl!("Keyboard Shortcuts"), tl!("Set:"));
for (tools, heading) in [(true, "Tools"), (false, "Menu Commands")] {
out += &format!("\n{}\n", tl!(heading));
for e in entries().iter().filter(|e| e.is_tool == tools && listed(km.overrides(), e)) {
let name = if e.is_tool || e.group.is_empty() { tl!(&e.label).to_string() } else { format!("{} › {}", tl!(&e.group), tl!(&e.label)) };
for e in snapshot.entries.iter().filter(|e| e.is_tool == tools && listed(km.overrides(), e)) {
let name = shown_name(e);
let sc = dialog_shortcuts_with(&km, &e.key, backtick.as_deref()).unwrap_or_default();
out += &format!("{name}\t{sc}\n");
}
@@ -823,10 +962,12 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
Ok(q) => q.unwrap_or_default().to_lowercase(),
Err(e) => return Some(Err(e)),
};
let km = keymap_with(&app.ui.shortcut_overrides);
let ov = &app.ui.shortcut_overrides;
let snapshot = snapshot();
let km = crate::keymap::keymap_in(&snapshot, &app.ui.shortcut_overrides);
let ov = &crate::keymap::effect_keys_in(&app.ui.shortcut_overrides, &snapshot.effects);
Ok(Value::Array(
entries()
snapshot
.entries
.iter()
.filter(|e| listed(ov, e))
.filter(|e| q.is_empty() || e.label.to_lowercase().contains(&q) || e.key.to_lowercase().contains(&q))
@@ -960,13 +1101,17 @@ pub fn is_recording(app: &VectorcraftApp) -> bool {
// ---------- dialog ----------
/// The dialog's working set as a keymap, with each entry's shortcut text (in [`entries`] order)
/// and the number of conflicts, kept between frames while the set is unchanged.
/// The dialog's working set as a keymap, with the entries it lists, each entry's shortcut text (in
/// their order) and the number of conflicts, kept between frames while the set and the installed
/// plug-ins are unchanged.
struct DialogView {
/// The dialog's `overrides` field this was built from.
source: Value,
/// The [`backtick_text`] the texts show.
backtick: Option<String>,
/// The plug-in revision of `entries`.
revision: u64,
entries: Arc<[Entry]>,
keymap: Keymap,
texts: Vec<Option<String>>,
conflicts: usize,
@@ -974,20 +1119,32 @@ struct DialogView {
impl DialogView {
fn new(overrides: Overrides) -> DialogView {
let keymap = keymap_with(&overrides);
let snapshot = snapshot();
let keymap = crate::keymap::keymap_in(&snapshot, &overrides);
let backtick = backtick_text();
let texts = entries().iter().map(|e| dialog_shortcuts_with(&keymap, &e.key, backtick.as_deref())).collect();
let texts = snapshot.entries.iter().map(|e| dialog_shortcuts_with(&keymap, &e.key, backtick.as_deref())).collect();
let conflicts = conflicts_in(&keymap).len();
DialogView { source: stored::to_value(&overrides), backtick, keymap, texts, conflicts }
DialogView {
source: stored::to_value(&crate::keymap::effect_keys_in(&overrides, &snapshot.effects)),
backtick,
revision: snapshot.revision,
entries: snapshot.entries.clone(),
keymap,
texts,
conflicts,
}
}
/// The view of `d`'s working set, from the cache unless the set or the backtick key's
/// character changed. The dialog's `overrides` field is left in its stored form.
/// The view of `d`'s working set, from the cache unless the set, the backtick key's character
/// or the installed plug-ins changed. The dialog's `overrides` field is left in its stored form.
fn of(ctx: &egui::Context, d: &mut Dialog) -> Arc<DialogView> {
let id = egui::Id::unique("dialog-shortcuts-view");
let source = d.fields.get("overrides").unwrap_or(&Value::Null);
let backtick = backtick_text();
if let Some(v) = ctx.data(|m| m.get_temp::<Arc<DialogView>>(id)).filter(|v| v.source == *source && v.backtick == backtick) {
let revision = menus::plugin_revision();
if let Some(v) =
ctx.data(|m| m.get_temp::<Arc<DialogView>>(id)).filter(|v| v.source == *source && v.backtick == backtick && v.revision == revision)
{
return v;
}
let v = Arc::new(DialogView::new(dialog_overrides(d)));
@@ -1096,25 +1253,21 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
ui.label(egui::RichText::new(tl!("Shortcut")).color(t.text_dim).size(11.0));
});
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, false]).show(ui, |ui| {
for (i, e) in entries().iter().enumerate().filter(|(_, e)| e.is_tool == tools && listed(km.overrides(), e)) {
for (i, e) in view.entries.iter().enumerate().filter(|(_, e)| e.is_tool == tools && listed(km.overrides(), e)) {
let sc = match &km {
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)
|| tl!(&e.label).to_lowercase().contains(&q)
|| shown_label(e).to_lowercase().contains(&q)
|| e.group.to_lowercase().contains(&q)
|| tl!(&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 {
continue;
}
let row = ui.horizontal(|ui| {
let name = if e.is_tool || e.group.is_empty() {
tl!(&e.label).to_string()
} else {
format!("{} › {}", tl!(&e.group), tl!(&e.label))
};
let name = shown_name(e);
let r = ui
.allocate_ui_with_layout(egui::vec2(360.0, 20.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(360.0);
@@ -1282,7 +1435,7 @@ fn export_dialog_set(app: &mut VectorcraftApp, command: &str) {
fn drop_hidden(d: &mut Dialog, overrides: &Overrides) {
for field in ["__recording", "__selected", "__conflict"] {
let key = d.str(field);
if !key.is_empty() && entry(&key).is_none_or(|e| !listed(overrides, e)) {
if !key.is_empty() && entry(&key).is_none_or(|e| !listed(overrides, &e)) {
d.fields.insert(field.into(), json!(""));
}
}
@@ -1322,9 +1475,23 @@ fn record(d: &mut Dialog, km: &mut Keymap, key: &str, chord: &str) {
mod tests {
use super::*;
/// Runs `f` again until no plug-in was installed or removed while it ran, and returns what it
/// returned. Other tests install and remove plug-ins at any time, and each changes the Effect
/// menu's rows.
fn with_steady_plugins<T>(mut f: impl FnMut() -> T) -> T {
for _ in 0..100 {
let revision = menus::plugin_revision();
let r = f();
if menus::plugin_revision() == revision {
return r;
}
}
panic!("the installed plug-ins kept changing");
}
#[test]
fn format_parse_round_trip_for_every_default() {
for e in entries() {
for e in entries().iter() {
if let Some(s) = default_of(&e.key)
&& let Some(sc) = crate::shortcuts::parse(s)
{
@@ -1428,7 +1595,6 @@ mod tests {
open(&mut app);
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let at = entries().iter().position(|e| e.key == "edit.clear").unwrap();
let press = |key| egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE };
let text = |t: &str| egui::Event::Text(t.into());
let mut record_with = |events: Vec<egui::Event>| {
@@ -1436,7 +1602,9 @@ mod tests {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(&mut app, ui.ctx()));
out.textures_delta.clear();
let d = app.ui.dialog.clone().unwrap();
let shown = DialogView::new(dialog_overrides(&d)).texts[at].clone();
let view = DialogView::new(dialog_overrides(&d));
let at = view.entries.iter().position(|e| e.key == "edit.clear").unwrap();
let shown = view.texts[at].clone();
(crate::keymap::override_text(&dialog_overrides(&d), "edit.clear"), shown)
};
assert_eq!(record_with(vec![press(Key::Backtick), text("\"")]), (Some("~".to_string()), Some("\"".to_string())));
@@ -1590,18 +1758,26 @@ mod tests {
fn export_text_lists_the_dialogs_rows_with_their_shortcuts() {
let mut ov = Overrides::new();
crate::keymap::set_override(&mut ov, "edit.preferences", "Ctrl+Alt+Shift+9");
let text = export_text("VectorCraft Defaults", &ov);
let (text, km, tool_rows, menu_rows) = with_steady_plugins(|| {
let text = export_text("VectorCraft Defaults", &ov);
let km = keymap_with(&ov);
let names = |tools: bool| -> Vec<String> {
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()
};
let rows = (names(true), names(false));
(text, km, rows.0, rows.1)
});
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines[..4], ["Keyboard Shortcuts", "Set: VectorCraft Defaults", "", "Tools"]);
let menu_at = lines.iter().position(|l| *l == "Menu Commands").expect("a Menu Commands section");
assert_eq!(lines[menu_at - 1], "", "a blank line before Menu Commands");
let km = keymap_with(&ov);
let names = |tools: bool| -> Vec<String> {
entries().iter().filter(|e| e.is_tool == tools && listed(km.overrides(), e)).map(|e| text_row(&e.key, "")).collect()
};
let names_of = |rows: &[&str]| -> Vec<String> { rows.iter().map(|l| format!("{}\t", l.split('\t').next().unwrap_or_default())).collect() };
assert_eq!(names_of(&lines[4..menu_at - 1]), names(true), "the Tools rows, in the dialog's order");
assert_eq!(names_of(&lines[menu_at + 1..]), names(false), "the Menu Commands rows, in the dialog's order");
assert_eq!(names_of(&lines[4..menu_at - 1]), tool_rows, "the Tools rows, in the dialog's order");
assert_eq!(names_of(&lines[menu_at + 1..]), menu_rows, "the Menu Commands rows, in the dialog's order");
let pen = menus::pretty_shortcut(default_of("tool:pen").expect("the Pen tool has a default key"));
assert!(lines[4..menu_at].contains(&text_row("tool:pen", &pen).as_str()), "{text}");
let prefs = menus::pretty_shortcut(&effective_with(&km, "edit.preferences").expect("the shortcut set"));
@@ -1641,7 +1817,11 @@ mod tests {
Ok(())
}));
app.run("shortcuts.import", json!({"data": export_json("Studio", &Overrides::new())})).unwrap();
assert_eq!(app.run("shortcuts.exportText", json!({"path": "/sets/Studio.txt"})), Ok(json!({"path": "/sets/Studio.txt"})));
let expected = with_steady_plugins(|| {
written.borrow_mut().clear();
assert_eq!(app.run("shortcuts.exportText", json!({"path": "/sets/Studio.txt"})), Ok(json!({"path": "/sets/Studio.txt"})));
export_text("Studio", &Overrides::new())
});
assert_eq!(
app.run("shortcuts.exportText", json!({"path": "/missing/dir/Studio.txt"})),
Err("can't write /missing/dir/Studio.txt".to_string())
@@ -1651,7 +1831,7 @@ mod tests {
let [(path, text)] = files.as_slice() else { panic!("{files:?}") };
assert_eq!(path, "/sets/Studio.txt");
assert!(text.starts_with("Keyboard Shortcuts\nSet: Studio\n\nTools\n"), "{text}");
assert_eq!(*text, export_text("Studio", &Overrides::new()));
assert_eq!(*text, expected);
}
/// The dialog's Import… leaves an action its function key when the imported set gives the key
@@ -1695,7 +1875,7 @@ mod tests {
fn paint_dialog_rows_show_both_default_keys() {
let view = DialogView::new(Overrides::new());
for (key, plain, shifted) in [("paint.lastColor", ",", "Shift+Comma"), ("paint.lastGradient", ".", "Shift+Period")] {
let at = entries().iter().position(|e| e.key == key).unwrap();
let at = view.entries.iter().position(|e| e.key == key).unwrap();
let shown = view.texts[at].as_deref().unwrap();
let shifted = menus::pretty_shortcut(shifted);
assert!(shown.contains(plain) && shown.contains(&shifted), "{key}: {shown}");
@@ -1706,7 +1886,7 @@ mod tests {
#[test]
fn edit_dialog_rows_show_primary_and_function_keys() {
for p in [craft_keymap::Platform::Windows, craft_keymap::Platform::Linux, craft_keymap::Platform::Mac] {
let km = crate::keymap::keymap_for(p);
let km = crate::keymap::keymap_for(p, &snapshot());
for (key, primary, alternate) in [("edit.cut", "Cmd+X", "F2"), ("edit.copy", "Cmd+C", "F3"), ("edit.paste", "Cmd+V", "F4")] {
let shown = dialog_shortcuts_with(&km, key, None).unwrap();
let primary = menus::pretty_shortcut(primary);
@@ -1835,7 +2015,7 @@ mod tests {
#[test]
fn duplicate_can_be_given_a_shortcut() {
// Duplicate has no default chord (#1539): it must still be listed so one can be set.
assert_eq!(entry("edit.duplicate").map(|e| e.group.as_str()), Some("Edit"));
assert_eq!(entry("edit.duplicate").as_ref().map(|e| e.group.as_str()), Some("Edit"));
let mut ov = Overrides::new();
assign(&mut ov, "edit.duplicate", Some("Cmd+Alt+Shift+F8"), false).unwrap();
assert_eq!(effective_in(&ov, "edit.duplicate").as_deref(), Some("Cmd+Alt+Shift+F8"));
@@ -1883,6 +2063,53 @@ mod tests {
.collect()
}
/// An open dialog lists a live-effect plug-in installed while it is open, with the shortcut its
/// working set gives it, and drops the row while the plug-in is removed, keeping that shortcut
/// in the working set.
#[test]
fn an_open_dialog_follows_live_effect_plugins() {
use vectorcraft_plugins::wat as tpl;
let (plugin, key) = ("test.ui.openfx", "effect:plugin.test.ui.openfx");
let wasm = wat::parse_str(tpl::module(&tpl::manifest(plugin, "effect", r#"{"wiggle":{"type":"bool"}}"#), tpl::ECHO, "", 1)).unwrap();
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
open(&mut app);
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("tab".into(), json!("menu"));
d.fields.insert("query".into(), json!("test.ui.openfx"));
let mut working = Overrides::new();
crate::keymap::set_override(&mut working, key, "Cmd+Alt+Shift+F5");
d.fields.insert("overrides".into(), stored::to_value(&working));
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let texts = |app: &mut VectorcraftApp| {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui.ctx()));
out.textures_delta.clear();
out.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Text(t) => Some(t.galley.text().to_string()),
_ => None,
})
.collect::<Vec<_>>()
};
let row = "Effect › Plug-ins › Test test.ui.openfx…";
let shortcut = menus::pretty_shortcut("Cmd+Alt+Shift+F5");
// The modal draws nothing in its first frame, which only sizes it.
texts(&mut app);
assert!(!texts(&mut app).iter().any(|t| t == row));
vectorcraft_plugins::registry::install_bytes(&wasm).unwrap();
let shown = texts(&mut app);
assert!(shown.iter().any(|t| t == row) && shown.contains(&shortcut), "{shown:?}");
vectorcraft_plugins::registry::remove(plugin);
assert!(!texts(&mut app).iter().any(|t| t == row));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(crate::keymap::override_text(&dialog_overrides(d), key).as_deref(), Some("Cmd+Alt+Shift+F5"));
vectorcraft_plugins::registry::install_bytes(&wasm).unwrap();
let shown = texts(&mut app);
assert!(shown.iter().any(|t| t == row) && shown.contains(&shortcut), "{shown:?}");
vectorcraft_plugins::registry::remove(plugin);
}
/// A menu command whose params need a value (Units needs `{units}`) is not listed while it has
/// no shortcut. A shortcut the user gave it keeps it listed, where it can be changed or cleared.
#[test]
@@ -1908,10 +2135,11 @@ mod tests {
// whose params are all optional, one the menu bar runs without params (Rotate… opens its
// dialog) and one that opens a dialog for its values (New Variable).
let none = Overrides::new();
let dropped: Vec<&str> = entries().iter().filter(|e| default_of(&e.key).is_some() && !listed(&none, e)).map(|e| e.key.as_str()).collect();
let all = entries();
let dropped: Vec<&str> = all.iter().filter(|e| default_of(&e.key).is_some() && !listed(&none, e)).map(|e| e.key.as_str()).collect();
assert!(dropped.is_empty(), "{dropped:?}");
for id in ["edit.duplicate", "object.pathfinder.unite", "object.rotate", "variable.define", "object.distributeSpacing"] {
assert!(entry(id).is_some_and(|e| listed(&none, e)), "{id}");
assert!(entry(id).is_some_and(|e| listed(&none, &e)), "{id}");
}
for (doc, required) in [
("{}", false),
@@ -1940,12 +2168,12 @@ mod tests {
let others = ["swatch.unused", "graphicStyle.unused", "document.info", "view.proofSetup", "magicWand.set", "edit.findNext"];
for id in NEEDS_A_PARAM.iter().chain(&others) {
let e = entry(id).unwrap_or_else(|| panic!("{id} has an entry"));
assert!(!listed(&none, e), "{id}");
assert!(!listed(&none, &e), "{id}");
assert_eq!(default_of(id), None, "{id}");
}
let mut ov = Overrides::new();
crate::keymap::set_override(&mut ov, "transparency.set", "Cmd+Alt+Shift+F10");
assert!(entry("transparency.set").is_some_and(|e| listed(&ov, e)));
assert!(entry("transparency.set").is_some_and(|e| listed(&ov, &e)));
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
for id in ["transparency.set", "transparency.setOpacityMask"] {
let list = run_command(&mut app, "shortcuts.list", &json!({ "query": id })).unwrap().unwrap();
@@ -1960,7 +2188,7 @@ mod tests {
"addon.color.printFixes",
"edit.findReplace",
] {
assert!(entry(id).is_some_and(|e| listed(&none, e)), "{id}");
assert!(entry(id).is_some_and(|e| listed(&none, &e)), "{id}");
}
assert_eq!(menu_target("object.path.cleanUp").map(|(id, _)| id).as_deref(), Some("ui.paramDialog"));
assert_eq!(
@@ -2029,8 +2257,9 @@ mod tests {
assert!(app.ui.shortcut_overrides.is_empty(), "{id} {p}");
assert_eq!(app.ui.shortcut_set, set, "{id} {p}");
}
let all = app.run("shortcuts.list", json!({})).unwrap();
assert_eq!(app.run("shortcuts.list", json!({"query": null})), Ok(all));
let (all, null_query) =
with_steady_plugins(|| (app.run("shortcuts.list", json!({})).unwrap(), app.run("shortcuts.list", json!({"query": null}))));
assert_eq!(null_query, Ok(all));
let r = app.run("shortcuts.set", json!({"id": "object.isolate", "shortcut": "Cmd+Alt+Shift+F11", "force": null})).unwrap();
assert_eq!(r["shortcut"], "Cmd+Alt+Shift+F11");
assert!(app.run("shortcuts.preset", json!({"name": null})).unwrap_err().starts_with("unknown preset"));
+7 -1
View File
@@ -116,11 +116,17 @@ pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json
}
// Effect menu items with shortcuts of their own (#3296): a chord bound to one is a command's
// too, so the tool doesn't take Cmd+Enter bound to an effect before the keymap runs it.
for e in crate::shortcut_editor::effect_items() {
for e in crate::shortcut_editor::effect_items().iter() {
if let Some(sc) = crate::shortcut_editor::effect_shortcut(&e.effect).and_then(parse) {
v.push((sc, e.command, json!({ "effect": e.effect })));
}
}
// Object › Plug-ins' filter items with shortcuts, for the same reason.
for f in crate::shortcut_editor::filter_items().iter() {
if let Some(sc) = crate::shortcut_editor::filter_shortcut(&f.id).and_then(parse) {
v.push((sc, "plugin.dialog", json!({ "id": f.id })));
}
}
// Most specific (most modifiers) first: `paste_command` takes the first V chord the held keys
// match logically, so Cmd+Shift+V isn't taken by Cmd+V.
v.sort_by_key(|(sc, ..)| {
+229 -2
View File
@@ -557,7 +557,7 @@ fn cmd_alt_k_and_cmd_alt_shift_f_highlight_the_character_fields() {
assert!(app.ui.dialog.is_none(), "not Preferences");
// Keyboard Shortcuts lists all three under Type; Highlight Tracking has no default key.
for id in ["type.highlightFontName", "type.highlightKerning", "type.highlightTracking"] {
assert_eq!(crate::shortcut_editor::entry(id).map(|e| e.group.as_str()), Some("Type"), "{id}");
assert_eq!(crate::shortcut_editor::entry(id).as_ref().map(|e| e.group.as_str()), Some("Type"), "{id}");
}
assert_eq!(crate::menus::shortcut_of("type.highlightTracking"), None);
}
@@ -2419,7 +2419,7 @@ fn all_registered_shortcuts_parse() {
/// dialog for the selection.
#[test]
fn an_effect_menu_item_takes_a_shortcut_of_its_own() {
let entry = crate::shortcut_editor::entries().iter().find(|e| e.key == "effect:distort.transform").expect("Transform is listed");
let entry = crate::shortcut_editor::entry("effect:distort.transform").expect("Transform is listed");
assert_eq!(entry.group, "Effect › Distort & Transform");
assert_eq!(crate::keymap::command_of("effect:distort.transform").id, "effect.dialog");
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
@@ -2460,6 +2460,233 @@ fn cmd_enter_bound_to_an_effect_runs_the_effect_not_the_tool() {
crate::shortcut_editor::sync(&crate::state::UiState::default());
}
/// A live-effect plug-in installed after Keyboard Shortcuts built its list has a row of its own:
/// it takes a shortcut, `shortcuts.list` and its Effect › Plug-ins item show it, and the key
/// applies the effect. While the plug-in is removed it has no row and no key is bound to it; the
/// saved shortcut is the effect's again when the plug-in comes back, now opening the dialog of the
/// options it has.
#[test]
fn a_live_effect_plugin_installed_later_takes_a_shortcut_of_its_own() {
use vectorcraft_plugins::wat as tpl;
let (plugin, effect, key) = ("test.ui.keyedfx", "plugin.test.ui.keyedfx", "effect:plugin.test.ui.keyedfx");
let install = |params: &str| {
let src = tpl::module(&tpl::manifest(plugin, "effect", params), tpl::ECHO, "", 1);
vectorcraft_plugins::registry::install_bytes(&wat::parse_str(src).unwrap()).unwrap();
};
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
let node = vectorcraft_doc::NodeId(id.as_u64().unwrap());
let effects =
|app: &VectorcraftApp| app.session.doc().unwrap().doc.node(node).unwrap().appearance.effects.iter().filter(|e| e.id == effect).count();
let shown = |app: &mut VectorcraftApp| {
let list = crate::shortcut_editor::run_command(app, "shortcuts.list", &json!({ "query": effect })).unwrap().unwrap();
list.as_array().unwrap().iter().map(|e| (e["id"].as_str().unwrap_or_default().to_string(), e["shortcut"].clone())).collect::<Vec<_>>()
};
let chord = "Cmd+Alt+Shift+F6";
let f6 = || press(Key::F6, cmd() | Modifiers::ALT | Modifiers::SHIFT);
// The lists and the keymap in effect are built before the plug-in is installed.
assert!(crate::shortcut_editor::entry(key).is_none());
crate::shortcut_editor::sync(&app.ui);
assert!(crate::shortcuts::all_shortcuts().iter().all(|(_, _, p)| p["effect"] != effect));
install("{}");
let e = crate::shortcut_editor::entry(key).expect("listed once installed");
assert_eq!((e.label.as_str(), e.group.as_str()), ("Test test.ui.keyedfx", "Effect › Plug-ins"));
assert_eq!(crate::keymap::command_of(key).id, "effect.apply", "it has no options");
let set = crate::shortcut_editor::run_command(&mut app, "shortcuts.set", &json!({"id": key, "shortcut": chord})).unwrap().unwrap();
assert_eq!(set["shortcut"], chord);
assert_eq!(shown(&mut app), vec![(key.to_string(), json!(chord))]);
assert_eq!(crate::menus::item_shortcut("effect.apply", &json!({ "effect": effect })), Some(chord));
frame(&mut app, f6());
assert_eq!(effects(&app), 1, "the key applies the effect");
// Removed: no row, and no key is bound to the effect; the saved shortcut stays.
vectorcraft_plugins::registry::remove(plugin);
assert!(crate::shortcut_editor::entry(key).is_none());
assert_eq!(shown(&mut app), vec![]);
assert!(crate::shortcuts::all_shortcuts().iter().all(|(_, _, p)| p["effect"] != effect), "no key is bound to the removed effect");
assert!(crate::keymap::has_override(&app.ui.shortcut_overrides, key));
// Back, now with options: the saved shortcut opens its dialog.
install(r#"{"wiggle":{"type":"bool"}}"#);
assert_eq!(crate::keymap::command_of(key).id, "effect.dialog");
assert_eq!(shown(&mut app), vec![(key.to_string(), json!(chord))]);
assert_eq!(crate::menus::item_shortcut("effect.dialog", &json!({ "effect": effect })), Some(chord));
frame(&mut app, f6());
let d = app.ui.dialog.as_ref().expect("the effect's dialog opens");
assert_eq!(d.fields.get("__effect").and_then(serde_json::Value::as_str), Some(effect), "{:?}", d.kind);
vectorcraft_plugins::registry::remove(plugin);
crate::shortcut_editor::sync(&crate::state::UiState::default());
}
/// Each filter plug-in's Object › Plug-ins item has a row of its own (`plugin:<id>`), with no
/// default shortcut, even when two filters share a name: a key given to one shows in its menu item
/// and opens that filter's dialog, and the other stays unbound. While the plug-in is removed it
/// has no row and no key is bound to it; the saved shortcut is the filter's again when it comes
/// back, now running it at once since it has no parameters.
#[test]
fn each_filter_plugin_takes_a_shortcut_of_its_own() {
use serde_json::Value;
use vectorcraft_plugins::wat as tpl;
let (one, two) = ("test.UI.key_filter-one", "test.UI.key_filter-two");
let (key_one, key_two) = (format!("plugin:{one}"), format!("plugin:{two}"));
let install = |id: &str, name: &str, params: &str| {
let manifest = format!(r#"{{"id":"{id}","name":"{name}","version":"1","kind":"filter","params":{params}}}"#);
vectorcraft_plugins::registry::install_bytes(&wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap()).unwrap();
};
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
let shown = |app: &mut VectorcraftApp| {
let list = crate::shortcut_editor::run_command(app, "shortcuts.list", &json!({ "query": "test.ui.key_filter-" })).unwrap().unwrap();
list.as_array().unwrap().iter().map(|e| (e["id"].as_str().unwrap_or_default().to_string(), e["shortcut"].clone())).collect::<Vec<_>>()
};
let chord = "Cmd+Alt+Shift+F7";
let f7 = || press(Key::F7, cmd() | Modifiers::ALT | Modifiers::SHIFT);
crate::shortcut_editor::sync(&app.ui);
install(one, "Same Name", "{}");
install(two, "Same Name", r#"{"wiggle":{"type":"bool"}}"#);
let rows: Vec<_> = [&key_one, &key_two].iter().map(|k| crate::shortcut_editor::entry(k).map(|e| (e.label, e.group, e.is_tool))).collect();
let group = "Object › Plug-ins".to_string();
assert_eq!(rows, [Some(("Same Name".into(), group.clone(), false)), Some(("Same Name…".into(), group.clone(), false))]);
for key in [&key_one, &key_two] {
assert_eq!(crate::shortcut_editor::default_of(key), None, "no default shortcut");
let command = crate::keymap::command_of(key);
assert_eq!(command.id, "plugin.dialog");
assert_eq!(crate::keymap::entry_key(&command), *key, "the id is kept whole");
assert_eq!(crate::keymap::entry_of_stored(&crate::keymap::stored_key(key)), *key);
}
assert_eq!(shown(&mut app), vec![(key_one.clone(), Value::Null), (key_two.clone(), Value::Null)]);
let set = crate::shortcut_editor::run_command(&mut app, "shortcuts.set", &json!({"id": key_two, "shortcut": chord})).unwrap().unwrap();
assert_eq!(set["shortcut"], chord);
assert_eq!(shown(&mut app), vec![(key_one.clone(), Value::Null), (key_two.clone(), json!(chord))]);
assert_eq!(crate::menus::item_shortcut("plugin.dialog", &json!({ "id": two })), Some(chord));
assert_eq!(crate::menus::item_shortcut("plugin.dialog", &json!({ "id": one })), None);
let text = crate::shortcut_editor::export_text("Custom", &app.ui.shortcut_overrides);
let lines: Vec<&str> = text.lines().filter(|l| l.starts_with("Object › Plug-ins › Same Name")).collect();
assert_eq!(lines.len(), 2, "{text}");
assert_eq!(lines[0], "Object › Plug-ins › Same Name\t");
assert!(lines[1].starts_with("Object › Plug-ins › Same Name…\t") && lines[1].ends_with("+Alt+Shift+F7"), "{}", lines[1]);
// A chord an Effect menu item holds is a conflict.
crate::keymap::set_override(&mut app.ui.shortcut_overrides, "effect:distort.transform", "Cmd+Alt+Shift+F8");
let conflicts = crate::shortcut_editor::conflicts_with(&app.ui.shortcut_overrides, "Cmd+Alt+Shift+F8", &key_one);
assert_eq!(conflicts, vec!["effect:distort.transform".to_string()]);
crate::keymap::remove_override(&mut app.ui.shortcut_overrides, "effect:distort.transform");
frame(&mut app, f7());
let d = app.ui.dialog.take().expect("the filter's dialog opens");
assert_eq!((d.kind.as_str(), d.str("__plugin")), ("plugin", two.to_string()));
let bound = |app: &VectorcraftApp| {
crate::keymap::vc_chord(chord).and_then(|c| crate::keymap::lookup(&crate::keymap::keymap_with(&app.ui.shortcut_overrides), &c))
};
assert_eq!(bound(&app).map(|c| crate::keymap::entry_key(&c)), Some(key_two.clone()));
// Removed: no row, and no key is bound to the filter; the saved shortcut stays.
vectorcraft_plugins::registry::remove(two);
assert_eq!(bound(&app), None, "the key runs nothing");
assert!(crate::shortcut_editor::entry(&key_two).is_none());
assert_eq!(shown(&mut app), vec![(key_one.clone(), Value::Null)]);
assert!(crate::shortcuts::all_shortcuts().iter().all(|(_, _, p)| p["id"] != two), "no key is bound to the removed filter");
assert!(crate::keymap::has_override(&app.ui.shortcut_overrides, &key_two));
// Back under another name and without parameters: the saved shortcut runs it at once.
install(two, "Renamed", "{}");
assert_eq!(crate::shortcut_editor::entry(&key_two).map(|e| e.label), Some("Renamed".into()));
assert_eq!(shown(&mut app), vec![(key_one.clone(), Value::Null), (key_two.clone(), json!(chord))]);
let undo = app.session.doc().unwrap().history.undo.len();
frame(&mut app, f7());
assert!(app.ui.dialog.is_none(), "no dialog: {:?}", app.ui.dialog.as_ref().map(|d| &d.kind));
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "the filter ran as one undo step");
vectorcraft_plugins::registry::remove(one);
vectorcraft_plugins::registry::remove(two);
crate::shortcut_editor::sync(&crate::state::UiState::default());
}
/// A filter plug-in replaced under its id, or reinstalled from a folder by Reload Plug-ins, keeps
/// its shortcut, which runs the plug-in as it is now. With no document open the key does nothing,
/// as the dimmed menu item does.
#[test]
fn a_filter_plugin_keeps_its_shortcut_when_replaced_or_reloaded() {
use vectorcraft_plugins::wat as tpl;
let id = "test.UI.replaced_filter";
let key = format!("plugin:{id}");
let wasm = |name: &str, params: &str| {
let manifest = format!(r#"{{"id":"{id}","name":"{name}","version":"1","kind":"filter","params":{params}}}"#);
wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap()
};
let with_params = r#"{"wiggle":{"type":"bool"}}"#;
let chord = "Cmd+Alt+Shift+F6";
let f6 = || press(Key::F6, cmd() | Modifiers::ALT | Modifiers::SHIFT);
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
vectorcraft_plugins::registry::install_bytes(&wasm("Original", "{}")).unwrap();
crate::shortcut_editor::run_command(&mut app, "shortcuts.set", &json!({"id": key, "shortcut": chord})).unwrap().unwrap();
vectorcraft_plugins::registry::install_bytes(&wasm("Replaced", with_params)).unwrap();
assert_eq!(crate::shortcut_editor::entry(&key).map(|e| e.label), Some("Replaced…".into()));
assert_eq!(crate::menus::item_shortcut("plugin.dialog", &json!({ "id": id })), Some(chord));
frame(&mut app, f6());
assert_eq!(app.ui.dialog.take().expect("the replaced filter has parameters").str("__plugin"), id);
let folder = std::env::temp_dir().join(format!("vectorcraft-ui-filter-shortcut-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(folder.join("filter.wasm"), wasm("Reloaded", with_params)).unwrap();
app.run("plugin.reload", json!({ "path": folder.display().to_string() })).unwrap();
std::fs::remove_dir_all(&folder).ok();
assert_eq!(crate::shortcut_editor::entry(&key).map(|e| e.label), Some("Reloaded…".into()));
assert_eq!(crate::menus::item_shortcut("plugin.dialog", &json!({ "id": id })), Some(chord));
let mut empty = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
empty.ui.shortcut_overrides = app.ui.shortcut_overrides.clone();
frame(&mut empty, f6());
assert!(empty.ui.dialog.is_none(), "no document: no dialog");
vectorcraft_plugins::registry::remove(id);
crate::shortcut_editor::sync(&crate::state::UiState::default());
}
/// The key of a heavy filter does what a click on its item does: one without parameters starts in
/// the background, and one with parameters opens its dialog, which has no Preview.
#[test]
fn a_heavy_filter_plugin_runs_from_its_shortcut_as_from_its_menu_item() {
use vectorcraft_plugins::wat as tpl;
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let (at_once, asks) = ("test.UI.heavy_filter-at-once", "test.UI.heavy_filter-asks");
for (id, params, chord) in [(at_once, "{}", "Cmd+Alt+Shift+F7"), (asks, r#"{"wiggle":{"type":"bool"}}"#, "Cmd+Alt+Shift+F8")] {
let manifest = format!(r#"{{"id":"{id}","name":"Heavy {id}","version":"1","kind":"filter","budget":"heavy","params":{params}}}"#);
let wasm = wat::parse_str(tpl::spinning(&manifest, 1 << 40, r#"{"objects":[]}"#)).unwrap();
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "allowHeavy": true})).unwrap();
crate::shortcut_editor::run_command(&mut app, "shortcuts.set", &json!({"id": format!("plugin:{id}"), "shortcut": chord})).unwrap().unwrap();
}
frame(&mut app, press(Key::F7, cmd() | Modifiers::ALT | Modifiers::SHIFT));
assert_eq!(app.plugin_runs.running.len(), 1, "started in the background");
assert_eq!(app.ui.status, format!("Running “Heavy {at_once}”…"));
frame(&mut app, press(Key::F8, cmd() | Modifiers::ALT | Modifiers::SHIFT));
let d = app.ui.dialog.take().expect("the heavy filter with parameters opens its dialog");
assert_eq!(d.str("__plugin"), asks);
assert!(d.fields.get("preview").is_none(), "no Preview");
assert_eq!(app.run("plugin.cancelRuns", json!({})).unwrap()["cancelled"], 1);
vectorcraft_plugins::registry::remove(at_once);
vectorcraft_plugins::registry::remove(asks);
crate::shortcut_editor::sync(&crate::state::UiState::default());
}
/// Keyboard Shortcuts translates a menu path one segment at a time and the label of a built-in
/// item. A filter plug-in's name stays as the plug-in gives it, even when it reads like a command.
#[test]
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 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);
assert_ne!(path, "Object › Plug-ins");
assert_eq!(filter, format!("{path} › Group"));
assert_eq!(command, format!("{path} › {}", crate::i18n::tr(zh, "Group")));
}
/// #2898 review: Up and Down change the corner kind only while a corner widget is pressed. With
/// none pressed, a plain Up still nudges the selected rectangle and leaves its corners alone; with
/// one pressed, Up steps the kind and the rectangle stays where it is.
+6 -1
View File
@@ -417,6 +417,7 @@ fn keyboard_shortcuts_export_text_writes_the_working_set_as_text() {
Ok(())
}));
let ov = crate::keymap::overrides_from(&[("view.grid", "Ctrl+Alt+G")]);
let revision = crate::menus::plugin_revision();
app.run("edit.keyboardShortcuts", json!({})).unwrap();
let d = app.ui.dialog.as_mut().expect("Keyboard Shortcuts");
d.fields.insert("overrides".into(), crate::keymap::stored::to_value(&ov));
@@ -431,7 +432,11 @@ fn keyboard_shortcuts_export_text_writes_the_working_set_as_text() {
let texts = texts.borrow();
let [(path, text)] = texts.as_slice() else { panic!("{texts:?}") };
assert_eq!(path, "/keys/list.txt");
assert_eq!(*text, crate::shortcut_editor::export_text("Custom", &ov));
// Another test installing or removing a plug-in meanwhile changes the Effect menu's rows.
if crate::menus::plugin_revision() == revision {
assert_eq!(*text, crate::shortcut_editor::export_text("Custom", &ov));
}
assert!(text.starts_with("Keyboard Shortcuts\nSet: Custom\n"), "{text}");
assert!(text.contains(&format!("\t{}\n", crate::menus::pretty_shortcut("Ctrl+Alt+G"))), "{text}");
assert!(app.ui.shortcut_overrides.is_empty(), "the working set is kept only on OK");
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("shortcuts"), "the dialog stays open");
+7
View File
@@ -3656,6 +3656,13 @@ They list such a command while the user has changed its shortcut (`overridden: t
shortcut for that command runs what a click on the item runs: Object › Path › Clean Up…, Print Ink Fixes… and Edit ›
Find and Replace… open their dialogs.
Each installed filter plug-in's Object › Plug-ins item is its own row, with id `plugin:<id>` (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:<id>", 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.
`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
and a "Set:" line with the set's name, followed by a Tools section and a Menu Commands section. Each section lists the
+3 -1
View File
@@ -43,7 +43,9 @@ In the app, installed filters appear under **Object › Plug-ins**, next to **In
Plug-ins**; live effects under **Effect › Plug-ins** and in the Appearance panel's effect menu. A plug-in with
parameters gets a dialog generated from its schema (numbers, whole numbers, checkboxes, dropdowns, text, lists
of numbers and colors, labelled, explained and sorted into sections as its manifest says) with a live preview; without
parameters it runs at once. Parameters too many for the window's height scroll inside the dialog, which keeps its
parameters it runs at once. Edit › Keyboard Shortcuts lists each installed filter under Object › Plug-ins, where
it can be given a shortcut (`plugin:<id>`); the shortcut stays saved while the plug-in is removed and applies again
when it is reinstalled. Parameters too many for the window's height scroll inside the dialog, which keeps its
heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use
Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the
same kind once the file is picked, and its Export button writes the file. **File › Open** of a `.wasm` file installs
+4
View File
@@ -1812,6 +1812,10 @@ Cmd/Ctrl+Alt+. (`stroke.weightUp`) and Cmd/Ctrl+Alt+, (`stroke.weightDown`) rais
Edit › Keyboard Shortcuts lists every item of the Effect menu under its menu path (Effect › Distort & Transform › Transform…) and gives each one a shortcut of its own (stored as `effect:<id>`; they all run `effect.dialog {effect}`, or `effect.apply` for an effect without options, so a shortcut on the command alone couldn't tell them apart). The menu shows the shortcut, and pressing it opens that effect's dialog for the selection (#3296). Test: `shortcuts::tests::an_effect_menu_item_takes_a_shortcut_of_its_own`.
A live-effect plug-in installed while the app runs gets its Effect › Plug-ins row in Edit › Keyboard Shortcuts, in `shortcuts.list` and in a Keyboard Shortcuts dialog that is already open, and its key runs it. A removed plug-in's row goes away and its key runs nothing; the shortcut stays saved and applies again when a plug-in with the same id is installed, opening the effect's dialog if the new one has options and applying the effect if it has none. Tests: `shortcuts::tests::a_live_effect_plugin_installed_later_takes_a_shortcut_of_its_own`, `shortcut_editor::tests::an_open_dialog_follows_live_effect_plugins`.
Each installed filter plug-in's Object › Plug-ins item has a row of its own (stored as `plugin:<id>`, since every filter runs `plugin.dialog {id}`), with no default shortcut, also when two filters share a name. The menu shows its shortcut, and pressing it does what a click on the item does: it opens the filter's dialog, or runs a filter without parameters at once as one undo step (a heavy filter starts in the background). A removed filter's row goes away and its key runs nothing; the shortcut stays saved and applies again when a plug-in with the same id is installed. With no document open the key does nothing, as the dimmed item does. The command palette has one result per filter, and Import Shortcut Set offers each filter as a target without matching a row to it by name. Plug-in names stay as the plug-in gives them; the menu path is translated. Tests: `shortcuts::tests::each_filter_plugin_takes_a_shortcut_of_its_own`, `shortcuts::tests::a_filter_plugin_keeps_its_shortcut_when_replaced_or_reloaded`, `shortcuts::tests::a_heavy_filter_plugin_runs_from_its_shortcut_as_from_its_menu_item`, `shortcuts::tests::a_filter_plugin_row_translates_its_path_and_not_its_name`, `palette::tests::each_filter_plugin_is_a_result_of_its_own`, `dialogs::tests_shortcut_import::an_imported_shortcut_reaches_a_filter_plugin_picked_as_its_target`.
## Live Corners: arrow keys change the corner kind while dragging (2026-11-01)
While a corner widget is pressed or dragged with the Selection or Direct Selection tool, the Up and Down arrows step the kind of the corners being edited (Round, Inverted Round, Chamfer, in turn, from the pressed corner's), shown live with the radius and kept, with it, as one undo step when the drag ends; a press with an arrow and no drag changes just the kind (#2898). Alt-clicking a widget still cycles it as before. Test: `tests::livecorners::arrows_while_dragging_a_corner_widget_change_the_corner_kind`.