mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
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:
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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() }))
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user