mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 19:57:05 -06:00
* Shortcuts: the keymap, overrides and dispatch come from craft-keymap
The editor's catalogue (commands, UI commands, panels, tools; one tool
group per tool; show/hide commands marked as toggles) builds a
craft-keymap Keymap. UiState::shortcut_overrides is a craft_keymap
Overrides stored through keymap::stored, which writes the same map of
entry key -> one string as before, with panel: keys and VectorCraft's
spellings, so earlier versions read the file; a value that is not text
drops that entry instead of every preference.
Key presses run through craft_keymap's Dispatcher with accept filters:
keys that type text go to the one-key text path, and while the Type
tool edits only command-key chords run. Held keys repeat. Conflicts
and assignment use the crate's conflicts and set_bindings. Extra Shift
no longer falls back to the chord without it.
* Shortcuts: .kys import matches and applies through craft-keymap
The review proposes targets with craft_keymap's namematch, and Apply
hands the ready rows to craft_keymap::plan as one set, each row's
target passed as the remembered answer for its entry. The later row
takes a chord, and commands outside the set lose the chords the set
gives. Rows with one name in one block keep their own targets.
The engine's own namematch module is removed.
* Shortcuts: Cmd+, beside other hits, web Mac platform, cached dialog view
- Pin craft-keymap at 362eb59f.
- Cmd+, opens Settings on a Mac after the frame's other shortcuts run, with the
Mac read from egui's OS; drop settings_chord, which dispatch no longer used.
- The web build follows the browser's OS, so Ctrl stays Ctrl on a Mac.
- The Keyboard Shortcuts dialog keeps its keymap, row texts and conflict count
between frames while the working set is unchanged.
- Every default shortcut text becomes its entry's binding (test).
- craft-keymap counts as a UI crate for the layer check.
- Translate "too many changed shortcuts"; the parse error reuses the existing
"can't parse shortcut `{c}`" message.
* Shortcuts: the chords the operating system takes can't be assigned
The keymap reserves craft_keymap::reserved::system for its platform:
window switching and closing, and the virtual consoles off the Mac.
The Keyboard Shortcuts dialog and shortcuts.set refuse them with
"<chord> is reserved by the system", and a saved override on one keeps
the entry's default. No default shortcut uses a reserved chord.
* Shortcuts: craft-keymap at craft-libs 2eca9b3a
craft-keymap comes from storytold/craft-libs (craft-libs #164 and #186), at the rev craft-pdf
already uses. The web build keeps the browser's full platform, so Windows browsers get Windows'
reserved chords, and each platform caches its own default keymap.
452 lines
19 KiB
Rust
452 lines
19 KiB
Rust
//! VectorCraft's keymap on [`craft_keymap`]: the catalogue of everything a shortcut can run, the
|
||
//! bridge between VectorCraft's chord spellings and the crate's, and the stored form of the
|
||
//! 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.
|
||
|
||
use std::sync::{OnceLock, 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};
|
||
|
||
/// Menu commands whose label flips between Show and Hide (see `menus::dynamic_label`).
|
||
pub const TOGGLES: &[&str] = &[
|
||
"view.edges",
|
||
"view.template",
|
||
"view.cornerWidget",
|
||
"view.textThreads",
|
||
"type.hiddenCharacters",
|
||
"view.gradientAnnotator",
|
||
"view.artboards",
|
||
"view.rulers",
|
||
"view.boundingBox",
|
||
"view.transparencyGrid",
|
||
"view.guides",
|
||
"view.grid",
|
||
"view.slices.hide",
|
||
"view.printTiling",
|
||
"perspective.grid.show",
|
||
"perspective.grid.rulers",
|
||
];
|
||
|
||
// ---------- entry keys ----------
|
||
|
||
/// The command an entry key runs.
|
||
pub fn command_of(key: &str) -> CommandRef {
|
||
match key.strip_prefix("panel:") {
|
||
Some(panel) => CommandRef::with_params("window.panel", json!({ "panel": panel })),
|
||
None => CommandRef::new(key),
|
||
}
|
||
}
|
||
|
||
/// The entry key of a command: `panel:<id>` for `window.panel {panel}`.
|
||
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}"),
|
||
_ => command.key(),
|
||
}
|
||
}
|
||
|
||
/// The [`Overrides`] key of an entry key.
|
||
pub fn stored_key(key: &str) -> String {
|
||
if key.starts_with("panel:") { command_of(key).key() } else { key.to_string() }
|
||
}
|
||
|
||
/// The entry key of an [`Overrides`] key.
|
||
pub fn entry_of_stored(key: &str) -> String {
|
||
CommandRef::parse_key(key).filter(|c| c.id == "window.panel").map_or_else(|| key.to_string(), |c| entry_key(&c))
|
||
}
|
||
|
||
// ---------- chord texts ----------
|
||
|
||
/// Reads VectorCraft chord text. `+` and `=` are one key: Plus alone, Equals with modifiers.
|
||
pub fn vc_chord(text: &str) -> Option<Chord> {
|
||
let sc = crate::shortcuts::parse(text.trim())?;
|
||
let key = Key::from_egui(sc.logical_key)?;
|
||
let m = sc.modifiers;
|
||
let mods = Mods { cmd: m.command || m.mac_cmd, ctrl: m.ctrl, alt: m.alt, shift: m.shift };
|
||
let key = match key {
|
||
Key::Plus | Key::Equals if mods.is_none() => Key::Plus,
|
||
Key::Plus | Key::Equals => Key::Equals,
|
||
k => k,
|
||
};
|
||
Some(Chord::new(mods, key))
|
||
}
|
||
|
||
/// VectorCraft's text of a chord, as `shortcut_editor::format` writes it.
|
||
pub fn vc_format(chord: &Chord) -> String {
|
||
let m = chord.mods;
|
||
let mods = egui::Modifiers { alt: m.alt, ctrl: m.ctrl, shift: m.shift, mac_cmd: false, command: m.cmd };
|
||
shortcut_editor::format(&egui::KeyboardShortcut::new(mods, chord.key.to_egui()))
|
||
}
|
||
|
||
/// VectorCraft's text of a binding; a scoped or double binding (hand-written) keeps the crate's.
|
||
pub fn binding_text(b: &Binding) -> String {
|
||
if b.scope.is_none() && !b.trigger.double { vc_format(&b.trigger.chord) } else { b.to_string() }
|
||
}
|
||
|
||
/// VectorCraft text → the crate's. Text both read as one chord stays as written (`Comma`,
|
||
/// `Ctrl+Cmd+H`), so it is saved back as the user wrote it; text neither reads is kept for
|
||
/// `Keymap::problems`.
|
||
pub fn vc_to_crate(text: &str) -> String {
|
||
if text.trim().is_empty() {
|
||
return String::new();
|
||
}
|
||
match vc_chord(text) {
|
||
Some(c) if Binding::parse(text).is_ok_and(|b| b == Binding::new(c)) => text.to_string(),
|
||
Some(c) => c.to_string(),
|
||
None => text.to_string(),
|
||
}
|
||
}
|
||
|
||
/// The crate's text → VectorCraft's. The crate's canonical text is rewritten; other text is
|
||
/// what [`vc_to_crate`] kept as written, or text that does not parse.
|
||
pub fn crate_to_vc(text: &str) -> String {
|
||
match Binding::parse(text) {
|
||
Ok(b) if b.to_string() == text => binding_text(&b),
|
||
_ => text.to_string(),
|
||
}
|
||
}
|
||
|
||
/// A default shortcut of the catalogue.
|
||
fn vc_binding(text: &str) -> Option<Binding> {
|
||
vc_chord(text).map(Binding::new)
|
||
}
|
||
|
||
// ---------- catalogue ----------
|
||
|
||
/// 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() -> Vec<Bindable> {
|
||
let rank = |e: &Entry| {
|
||
if e.is_tool {
|
||
2
|
||
} else if e.key.starts_with("panel:") {
|
||
1
|
||
} else {
|
||
0
|
||
}
|
||
};
|
||
let mut entries: Vec<&Entry> = shortcut_editor::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);
|
||
if let Some(tool) = e.key.strip_prefix("tool:") {
|
||
b = b.with_kind(Kind::Tool { group: Some(tool.to_string()) });
|
||
}
|
||
if TOGGLES.contains(&e.key.as_str()) {
|
||
b = b.toggle();
|
||
}
|
||
match shortcut_editor::default_of(&e.key).and_then(vc_binding) {
|
||
Some(d) => b.with_default(d),
|
||
None => b,
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// On the web, the browser's operating system once egui reports it ([`note_os`]).
|
||
static WEB_PLATFORM: RwLock<Option<Platform>> = RwLock::new(None);
|
||
|
||
/// The platform shortcuts follow: the build's, and on the web the browser's operating system, so
|
||
/// Cmd shortcuts work in a browser on a Mac and Windows' reserved chords stay free on Windows.
|
||
pub fn platform() -> Platform {
|
||
let web = if cfg!(target_arch = "wasm32") { WEB_PLATFORM.read().ok().and_then(|p| *p) } else { None };
|
||
web.unwrap_or_else(Platform::current)
|
||
}
|
||
|
||
/// Records the operating system egui reports, which the web build reads from the browser.
|
||
pub fn note_os(ctx: &egui::Context) {
|
||
if cfg!(target_arch = "wasm32") {
|
||
let p = craft_keymap::egui::platform(ctx);
|
||
if let Ok(mut web) = WEB_PLATFORM.write()
|
||
&& *web != Some(p)
|
||
{
|
||
*web = Some(p);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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`], built once per platform.
|
||
pub fn template() -> &'static Keymap {
|
||
static TEMPLATES: [OnceLock<Keymap>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()];
|
||
let p = platform();
|
||
per_platform(&TEMPLATES, p, || keymap_for(p))
|
||
}
|
||
|
||
/// The default keymap of `p`. The chords the operating system takes can't be assigned.
|
||
fn keymap_for(p: Platform) -> Keymap {
|
||
Keymap::new(catalogue(), Scopes::new(), p).with_reserved(craft_keymap::reserved::system(p))
|
||
}
|
||
|
||
/// The keymap with `overrides` applied.
|
||
pub fn keymap_with(overrides: &Overrides) -> Keymap {
|
||
template().clone().with_overrides(overrides.clone())
|
||
}
|
||
|
||
/// The command a chord runs, the first for tools sharing it.
|
||
pub fn lookup(km: &Keymap, chord: &Chord) -> Option<CommandRef> {
|
||
match km.lookup(chord, false, &craft_keymap::Active::NONE) {
|
||
Lookup::None => None,
|
||
Lookup::Run(c) => Some(c),
|
||
Lookup::Cycle(v) => v.into_iter().next(),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Effective shortcut text of an entry key under `km`: the default's own text while the entry
|
||
/// has its defaults. An entry the catalogue lacks reads its stored text.
|
||
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)) {
|
||
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),
|
||
};
|
||
}
|
||
let now = km.bindings(&command);
|
||
if now == km.default_bindings(&command) {
|
||
return shortcut_editor::default_of(key).map(str::to_string);
|
||
}
|
||
let first = now.first()?;
|
||
// The text as stored, when it is this binding's: the user's spelling.
|
||
let stored = km.overrides().get(&stored_key(key)).and_then(|t| t.first()).filter(|t| Binding::parse(t).is_ok_and(|b| b == *first));
|
||
Some(stored.map_or_else(|| binding_text(first), |t| crate_to_vc(t)))
|
||
}
|
||
|
||
// ---------- overrides ----------
|
||
|
||
/// 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)] };
|
||
// Only an over-long key or text is refused; such an entry could not be stored anyway.
|
||
let _ = overrides.insert(stored_key(key), texts);
|
||
}
|
||
|
||
/// Overrides holding `(entry key, shortcut)` pairs in VectorCraft's terms.
|
||
pub fn overrides_from(pairs: &[(&str, &str)]) -> Overrides {
|
||
let mut out = Overrides::new();
|
||
for (k, t) in pairs {
|
||
set_override(&mut out, k, t);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// 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())
|
||
}
|
||
|
||
pub fn has_override(overrides: &Overrides, key: &str) -> bool {
|
||
overrides.get(&stored_key(key)).is_some()
|
||
}
|
||
|
||
/// Serde for [`Overrides`] in VectorCraft's own form, which every earlier version reads: a map
|
||
/// of entry key → one shortcut text (`""` for none), with `panel:` keys and VectorCraft's
|
||
/// spellings. Loading never fails: what cannot be used is dropped or kept for
|
||
/// `Keymap::problems`, and only an entry's first text is kept.
|
||
pub mod stored {
|
||
use craft_keymap::Overrides;
|
||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||
use serde_json::{Map, Value};
|
||
|
||
use super::{crate_to_vc, entry_of_stored, stored_key, vc_to_crate};
|
||
|
||
pub fn serialize<S: Serializer>(overrides: &Overrides, s: S) -> Result<S::Ok, S::Error> {
|
||
s.collect_map(overrides.iter().map(|(k, texts)| (entry_of_stored(k), texts.first().map(|t| crate_to_vc(t)).unwrap_or_default())))
|
||
}
|
||
|
||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Overrides, D::Error> {
|
||
Ok(from_value(Value::deserialize(d)?))
|
||
}
|
||
|
||
pub fn to_value(overrides: &Overrides) -> Value {
|
||
struct Stored<'a>(&'a Overrides);
|
||
impl Serialize for Stored<'_> {
|
||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||
serialize(self.0, s)
|
||
}
|
||
}
|
||
serde_json::to_value(Stored(overrides)).unwrap_or_else(|_| Value::Object(Map::new()))
|
||
}
|
||
|
||
pub fn from_value(v: Value) -> Overrides {
|
||
let entries: Vec<(String, Value)> = match v {
|
||
Value::Object(m) => m.into_iter().collect(),
|
||
// The crate's list form, `[{command, keys}]`, as a newer version might write it.
|
||
Value::Array(items) => items
|
||
.into_iter()
|
||
.filter_map(|item| {
|
||
let command = item.get("command").and_then(Value::as_str)?.to_string();
|
||
Some((command, item.get("keys").cloned().unwrap_or(Value::Array(vec![]))))
|
||
})
|
||
.collect(),
|
||
other => return Overrides::deserialize(other).unwrap_or_default(),
|
||
};
|
||
let map: Map<String, Value> = entries.into_iter().map(|(k, v)| (stored_key(&k), first_text(v))).collect();
|
||
Overrides::deserialize(Value::Object(map)).unwrap_or_default()
|
||
}
|
||
|
||
/// One text in the crate's spelling; values that are not text are left for the crate to
|
||
/// report.
|
||
fn first_text(v: Value) -> Value {
|
||
match v {
|
||
Value::String(s) => Value::String(vc_to_crate(&s)),
|
||
Value::Array(items) => match items.into_iter().next() {
|
||
None => Value::Array(vec![]),
|
||
Some(Value::String(s)) => Value::String(vc_to_crate(&s)),
|
||
Some(other) => Value::Array(vec![other]),
|
||
},
|
||
other => other,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::state::UiState;
|
||
use std::collections::BTreeMap;
|
||
|
||
/// Earlier versions read the overrides as a map of one string per entry; a file this
|
||
/// version writes must stay that.
|
||
#[test]
|
||
fn older_versions_read_the_saved_overrides() {
|
||
let mut ui = UiState::default();
|
||
set_override(&mut ui.shortcut_overrides, "tool:pen", "Shift+9");
|
||
set_override(&mut ui.shortcut_overrides, "edit.preferences", "");
|
||
set_override(&mut ui.shortcut_overrides, "panel:layers", "Cmd+Alt+Shift+F7");
|
||
set_override(&mut ui.shortcut_overrides, "view.grid", "Cmd+Period");
|
||
// An entry holding two texts (the crate keeps texts it cannot use after the binding).
|
||
ui.shortcut_overrides.insert("tool:curvature", vec!["Shift+`".into(), "Hyper+K".into()]);
|
||
let saved = serde_json::to_value(&ui).unwrap();
|
||
let old: BTreeMap<String, String> = serde_json::from_value(saved["shortcut_overrides"].clone()).unwrap();
|
||
let want: BTreeMap<String, String> = [
|
||
("tool:pen", "Shift+9"),
|
||
("edit.preferences", ""),
|
||
("panel:layers", "Cmd+Alt+Shift+F7"),
|
||
("view.grid", "Cmd+Period"),
|
||
("tool:curvature", "Shift+~"),
|
||
]
|
||
.into_iter()
|
||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||
.collect();
|
||
assert_eq!(old, want);
|
||
let back: UiState = serde_json::from_value(saved).unwrap();
|
||
assert_eq!(override_text(&back.shortcut_overrides, "panel:layers").as_deref(), Some("Cmd+Alt+Shift+F7"));
|
||
assert_eq!(override_text(&back.shortcut_overrides, "tool:curvature").as_deref(), Some("Shift+~"));
|
||
}
|
||
|
||
/// A hand-edited value that is not text drops that entry, not every preference.
|
||
#[test]
|
||
fn a_bad_override_value_keeps_the_other_preferences() {
|
||
let mut saved = serde_json::to_value(UiState { toolbar: false, ..Default::default() }).unwrap();
|
||
saved["shortcut_overrides"] = json!({"x": 5, "tool:pen": "Shift+9"});
|
||
let ui: UiState = serde_json::from_value(saved).unwrap();
|
||
assert!(!ui.toolbar);
|
||
assert!(!has_override(&ui.shortcut_overrides, "x"));
|
||
assert_eq!(override_text(&ui.shortcut_overrides, "tool:pen").as_deref(), Some("Shift+9"));
|
||
}
|
||
|
||
#[test]
|
||
fn curvature_keeps_shift_tilde_and_plus_is_one_key() {
|
||
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")));
|
||
assert_eq!(effective_with(km, "tool:curvature").as_deref(), Some("Shift+~"));
|
||
// Plus alone is the Add Anchor Point tool's, however it is written; with modifiers the
|
||
// key is `=`.
|
||
for t in ["+", "="] {
|
||
assert_eq!(vc_chord(t), Some(Chord::plain(Key::Plus)), "{t}");
|
||
}
|
||
for t in ["Cmd++", "Cmd+="] {
|
||
assert_eq!(vc_chord(t), Some(Chord::new(Mods::CMD, Key::Equals)), "{t}");
|
||
}
|
||
assert_eq!(lookup(km, &Chord::plain(Key::Plus)), Some(CommandRef::new("tool:addAnchor")));
|
||
assert_eq!(lookup(km, &Chord::new(Mods::CMD, Key::Equals)), Some(CommandRef::new("view.zoomIn")));
|
||
assert_eq!(vc_format(&Chord::new(Mods::CMD, Key::Comma)), "Cmd+Comma");
|
||
}
|
||
|
||
#[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");
|
||
// A default the bridge can't read would show in the menus but never run.
|
||
let unbound: Vec<(&str, &str)> = shortcut_editor::entries()
|
||
.iter()
|
||
.filter_map(|e| shortcut_editor::default_of(&e.key).map(|d| (e.key.as_str(), d)))
|
||
.filter(|(key, d)| vc_binding(d).map(|b| vec![b]) != Some(template().default_bindings(&command_of(key))))
|
||
.collect();
|
||
assert_eq!(unbound, vec![], "every default text becomes the entry's binding");
|
||
}
|
||
|
||
/// The chords the operating system takes are refused, and no default uses one.
|
||
#[test]
|
||
fn system_shortcuts_are_refused() {
|
||
for (p, os_chord) in [(Platform::Mac, "Cmd+Tab"), (Platform::Windows, "Alt+F4"), (Platform::Linux, "Ctrl+Alt+F2")] {
|
||
let mut km = keymap_for(p);
|
||
assert_eq!((km.conflicts(), km.problems()), (vec![], vec![]), "{p:?}");
|
||
let reserved: Vec<String> = shortcut_editor::entries()
|
||
.iter()
|
||
.filter(|e| km.bindings(&command_of(&e.key)).iter().any(|b| km.is_reserved(&b.trigger.chord)))
|
||
.map(|e| e.key.clone())
|
||
.collect();
|
||
assert_eq!(reserved, Vec::<String>::new(), "{p:?}: defaults on reserved chords");
|
||
let err = shortcut_editor::assign_in(&mut km, "edit.undo", Some(os_chord), true).unwrap_err();
|
||
assert!(err.ends_with("is reserved by the system"), "{p:?}: {err}");
|
||
// 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);
|
||
assert_eq!(km.bindings(&command_of("edit.undo")), km.default_bindings(&command_of("edit.undo")), "{p:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn panel_entries_bridge_to_window_panel() {
|
||
let c = command_of("panel:layers");
|
||
assert_eq!((c.id.as_str(), c.params.clone()), ("window.panel", json!({"panel": "layers"})));
|
||
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;
|
||
assert_eq!(paths, &vec![vec!["Window".to_string(), "Layers".to_string()]], "import matches panels by name");
|
||
}
|
||
|
||
/// Each toggle's menu label reads Show or Hide.
|
||
#[test]
|
||
fn toggles_show_and_hide() {
|
||
let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||
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));
|
||
assert!(listed.is_none_or(|b| b.is_toggle), "{id}");
|
||
}
|
||
}
|
||
}
|