PDF.R1: reconcile shared PDF migration with current main

Preserve the writer and reader changes while integrating the current main.

The combined candidate passes all eight local CI gates and the 2,048-case
reader panic verification. Hosted checks remain required before landing.

Refs #1893, #1894.
This commit is contained in:
skylar taylor-barrick
2026-10-14 22:02:49 -06:00
45 changed files with 3380 additions and 59 deletions
+1
View File
@@ -683,6 +683,7 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
.chain(std::iter::once(("Shortcut sets", super::shortcutset::EXTS)))
.chain(std::iter::once(("Scripts", super::scripts::EXTS)))
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
}
+2 -2
View File
@@ -159,8 +159,8 @@ fn open_exts_cover_every_readable_format() {
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
assert_eq!(
filters.len(),
8 + FORMATS.iter().filter(|f| f.read).count(),
"and swatch libraries, libraries, flattener, PDF and print presets, scripts, plug-ins"
9 + FORMATS.iter().filter(|f| f.read).count(),
"and swatch libraries, libraries, flattener, PDF and print presets, shortcut sets, scripts, plug-ins"
);
assert!(filters.contains(&("Scripts", crate::cmd::scripts::EXTS)), "File › Open checks scripts");
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
+172
View File
@@ -0,0 +1,172 @@
//! What names and codes in other apps' files stand for in VectorCraft, as the user confirmed them on
//! import: a shortcut set's entry names, and its key and modifier codes. They are kept with the
//! preferences on the user's machine, so a later import proposes them and asks only about the rest
//! (`importMatches.*`).
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::*;
use crate::Prefs;
/// The most matches kept.
const MAX_MATCHES: usize = 20_000;
/// The longest name, target or label kept (bytes).
const MAX_TEXT: usize = 1024;
/// The kinds of names: entry names (commands, tools and panels), and a shortcut set's key and
/// modifier codes.
pub const KINDS: &[&str] = &["name", "key", "modifiers"];
/// A name or code in another app's file and what it stands for in VectorCraft
/// ([`crate::Prefs::import_matches`]). Missing fields read as empty, so a preferences file with a
/// damaged match still loads.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ImportMatch {
/// One of [`KINDS`].
pub kind: String,
/// As the file writes it: an entry's name, or a code in decimal (`48`).
pub name: String,
/// Kind `name`: a command id, `tool:<id>` or `panel:<id>`, or "" for no equivalent. Kind `key`: a
/// key as Edit › Keyboard Shortcuts writes it (`K`, `]`, `F5`), or "" for no equivalent. Kind
/// `modifiers`: modifiers as it writes them (`Cmd+Shift`), or "" for none.
pub target: String,
/// What the target was called when it was chosen.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "importMatches.list",
"Import Matches",
[],
None,
"{kind?: name|key|modifiers, query?: text (in the name, target or label; any case)} the matches the user confirmed when importing other apps' files (shortcut sets), kept with the preferences → [{kind, name, target, label?}] (target \"\": no equivalent)",
always,
list
),
cmd!(
query "importMatches.set",
"Remember Import Matches",
[],
None,
"{matches: [{kind: name|key|modifiers, name: as the file writes it (codes in decimal), target: a command id, tool:<id> or panel:<id> (name), a key as Edit › Keyboard Shortcuts writes it (key), modifiers as it writes them, such as Cmd+Shift (modifiers), \"\" for no equivalent (no modifiers for kind modifiers), or null to forget the match, label?}]} remember what names and codes in another app's file stand for in VectorCraft, for later imports (at most 20,000 matches; names, targets and labels up to 1,024 bytes) → {count}",
always,
set
),
cmd!(
query "importMatches.reset",
"Forget Import Matches",
[],
None,
"{kind?: name|key|modifiers} forget the import matches, all of them or those of one kind → {removed}",
always,
reset
),
]
}
/// The target the user confirmed for each kind and name.
pub fn targets(prefs: &Prefs) -> HashMap<(&str, &str), &str> {
let mut out = HashMap::new();
for m in &prefs.import_matches {
out.entry((m.kind.as_str(), m.name.as_str())).or_insert(m.target.as_str());
}
out
}
/// `kind` when it is one of [`KINDS`].
fn kind_param(v: Option<&Value>, cmd: &str) -> Result<Option<&'static str>> {
match v.filter(|v| !v.is_null()) {
None => Ok(None),
Some(k) => k
.as_str()
.and_then(|k| KINDS.iter().copied().find(|x| *x == k))
.map(Some)
.ok_or_else(|| bad(cmd, format!("`kind` is one of {}", KINDS.join(", ")))),
}
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let kind = kind_param(p.get("kind"), "importMatches.list")?;
let q = str_param(p, "query").unwrap_or_default().to_lowercase();
let hit = |m: &ImportMatch| {
q.is_empty() || [Some(&m.name), Some(&m.target), m.label.as_ref()].into_iter().flatten().any(|t| t.to_lowercase().contains(&q))
};
let rows: Vec<&ImportMatch> = s.prefs.import_matches.iter().filter(|m| kind.is_none_or(|k| m.kind == k) && hit(m)).collect();
Ok(serde_json::to_value(rows).unwrap_or_else(|_| json!([])))
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "importMatches.set";
let rows = p.get("matches").and_then(Value::as_array).ok_or_else(|| bad(C, "give `matches`: [{kind, name, target}]"))?;
if rows.len() > MAX_MATCHES {
return Err(bad(C, format!("at most {MAX_MATCHES} matches are kept")));
}
let text = |v: Option<&Value>, what: &str| -> Result<Option<String>> {
match v.filter(|v| !v.is_null()) {
None => Ok(None),
Some(v) => {
let t = v.as_str().ok_or_else(|| bad(C, format!("`{what}` must be text")))?;
if t.len() > MAX_TEXT {
return Err(bad(C, format!("`{what}` is longer than {MAX_TEXT} bytes")));
}
Ok(Some(t.to_string()))
}
}
};
// Checked whole before anything changes.
let mut changes: Vec<(ImportMatch, bool)> = vec![];
for row in rows {
let kind = kind_param(row.get("kind"), C)?.ok_or_else(|| bad(C, "each match needs `kind`"))?;
let name = text(row.get("name"), "name")?.filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "each match needs a `name`"))?;
let forget = row.get("target").is_none_or(Value::is_null);
let target = text(row.get("target"), "target")?.unwrap_or_default();
let label = text(row.get("label"), "label")?;
changes.push((ImportMatch { kind: kind.to_string(), name, target, label }, forget));
}
// Forgotten matches leave a gap until the end, so the others keep their places.
let mut next: Vec<Option<ImportMatch>> = s.prefs.import_matches.iter().cloned().map(Some).collect();
let mut at: HashMap<(String, String), usize> = HashMap::new();
for (i, m) in s.prefs.import_matches.iter().enumerate() {
at.entry((m.kind.clone(), m.name.clone())).or_insert(i);
}
for (m, forget) in changes {
let key = (m.kind.clone(), m.name.clone());
match (at.get(&key).copied(), forget) {
(Some(i), true) => {
if let Some(slot) = next.get_mut(i) {
*slot = None;
}
at.remove(&key);
}
(Some(i), false) => {
if let Some(slot) = next.get_mut(i) {
*slot = Some(m);
}
}
(None, true) => {}
(None, false) => {
at.insert(key, next.len());
next.push(Some(m));
}
}
}
let next: Vec<ImportMatch> = next.into_iter().flatten().collect();
if next.len() > MAX_MATCHES {
return Err(bad(C, format!("at most {MAX_MATCHES} matches are kept")));
}
s.prefs.import_matches = next;
Ok(json!({ "count": s.prefs.import_matches.len() }))
}
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
let kind = kind_param(p.get("kind"), "importMatches.reset")?;
let before = s.prefs.import_matches.len();
s.prefs.import_matches.retain(|m| kind.is_some_and(|k| m.kind != k));
Ok(json!({ "removed": before - s.prefs.import_matches.len() }))
}
+4
View File
@@ -31,6 +31,7 @@ pub(crate) mod gradient;
pub(crate) mod graph;
mod halftone;
pub mod help;
pub mod importmatches;
pub(crate) mod inline;
mod layer;
mod layerpanel;
@@ -67,6 +68,7 @@ pub mod recovery;
pub mod scripts;
mod select;
pub(crate) mod shaper;
pub mod shortcutset;
pub(crate) mod slices;
mod stroke;
mod style;
@@ -283,6 +285,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(cut::specs());
v.extend(halftone::specs());
v.extend(perspgrid::specs());
v.extend(shortcutset::specs());
v.extend(importmatches::specs());
v
})
}
+393
View File
@@ -0,0 +1,393 @@
//! Shortcut sets another app saves as text (`.kys` files that start `/Menus {`), read into their
//! entries without changing anything (`shortcutSet.read`). Names and codes the user matched on an
//! earlier import ([`super::importmatches`]) come back with what they stand for.
//!
//! The text is a tree of blocks. A slash name opens a block with `{` (a backslash in a name takes the
//! next character as it is, so `/Acme\ Lasso\ Tool` names "Acme Lasso Tool"), and `}` closes it.
//! Inside a block, a slash name followed by an integer sets a field: `/Context`, `/Modifiers`,
//! `/Represent` and `/Key`. A block that sets `/Key` is an entry, and the blocks around it are its
//! section. The layout follows the description at https://filext.com/file-extension/KYS and the
//! example in the MIT-licensed AiCommandPalette README
//! (https://github.com/joshbduncan/AiCommandPalette), whose entry for the 0 key holds `/Key 48` and
//! `/Modifiers 0`. No public source says what the other codes mean, so they are read as numbers.
use serde_json::{Value, json};
use super::*;
/// The most bytes of a file read.
const MAX_BYTES: usize = 4 << 20;
/// The most bytes taken in: [`MAX_BYTES`] after a byte order mark, and one more that shows the text
/// goes on.
pub const READ_LIMIT: usize = MAX_BYTES + 4;
/// The most blocks open at once.
const MAX_DEPTH: usize = 16;
/// The most entries read.
const MAX_ENTRIES: usize = 20_000;
/// The longest name kept (bytes).
const MAX_NAME: usize = 1024;
/// The most bytes of names kept over all entries, sections included: each entry repeats the names
/// of the blocks around it, so a small file could otherwise fill hundreds of megabytes.
const MAX_KEPT: usize = 2 * MAX_BYTES;
/// The most tokens listed as unread.
const MAX_UNREAD: usize = 1000;
/// The longest unread token shown (characters).
const UNREAD_SHOWN: usize = 80;
/// What `shortcutSet.read` says about a file that isn't a text shortcut set.
pub const NOT_READABLE: &str = "This shortcut file is in a form VectorCraft can't read.";
/// The extensions of shortcut sets other apps save (File › Open lists them).
pub const EXTS: &[&str] = &["kys"];
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
query "shortcutSet.read",
"Read Shortcut Set File",
[],
None,
"{path? | data?: the file's text | dataBase64?, name?: the file's name (default: the path's)} read a shortcut set another app saved as text (.kys, starting \"/Menus {\") without changing anything: each entry is a block that sets /Key, with the names of the blocks around it as its section → {set: the file's name without its extension, entries: [{section: [names], name, key, modifiers, context, represent, line, target?, chord?}] (codes as the file writes them; null where the entry doesn't set one; target: what importMatches says the name stands for; chord: the shortcut its codes stand for, as Edit › Keyboard Shortcuts writes it, when importMatches decodes them, an entry without /Modifiers taking none), codes: {keys: [{value, count, key?}], modifiers: [{value, count, mods?}]} (key and mods from importMatches), unread: [{line, text}] (tokens that aren't read, at most 1000), truncated: true when a cap stopped the reading (4 MiB, 16 open blocks, 20,000 entries, 8 MiB of names over the entries and their sections; names past 1,024 bytes are cut)}. A file in another form (such as XML) is an error",
always,
read
)]
}
/// An entry of a shortcut set: a block that sets `/Key`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct KysEntry {
/// The names of the blocks around it, outermost first (`["Menus"]`).
pub section: Vec<String>,
pub name: String,
pub key: Option<i64>,
pub modifiers: Option<i64>,
pub context: Option<i64>,
pub represent: Option<i64>,
/// The line its name is on (from 1).
pub line: usize,
}
/// A shortcut set read from text.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct KysSet {
pub entries: Vec<KysEntry>,
/// The tokens that weren't read, with their lines.
pub unread: Vec<(usize, String)>,
/// A cap stopped the reading, or cut a name.
pub truncated: bool,
}
/// A token of the text.
enum Token {
/// A slash name, its escapes undone.
Name(Vec<u8>),
/// An integer; `None` when it doesn't fit an `i64`.
Int(Option<i64>),
Open,
Close,
/// Anything else, as written.
Other(Vec<u8>),
}
/// The tokens of `text`, each with its line.
struct Tokens<'a> {
text: &'a [u8],
at: usize,
line: usize,
}
impl Tokens<'_> {
fn next_token(&mut self) -> Option<(usize, Token)> {
// Whitespace: LF, CR and CRLF each end a line.
while let Some(&b) = self.text.get(self.at) {
match b {
b'\n' => self.line += 1,
b'\r' if self.text.get(self.at + 1) != Some(&b'\n') => self.line += 1,
b' ' | b'\t' | b'\r' | 0x0C | 0 => {}
_ => break,
}
self.at += 1;
}
let line = self.line;
let b = *self.text.get(self.at)?;
let ends = |b: u8| b.is_ascii_whitespace() || matches!(b, b'{' | b'}' | b'/' | 0);
let token = match b {
b'{' => {
self.at += 1;
Token::Open
}
b'}' => {
self.at += 1;
Token::Close
}
b'/' => {
self.at += 1;
let mut name = vec![];
while let Some(&c) = self.text.get(self.at) {
if c == b'\\' {
// The next character as it is (a backslash at the end of the text is dropped).
if let Some(&n) = self.text.get(self.at + 1) {
name.push(n);
if n == b'\n' || n == b'\r' && self.text.get(self.at + 2) != Some(&b'\n') {
self.line += 1;
}
}
self.at += 2;
} else if ends(c) {
break;
} else {
name.push(c);
self.at += 1;
}
}
Token::Name(name)
}
_ => {
let start = self.at;
while self.text.get(self.at).is_some_and(|&c| !ends(c)) {
self.at += 1;
}
// Always past at least one byte.
self.at = self.at.max(start + 1);
let word = self.text.get(start..self.at).unwrap_or_default();
let number = word.strip_prefix(b"-").or_else(|| word.strip_prefix(b"+")).unwrap_or(word);
if !number.is_empty() && number.iter().all(u8::is_ascii_digit) {
Token::Int(std::str::from_utf8(word).ok().and_then(|s| s.parse().ok()))
} else {
Token::Other(word.to_vec())
}
}
};
Some((line, token))
}
}
/// A block being read.
struct Block {
name: String,
line: usize,
fields: [Option<i64>; 4],
}
/// The fields an entry's block sets, in [`Block::fields`] order.
const FIELDS: [&[u8]; 4] = [b"Key", b"Modifiers", b"Context", b"Represent"];
/// A name as text: at most [`MAX_NAME`] bytes, read as UTF-8 with replacement characters.
fn name_text(name: &[u8], set: &mut KysSet) -> String {
if name.len() > MAX_NAME {
set.truncated = true;
}
let mut s = String::from_utf8_lossy(name.get(..MAX_NAME).unwrap_or(name)).into_owned();
// A character cut at the limit reads as one replacement character.
if name.len() > MAX_NAME && s.ends_with('\u{FFFD}') {
s.pop();
}
s
}
/// List a token that isn't read.
fn unread(set: &mut KysSet, line: usize, text: impl AsRef<[u8]>) {
if set.unread.len() < MAX_UNREAD {
let text: String = String::from_utf8_lossy(text.as_ref()).chars().take(UNREAD_SHOWN).collect();
set.unread.push((line, text));
}
}
/// Read a shortcut set saved as text. An error when the text isn't one: it starts with `<` (XML) or
/// doesn't start with a slash name and `{`.
pub fn read_kys(bytes: &[u8]) -> std::result::Result<KysSet, String> {
let mut set = KysSet::default();
let text = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
let text = match text.get(..MAX_BYTES) {
Some(head) if text.len() > MAX_BYTES => {
set.truncated = true;
head
}
_ => text,
};
let mut tokens = Tokens { text, at: 0, line: 1 };
// The first two tokens show what the text is.
let mut first = Tokens { text, at: 0, line: 1 };
match (first.next_token(), first.next_token()) {
(Some((_, Token::Name(_))), Some((_, Token::Open))) => {}
(Some((_, Token::Other(w))), _) if w.starts_with(b"<") => return Err(NOT_READABLE.into()),
_ => return Err("not a shortcut set: the text doesn't start with a slash name and an opening brace".into()),
}
let mut stack: Vec<Block> = vec![];
// A slash name waiting for its `{` or its integer: (line, name).
let mut pending: Option<(usize, Vec<u8>)> = None;
// Blocks past [`MAX_DEPTH`] being skipped.
let mut skipped = 0usize;
// The bytes of names the entries keep so far ([`MAX_KEPT`]).
let mut kept = 0usize;
while let Some((line, token)) = tokens.next_token() {
if skipped > 0 {
match token {
Token::Open => skipped += 1,
Token::Close => skipped -= 1,
_ => {}
}
continue;
}
match token {
Token::Name(name) => {
if let Some((l, n)) = pending.replace((line, name)) {
unread(&mut set, l, [b"/".as_slice(), &n].concat());
}
}
Token::Int(value) => match pending.take() {
Some((_, name)) if !stack.is_empty() && FIELDS.contains(&name.as_slice()) && value.is_some() => {
let i = FIELDS.iter().position(|f| *f == name.as_slice()).unwrap_or(0);
if let Some(slot) = stack.last_mut().and_then(|b| b.fields.get_mut(i)) {
*slot = value;
}
}
Some((l, name)) => {
let shown = match value {
Some(v) => format!("/{} {v}", String::from_utf8_lossy(&name)),
None => format!("/{} (a number too large)", String::from_utf8_lossy(&name)),
};
unread(&mut set, l, shown);
}
None => unread(&mut set, line, value.map_or_else(|| "(a number too large)".to_string(), |v| v.to_string())),
},
Token::Open => {
let (l, name) = pending.take().unwrap_or((line, vec![]));
if stack.len() >= MAX_DEPTH {
set.truncated = true;
unread(&mut set, l, format!("blocks open more than {MAX_DEPTH} deep"));
skipped = 1;
continue;
}
let name = name_text(&name, &mut set);
stack.push(Block { name, line: l, fields: [None; 4] });
}
Token::Close => {
if let Some((l, n)) = pending.take() {
unread(&mut set, l, [b"/".as_slice(), &n].concat());
}
let Some(block) = stack.pop() else {
unread(&mut set, line, "}");
continue;
};
let [key, modifiers, context, represent] = block.fields;
if key.is_some() {
kept += block.name.len() + stack.iter().map(|b| b.name.len()).sum::<usize>();
if set.entries.len() >= MAX_ENTRIES || kept > MAX_KEPT {
set.truncated = true;
break;
}
let section = stack.iter().map(|b| b.name.clone()).collect();
set.entries.push(KysEntry { section, name: block.name, key, modifiers, context, represent, line: block.line });
}
}
Token::Other(word) => {
if let Some((l, n)) = pending.take() {
unread(&mut set, l, [b"/".as_slice(), &n].concat());
}
unread(&mut set, line, word);
}
}
}
if let Some((l, n)) = pending {
unread(&mut set, l, [b"/".as_slice(), &n].concat());
}
for block in stack.iter().rev() {
unread(&mut set, block.line, format!("/{} {{ (not closed)", block.name));
}
Ok(set)
}
/// Each code of `codes` with how many entries use it, the most used first.
fn counts(codes: impl Iterator<Item = i64>) -> Vec<(i64, usize)> {
let mut by: std::collections::BTreeMap<i64, usize> = std::collections::BTreeMap::new();
for c in codes {
*by.entry(c).or_default() += 1;
}
let mut v: Vec<(i64, usize)> = by.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
v
}
/// The shortcut text for `mods` (`Cmd+Shift`, or "" for none) and `key`, as Edit › Keyboard
/// Shortcuts writes it; none when the key has no equivalent.
fn chord(mods: &str, key: &str) -> Option<String> {
match (mods, key) {
(_, "") => None,
("", k) => Some(k.to_string()),
(m, k) => Some(format!("{m}+{k}")),
}
}
/// The bytes base64 text `b64` holds, up to [`READ_LIMIT`] and a few more: only the text they take is
/// decoded.
fn decode_head(b64: &str) -> Option<Vec<u8>> {
let chars = READ_LIMIT.div_ceil(3) * 4;
let mut kept = 0usize;
let end = b64
.bytes()
.position(|b| {
kept += usize::from(!b.is_ascii_whitespace());
kept > chars
})
.unwrap_or(b64.len());
vectorcraft_format::base64_decode(b64.get(..end)?)
}
fn read(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "shortcutSet.read";
let bytes: std::borrow::Cow<[u8]> = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) {
(Some(path), ..) => super::fileio::read_file_head(path, READ_LIMIT)?.into(),
(None, Some(text), _) => text.as_bytes().get(..READ_LIMIT).unwrap_or(text.as_bytes()).into(),
(None, None, Some(b64)) => decode_head(b64).ok_or_else(|| bad(C, "bad dataBase64"))?.into(),
_ => return Err(bad(C, "give `path`, `data` or `dataBase64`")),
};
let set = read_kys(&bytes).map_err(EngineError::Other)?;
let file = str_param(p, "name").or_else(|| str_param(p, "path")).unwrap_or_default();
let name = std::path::Path::new(file).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let targets = importmatches::targets(&s.prefs);
let saved = |kind: &str, name: &str| targets.get(&(kind, name)).copied();
let code = |kind: &str, c: i64| saved(kind, &c.to_string());
let entries: Vec<Value> = set
.entries
.iter()
.map(|e| {
let mut v = json!({
"section": e.section, "name": e.name, "key": e.key, "modifiers": e.modifiers,
"context": e.context, "represent": e.represent, "line": e.line,
});
if let Some(t) = saved("name", &e.name) {
v["target"] = json!(t);
}
let mods = match e.modifiers {
Some(m) => code("modifiers", m),
None => Some(""),
};
if let Some(c) = e.key.and_then(|k| code("key", k)).zip(mods).and_then(|(k, m)| chord(m, k)) {
v["chord"] = json!(c);
}
v
})
.collect();
let rows = |codes: Vec<(i64, usize)>, kind: &str, field: &str| -> Vec<Value> {
codes
.into_iter()
.map(|(value, count)| {
let mut v = json!({ "value": value, "count": count });
if let Some(t) = code(kind, value) {
v[field] = json!(t);
}
v
})
.collect()
};
Ok(json!({
"set": name,
"entries": entries,
"codes": {
"keys": rows(counts(set.entries.iter().filter_map(|e| e.key)), "key", "key"),
"modifiers": rows(counts(set.entries.iter().filter_map(|e| e.modifiers)), "modifiers", "mods"),
},
"unread": set.unread.iter().map(|(line, text)| json!({ "line": line, "text": text })).collect::<Vec<_>>(),
"truncated": set.truncated,
}))
}
+9
View File
@@ -12,6 +12,7 @@ pub mod guard;
pub mod inspect;
pub mod journal;
pub mod link_watch;
pub mod namematch;
pub mod steps;
mod tooling;
pub mod units;
@@ -856,6 +857,11 @@ pub struct Prefs {
/// Perspective Grid Options (double-click the Perspective Grid tool): whether the Plane
/// Switching Widget shows and where (`perspective.widget.options`).
pub perspective_widget: vectorcraft_tools::distort::perspective::widget::WidgetOptions,
/// What names and codes in other apps' shortcut sets stand for, as the user confirmed them on
/// import (`importMatches.*`). A local library, not a Preferences dialog field: resetting the
/// preferences keeps it.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub import_matches: Vec<cmd::importmatches::ImportMatch>,
}
impl Default for Prefs {
@@ -1001,6 +1007,7 @@ impl Default for Prefs {
perspective_presets: vec![],
blend_options: None,
perspective_widget: Default::default(),
import_matches: vec![],
}
}
}
@@ -1959,6 +1966,8 @@ mod tests_scripts;
#[cfg(test)]
mod tests_shaper;
#[cfg(test)]
mod tests_shortcutset;
#[cfg(test)]
mod tests_slices;
#[cfg(test)]
mod tests_smartguides;
+328
View File
@@ -0,0 +1,328 @@
//! Matching a name another app's file writes (a shortcut set's entry name) against VectorCraft's
//! commands, tools and panels, by their ids and labels only. A match is proposed when one candidate
//! scores well and clearly ahead of the next; otherwise the best few are offered to choose from.
use std::collections::HashMap;
/// A command, tool or panel a name can stand for.
#[derive(Clone, Debug)]
pub struct Candidate {
/// `select.all`, `tool:rectangle` or `panel:layers`.
pub id: String,
/// Its labels: the English one, and the one in the interface language.
pub labels: Vec<String>,
}
/// What a name stands for, as far as [`propose`] can tell.
#[derive(Clone, Debug, PartialEq)]
pub enum Proposal {
/// The candidate at this index of the list, with its score (0 to 1).
Match(usize, f64),
/// The best candidates, best first, when none stands out.
Choice(Vec<usize>),
}
/// The lowest score proposed.
const THRESHOLD: f64 = 0.6;
/// How far the best score must lead the next one to be proposed.
const MARGIN: f64 = 0.1;
/// The most candidates a [`Proposal::Choice`] lists.
const CHOICES: usize = 5;
/// The lowest score a [`Proposal::Choice`] lists.
const CHOICE_FLOOR: f64 = 0.3;
/// `s` with only its letters and digits, lowercased.
fn folded(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
}
/// The words of `s`, lowercased: runs of letters and digits, split where a lowercase letter or a digit
/// meets an uppercase one (`selectAll`: `select`, `all`).
fn words(s: &str) -> Vec<String> {
let mut out = vec![];
let mut word = String::new();
let mut prev_lower = false;
for c in s.chars() {
if !c.is_alphanumeric() {
if !word.is_empty() {
out.push(std::mem::take(&mut word));
}
prev_lower = false;
continue;
}
if c.is_uppercase() && prev_lower && !word.is_empty() {
out.push(std::mem::take(&mut word));
}
prev_lower = c.is_lowercase() || c.is_ascii_digit();
word.extend(c.to_lowercase());
}
if !word.is_empty() {
out.push(word);
}
out
}
/// The words of a candidate's id: its segments after any `tool:` or `panel:`, split as [`words`].
fn id_words(id: &str) -> Vec<String> {
let id = id.strip_prefix("tool:").or_else(|| id.strip_prefix("panel:")).unwrap_or(id);
id.split('.').flat_map(words).collect()
}
/// How many words `a` and `b` share, each word counted once, as a Dice coefficient (0 to 1).
fn word_overlap(a: &[String], b: &[String]) -> f64 {
if a.is_empty() || b.is_empty() {
return 0.0;
}
let mut rest: Vec<&String> = b.iter().collect();
let mut shared = 0usize;
for w in a {
if let Some(i) = rest.iter().position(|x| *x == w) {
rest.swap_remove(i);
shared += 1;
}
}
(2 * shared) as f64 / (a.len() + b.len()) as f64
}
/// The pairs of neighboring characters in `s`.
fn pairs(s: &str) -> Vec<(char, char)> {
let c: Vec<char> = s.chars().collect();
c.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect()
}
/// How many character pairs `a` and `b` (each sorted) share, as a Dice coefficient (0 to 1): for
/// names written without spaces. Linear in their lengths, so long names in a file stay quick.
fn pair_similarity(a: &[(char, char)], b: &[(char, char)]) -> f64 {
if a.is_empty() || b.is_empty() {
return 0.0;
}
let (mut i, mut j, mut shared) = (0usize, 0usize, 0usize);
while let (Some(p), Some(q)) = (a.get(i), b.get(j)) {
match p.cmp(q) {
std::cmp::Ordering::Less => i += 1,
std::cmp::Ordering::Greater => j += 1,
std::cmp::Ordering::Equal => {
shared += 1;
i += 1;
j += 1;
}
}
}
(2 * shared) as f64 / (a.len() + b.len()) as f64
}
/// A name or label as the scores compare it: its letters and digits (lowercased), its words and the
/// character pairs of the former (sorted).
struct Text {
folded: String,
words: Vec<String>,
pairs: Vec<(char, char)>,
}
impl Text {
fn of(s: &str) -> Self {
let folded = folded(s);
let mut pairs = pairs(&folded);
pairs.sort_unstable();
Self { pairs, words: words(s), folded }
}
}
/// A candidate as the scores compare it.
struct Prepared {
/// Its id's words, and those joined.
id_words: Vec<String>,
id: String,
labels: Vec<Text>,
}
impl Prepared {
fn of(c: &Candidate) -> Self {
let id_words = id_words(&c.id);
Self { id: id_words.concat(), id_words, labels: c.labels.iter().map(|l| Text::of(l)).collect() }
}
/// See [`score`].
fn score(&self, name: &Text) -> f64 {
if name.folded.is_empty() {
return 0.0;
}
if name.folded == self.id || self.labels.iter().any(|l| l.folded == name.folded) {
return 1.0;
}
let by_words = self.labels.iter().map(|l| word_overlap(&name.words, &l.words)).fold(word_overlap(&name.words, &self.id_words), f64::max);
let by_pairs = self.labels.iter().map(|l| pair_similarity(&name.pairs, &l.pairs)).fold(0.0, f64::max);
by_words.max(by_pairs * 0.9)
}
}
/// How well `name` fits `candidate` (0 to 1): 1 when it equals a label or the id, its letters and
/// digits compared without case; else the best of word overlap and character-pair similarity, the
/// latter counted a little lower.
pub fn score(name: &str, candidate: &Candidate) -> f64 {
Prepared::of(candidate).score(&Text::of(name))
}
/// The candidate `name` stands for, when one scores at least 0.6 and 0.1 ahead of every other; else
/// the best five that score at least 0.3, to choose from.
pub fn propose(name: &str, candidates: &[Candidate]) -> Proposal {
Matcher::new(candidates).propose(name)
}
/// [`propose`] for many names against the same candidates: each candidate's words and character
/// pairs are worked out once, and a name is scored only against the candidates that share a word or
/// a character pair with it (the others score 0).
pub struct Matcher {
prepared: Vec<Prepared>,
/// Candidates by their joined id words and their labels' letters and digits.
exact: HashMap<String, Vec<usize>>,
/// Candidates by a word of their id or labels.
by_word: HashMap<String, Vec<usize>>,
/// Candidates by a character pair of their labels.
by_pair: HashMap<(char, char), Vec<usize>>,
}
impl Matcher {
pub fn new(candidates: &[Candidate]) -> Self {
let prepared: Vec<Prepared> = candidates.iter().map(Prepared::of).collect();
let (mut exact, mut by_word, mut by_pair) = (HashMap::new(), HashMap::new(), HashMap::new());
fn add<K: std::hash::Hash + Eq>(map: &mut HashMap<K, Vec<usize>>, key: K, i: usize) {
let list = map.entry(key).or_default();
if list.last() != Some(&i) {
list.push(i);
}
}
for (i, p) in prepared.iter().enumerate() {
add(&mut exact, p.id.clone(), i);
for l in &p.labels {
add(&mut exact, l.folded.clone(), i);
}
for w in p.id_words.iter().chain(p.labels.iter().flat_map(|l| &l.words)) {
add(&mut by_word, w.clone(), i);
}
for pair in p.labels.iter().flat_map(|l| &l.pairs) {
add(&mut by_pair, *pair, i);
}
}
Self { prepared, exact, by_word, by_pair }
}
/// See [`propose`].
pub fn propose(&self, name: &str) -> Proposal {
let text = Text::of(name);
let mut seen = vec![false; self.prepared.len()];
let lists = self
.exact
.get(&text.folded)
.into_iter()
.chain(text.words.iter().filter_map(|w| self.by_word.get(w)))
// Each pair looked up once (they are sorted).
.chain(text.pairs.chunk_by(|a, b| a == b).filter_map(|c| self.by_pair.get(c.first()?)));
let mut scored: Vec<(usize, f64)> = vec![];
for &i in lists.flatten() {
if let Some(s) = seen.get_mut(i)
&& !*s
{
*s = true;
let score = self.prepared.get(i).map_or(0.0, |p| p.score(&text));
if score >= CHOICE_FLOOR {
scored.push((i, score));
}
}
}
scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
match (scored.first(), scored.get(1)) {
(Some(&(i, best)), next) if best >= THRESHOLD && next.is_none_or(|n| best - n.1 >= MARGIN) => Proposal::Match(i, best),
_ => Proposal::Choice(scored.iter().take(CHOICES).map(|(i, _)| *i).collect()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn candidates(list: &[(&str, &str)]) -> Vec<Candidate> {
list.iter().map(|(id, label)| Candidate { id: id.to_string(), labels: vec![label.to_string()] }).collect()
}
fn proposed<'a>(name: &str, list: &'a [Candidate]) -> Option<&'a str> {
match propose(name, list) {
Proposal::Match(i, _) => list.get(i).map(|c| c.id.as_str()),
Proposal::Choice(_) => None,
}
}
#[test]
fn extra_words_still_find_the_tool() {
let tools = candidates(&[
("tool:rectangle", "Rectangle Tool"),
("tool:roundedRectangle", "Rounded Rectangle Tool"),
("tool:rectangularGrid", "Rectangular Grid Tool"),
("tool:ellipse", "Ellipse Tool"),
]);
assert_eq!(proposed("Acme Rectangle Tool", &tools), Some("tool:rectangle"));
assert_eq!(proposed("Rounded Rectangle", &tools), Some("tool:roundedRectangle"));
assert_eq!(proposed("ellipse tool", &tools), Some("tool:ellipse"), "case aside, the label itself");
}
#[test]
fn letters_only_names_match_ids() {
let commands = candidates(&[("acme.copyAll", "Everything"), ("acme.copyAllOnPage", "Everything on This Page"), ("acme.clear", "Clear Out")]);
assert_eq!(proposed("acmecopyall", &commands), Some("acme.copyAll"));
assert_eq!(proposed("Acme Copy All On Page", &commands), Some("acme.copyAllOnPage"));
assert_eq!(proposed("clear out", &commands), Some("acme.clear"));
}
#[test]
fn ties_need_a_choice() {
let tools = candidates(&[("tool:rectangle", "Rectangle Tool"), ("tool:ellipse", "Ellipse Tool"), ("tool:star", "Star Tool")]);
assert_eq!(propose("Tool", &tools), Proposal::Choice(vec![0, 1, 2]));
assert_eq!(propose("Nothing Alike", &tools), Proposal::Choice(vec![]));
assert_eq!(propose("", &tools), Proposal::Choice(vec![]));
}
#[test]
fn names_without_spaces_match_by_character_pairs() {
let list = vec![
Candidate { id: "tool:rectangle".into(), labels: vec!["Rectangle Tool".into(), "長方形ツール".into()] },
Candidate { id: "tool:ellipse".into(), labels: vec!["Ellipse Tool".into(), "楕円形ツール".into()] },
];
assert_eq!(proposed("長方形ツール", &list), Some("tool:rectangle"));
assert_eq!(proposed("楕円形 ツール", &list), Some("tool:ellipse"), "spaces and punctuation aside");
assert_eq!(proposed("楕円形ツール2", &list), Some("tool:ellipse"), "most character pairs shared");
}
#[test]
fn the_index_finds_what_scoring_every_candidate_finds() {
let list = candidates(&[
("tool:rectangle", "Rectangle Tool"),
("tool:roundedRectangle", "Rounded Rectangle Tool"),
("tool:ellipse", "Ellipse Tool"),
("select.all", "All"),
("select.none", "Deselect"),
("edit.undo", "Undo"),
("foo.bar", "Zzz"),
("panel:layers", "Layers"),
]);
let matcher = Matcher::new(&list);
for name in ["Acme Rectangle Tool", "rounded", "acmecopyall", "foobar", "zz", "Lay", "Undo Redo", "ツール", "", "e"] {
let mut scored: Vec<(usize, f64)> =
list.iter().enumerate().map(|(i, c)| (i, score(name, c))).filter(|(_, s)| *s >= CHOICE_FLOOR).collect();
scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
let every = match (scored.first(), scored.get(1)) {
(Some(&(i, best)), next) if best >= THRESHOLD && next.is_none_or(|n| best - n.1 >= MARGIN) => Proposal::Match(i, best),
_ => Proposal::Choice(scored.iter().take(CHOICES).map(|(i, _)| *i).collect()),
};
assert_eq!(matcher.propose(name), every, "{name}");
}
assert_eq!(proposed("foobar", &list), Some("foo.bar"), "the id alone, sharing no word or pair with the label");
}
#[test]
fn words_split_camel_case_and_separators() {
assert_eq!(words("selectAll_onArtboard 2x"), ["select", "all", "on", "artboard", "2x"]);
assert_eq!(words("Acme\u{a0}Lasso-Tool"), ["acme", "lasso", "tool"]);
assert_eq!(id_words("tool:roundedRectangle"), ["rounded", "rectangle"]);
}
}
+246
View File
@@ -0,0 +1,246 @@
//! Shortcut sets another app saved as text (`shortcutSet.read`): entries, sections, codes, the
//! tokens left unread and the caps; and the matches kept from earlier imports (`importMatches.*`).
//! Every set here is written for the test, with invented names.
use serde_json::{Value, json};
use super::*;
use crate::cmd::importmatches;
use crate::cmd::shortcutset::{KysEntry, NOT_READABLE, read_kys};
fn read(text: &str) -> Value {
Session::new().execute("shortcutSet.read", &json!({ "data": text, "name": "My Keys.kys" })).unwrap()
}
#[test]
fn reads_an_entry_with_its_codes() {
let text = "/Menus {\n /Acme\\ Lasso\\ Tool {\n /Represent 7\n /Key 48\n /Context 2\n /Modifiers 0\n }\n}\n";
let set = read_kys(text.as_bytes()).unwrap();
let entry = KysEntry {
section: vec!["Menus".into()],
name: "Acme Lasso Tool".into(),
key: Some(48),
modifiers: Some(0),
context: Some(2),
represent: Some(7),
line: 2,
};
assert_eq!((set.entries, set.unread, set.truncated), (vec![entry], vec![], false));
}
#[test]
fn entries_keep_their_sections_and_the_command_counts_codes() {
let text = "/Menus {\n /Edit {\n /Acme\\ Undo { /Key 90 /Modifiers 64 }\n /Acme\\ Redo { /Key 90 /Modifiers 80 }\n }\n /Acme\\ Zoom { /Key 61 /Modifiers 64 }\n /Group\\ Only { /Context 1 }\n}\n";
let r = read(text);
assert_eq!(r["set"], "My Keys");
let names: Vec<(&str, Vec<&str>, usize)> = r["entries"]
.as_array()
.unwrap()
.iter()
.map(|e| {
(
e["name"].as_str().unwrap(),
e["section"].as_array().unwrap().iter().map(|s| s.as_str().unwrap()).collect(),
e["line"].as_u64().unwrap() as usize,
)
})
.collect();
assert_eq!(names, [("Acme Undo", vec!["Menus", "Edit"], 3), ("Acme Redo", vec!["Menus", "Edit"], 4), ("Acme Zoom", vec!["Menus"], 6)]);
assert_eq!(r["entries"][0]["context"], Value::Null, "a code the entry doesn't set");
assert_eq!(r["codes"]["keys"], json!([{"value": 90, "count": 2}, {"value": 61, "count": 1}]));
assert_eq!(r["codes"]["modifiers"], json!([{"value": 64, "count": 2}, {"value": 80, "count": 1}]));
assert_eq!((r["unread"].as_array().unwrap().len(), r["truncated"].as_bool()), (0, Some(false)), "{r}");
// The same from a file, and from its bytes.
let dir = vectorcraft_testkit::temp_dir("shortcutset-read");
let path = dir.join("Studio Keys.kys");
std::fs::write(&path, text).unwrap();
let mut s = Session::new();
let r = s.execute("shortcutSet.read", &json!({ "path": path.to_string_lossy() })).unwrap();
assert_eq!((r["set"].as_str(), r["entries"].as_array().unwrap().len()), (Some("Studio Keys"), 3));
let b64 = vectorcraft_format::base64_encode(text.as_bytes());
assert_eq!(s.execute("shortcutSet.read", &json!({ "dataBase64": b64 })).unwrap()["entries"].as_array().unwrap().len(), 3);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn refuses_xml_shortcut_files() {
let e = read_kys(b"\xEF\xBB\xBF<?xml version=\"1.0\"?>\n<shortcuts/>").unwrap_err();
assert_eq!(e, NOT_READABLE);
assert!(read_kys(b"").unwrap_err().starts_with("not a shortcut set"));
assert!(read_kys(b"/Menus 3").unwrap_err().starts_with("not a shortcut set"));
let e = Session::new().execute("shortcutSet.read", &json!({ "data": "<shortcuts/>" })).unwrap_err().to_string();
assert!(e.contains(NOT_READABLE), "{e}");
assert!(Session::new().execute("shortcutSet.read", &json!({})).is_err());
}
#[test]
fn lists_unknown_tokens_with_their_lines() {
// CR, LF and CRLF each end a line.
let text = "/Menus {\r/Acme\\ Tool {\r\n/Key 66 /Bogus 3 junk\n/Modifiers\n}\n/Dangling\n}\n} /Key 70";
let set = read_kys(text.as_bytes()).unwrap();
assert_eq!(set.entries.len(), 1);
assert_eq!(set.entries[0].modifiers, None, "/Modifiers without a value");
let unread: Vec<(usize, &str)> = set.unread.iter().map(|(l, t)| (*l, t.as_str())).collect();
assert_eq!(unread, [(3, "/Bogus 3"), (3, "junk"), (4, "/Modifiers"), (6, "/Dangling"), (8, "}"), (8, "/Key 70")]);
// A number too large, and a name ending in a backslash at the end of the text.
let set = read_kys(b"/Menus { /A { /Key 99999999999999999999 } } /End\\").unwrap();
assert!(set.entries.is_empty());
assert_eq!(set.unread, [(1, "/Key (a number too large)".to_string()), (1, "/End".to_string())]);
// A block left open.
let set = read_kys(b"/Menus { /A { /Key 1 }").unwrap();
assert_eq!((set.entries.len(), set.unread.clone()), (1, vec![(1, "/Menus { (not closed)".to_string())]));
}
#[test]
fn caps_nesting_entries_and_names() {
// Blocks past 16 deep are skipped, and what follows them is read.
let deep = format!("/Menus {{ {} /Inner {{ /Key 1 }} {} /After {{ /Key 2 }} }}", "/A { ".repeat(20), "} ".repeat(20));
let set = read_kys(deep.as_bytes()).unwrap();
assert!(set.truncated);
assert_eq!(set.entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["After"]);
// At most 20,000 entries.
let many = format!("/Menus {{ {} }}", "/E { /Key 1 } ".repeat(20_005));
let set = read_kys(many.as_bytes()).unwrap();
assert_eq!((set.entries.len(), set.truncated), (20_000, true));
// A name is cut at 1,024 bytes, between characters.
let long = format!("/Menus {{ /{} {{ /Key 1 }} }}", "é".repeat(700));
let set = read_kys(long.as_bytes()).unwrap();
assert_eq!((set.entries[0].name.chars().count(), set.truncated), (512, true));
// At most 1,000 unread tokens.
let junk = format!("/Menus {{ {} }}", "x ".repeat(1_500));
assert_eq!(read_kys(junk.as_bytes()).unwrap().unread.len(), 1000);
// At most 4 MiB of text.
let big = format!("/Menus {{ /E {{ /Key 1 }} {} }}", " ".repeat(4 << 20));
let set = read_kys(big.as_bytes()).unwrap();
assert_eq!((set.entries.len(), set.truncated), (1, true));
// Long section names repeated for every entry stop at 8 MiB of names, not hundreds of megabytes.
let open: String = (0..15).map(|i| format!("/{}{i} {{ ", "s".repeat(1020))).collect();
let wide = format!("{open}{}", "/E{/Key 1}".repeat(19_000));
let set = read_kys(wide.as_bytes()).unwrap();
let kept: usize = set.entries.iter().map(|e| e.name.len() + e.section.iter().map(String::len).sum::<usize>()).sum();
assert!(set.truncated && kept <= 8 << 20 && set.entries.len() > 500, "{} entries, {kept} bytes", set.entries.len());
}
#[test]
fn big_files_are_read_up_to_the_cap() {
let text = format!("/Menus {{ /E {{ /Key 1 }} {} }}", " ".repeat(5 << 20));
let dir = vectorcraft_testkit::temp_dir("shortcutset-big");
let path = dir.join("Big.kys");
std::fs::write(&path, &text).unwrap();
let b64 = vectorcraft_format::base64_encode(text.as_bytes());
// Base64 broken into lines decodes as well.
let lines: String = b64.as_bytes().chunks(76).map(|l| format!("{}\n", String::from_utf8_lossy(l))).collect();
let mut s = Session::new();
for p in [json!({ "path": path.to_string_lossy() }), json!({ "data": text }), json!({ "dataBase64": b64 }), json!({ "dataBase64": lines })] {
let r = s.execute("shortcutSet.read", &p).unwrap();
assert_eq!(
(r["entries"].as_array().unwrap().len(), r["truncated"].as_bool()),
(1, Some(true)),
"{}",
p.as_object().unwrap().keys().next().unwrap()
);
}
// A byte order mark doesn't count toward the cap.
let fits = format!("\u{feff}/Menus {{ /E {{ /Key 1 }} {}}}", " ".repeat((4 << 20) - 24));
assert_eq!(fits.len(), (4 << 20) + 3);
std::fs::write(&path, &fits).unwrap();
assert_eq!(s.execute("shortcutSet.read", &json!({ "path": path.to_string_lossy() })).unwrap()["truncated"], false);
let _ = std::fs::remove_dir_all(dir);
}
fn matched(s: &mut Session, p: Value) -> Vec<String> {
let r = s.execute("importMatches.list", &p).unwrap();
r.as_array().unwrap().iter().map(|m| m["name"].as_str().unwrap().to_string()).collect()
}
#[test]
fn import_matches_are_kept_replaced_and_forgotten() {
let mut s = Session::new();
let matches = json!([
{"kind": "name", "name": "Acme Undo", "target": "edit.undo", "label": "Undo"},
{"kind": "name", "name": "Acme Gadget", "target": ""},
{"kind": "key", "name": "90", "target": "Z"},
{"kind": "modifiers", "name": "64", "target": "Cmd"},
]);
assert_eq!(s.execute("importMatches.set", &json!({ "matches": matches })).unwrap()["count"], 4);
assert_eq!(matched(&mut s, json!({"kind": "name"})), ["Acme Undo", "Acme Gadget"]);
assert_eq!(matched(&mut s, json!({"query": "UNDO"})), ["Acme Undo"], "the name, target or label, any case");
assert_eq!(matched(&mut s, json!({"query": "cmd"})), ["64"]);
// A later answer replaces the earlier one, and null forgets it.
let later = json!([{"kind": "name", "name": "Acme Gadget", "target": "object.group"}, {"kind": "key", "name": "90", "target": null}]);
assert_eq!(s.execute("importMatches.set", &json!({ "matches": later })).unwrap()["count"], 3);
let targets = importmatches::targets(&s.prefs);
assert_eq!((targets.get(&("name", "Acme Gadget")), targets.get(&("key", "90"))), (Some(&"object.group"), None));
// A bad match fails the whole call.
for bad in [
json!([{"kind": "name", "name": "Fine", "target": "edit.copy"}, {"kind": "colour", "name": "Red", "target": ""}]),
json!([{"kind": "name", "name": "x".repeat(1025), "target": ""}]),
json!([{"kind": "name", "name": "", "target": ""}]),
json!([{"kind": "name", "name": "Fine", "target": 3}]),
json!((0..20_001).map(|i| json!({"kind": "key", "name": i.to_string(), "target": "K"})).collect::<Vec<_>>()),
] {
assert!(s.execute("importMatches.set", &json!({ "matches": bad })).is_err());
}
assert!(s.execute("importMatches.list", &json!({"kind": "colour"})).is_err());
assert_eq!(s.prefs.import_matches.len(), 3);
// Forgetting one kind, then the rest.
assert_eq!(s.execute("importMatches.reset", &json!({"kind": "modifiers"})).unwrap()["removed"], 1);
assert_eq!(s.execute("importMatches.reset", &json!({})).unwrap()["removed"], 2);
}
#[test]
fn import_matches_survive_a_preferences_reset_and_a_restart() {
let mut s = Session::new();
s.execute("importMatches.set", &json!({"matches": [{"kind": "name", "name": "Acme Undo", "target": "edit.undo"}]})).unwrap();
let saved = s.prefs.to_json();
assert_eq!(saved["importMatches"][0]["target"], "edit.undo");
s.execute("prefs.reset", &json!({})).unwrap();
assert_eq!(s.prefs.import_matches.len(), 1);
let mut fresh = Session::new();
fresh.apply_prefs(serde_json::from_value(saved).unwrap());
assert_eq!(matched(&mut fresh, json!({})), ["Acme Undo"]);
// Nothing is written while there are none, older preference files load with none, and a damaged
// match doesn't stop the preferences loading.
assert!(Prefs::default().to_json().get("importMatches").is_none());
let old: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2})).unwrap();
assert!(old.import_matches.is_empty());
let damaged: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2, "importMatches": [{"name": "Acme Undo"}]})).unwrap();
assert_eq!((damaged.keyboard_increment, damaged.import_matches[0].target.as_str()), (2.0, ""));
}
#[test]
fn read_shows_what_earlier_imports_matched() {
let mut s = Session::new();
let matches = json!([
{"kind": "name", "name": "Acme Undo", "target": "edit.undo"},
{"kind": "key", "name": "90", "target": "Z"},
{"kind": "key", "name": "61", "target": ""},
{"kind": "modifiers", "name": "64", "target": "Cmd"},
{"kind": "modifiers", "name": "0", "target": ""},
]);
s.execute("importMatches.set", &json!({ "matches": matches })).unwrap();
let text = "/Menus {\n /Acme\\ Undo { /Key 90 /Modifiers 64 }\n /Acme\\ Redo { /Key 90 /Modifiers 80 }\n /Acme\\ Zoom { /Key 61 /Modifiers 0 }\n /Acme\\ Tool { /Key 90 }\n}\n";
let r = s.execute("shortcutSet.read", &json!({ "data": text })).unwrap();
let e = &r["entries"];
assert_eq!((e[0]["target"].as_str(), e[0]["chord"].as_str()), (Some("edit.undo"), Some("Cmd+Z")));
assert_eq!((e[1].get("target"), e[1].get("chord")), (None, None), "modifiers 80 aren't matched yet");
assert_eq!(e[2].get("chord"), None, "a key with no equivalent");
assert_eq!(e[3]["chord"], "Z", "an entry without /Modifiers takes none");
assert_eq!(r["codes"]["keys"], json!([{"value": 90, "count": 3, "key": "Z"}, {"value": 61, "count": 1, "key": ""}]));
let mods = json!([{"value": 0, "count": 1, "mods": ""}, {"value": 64, "count": 1, "mods": "Cmd"}, {"value": 80, "count": 1}]);
assert_eq!(r["codes"]["modifiers"], mods);
}
#[test]
fn the_commands_take_only_the_keys_they_describe() {
// The control channel, MCP and the CLI check params against the description (#1442).
for (id, every_key) in [
("shortcutSet.read", json!({"path": "/x/a.kys", "data": "", "dataBase64": "", "name": "a.kys"})),
("importMatches.list", json!({"kind": "name", "query": "undo"})),
("importMatches.set", json!({"matches": []})),
("importMatches.reset", json!({"kind": "key"})),
] {
check_params(id, &every_key).unwrap();
assert!(check_params(id, &json!({"qeury": "undo"})).is_err(), "{id}");
}
}