Keyboard Shortcuts: Import… and File › Open read text shortcut sets (.kys)

A shortcut set another app saved as slash-name text (a .kys file whose
text starts with a slash name and "{", such as "/Menus {") opens in
Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts ›
Import… in the desktop app, or from a drop on the window, whether or
not a document is open. The dialog proposes a target for an entry when
an earlier import kept one for that name, or else when one tool,
command or panel matches the name clearly by its id or by one of its
labels. It decodes the file's key and modifier codes from the codes
earlier imports kept, from the example in the AiCommandPalette README
(/Key 48 for the 0 key, with /Modifiers 0 taken as no modifiers), and
from VectorCraft's default shortcuts where at least two entries vote and
four votes in five agree. The user picks the remaining targets, presses
the key for each unknown key code or marks it No equivalent, and chooses
the modifiers for each unknown modifier code. Apply assigns the ready
rows with the Keyboard Shortcuts rules: a tool takes a single key,
optionally with Shift. It goes through the rows from the last one back
and leaves a row out when a later row it applies has the same shortcut
or the same tool, command or panel. Keyboard Shortcuts then shows the
result as unsaved changes until the user clicks OK. Copy Report copies
the set's name, the rows Apply leaves out and the parts of the file that
weren't read.

- shortcutSet.read is a query command: it reads a set and does not
  modify the document or the preferences. It returns each entry with
  its section, codes and line, and up to 1,000 skipped tokens, each cut
  to 80 characters. It reads at most 4 MiB of text and 20,000 entries,
  skips blocks nested more than 16 deep and cuts names at 1,024 bytes.
  With a path it reads at most 4 MiB and 4 bytes of the file, and with
  --automation-read-root (#832) the path must lie inside the read root.
  A file whose text starts with "<", such as an XML shortcut file saved
  as UTF-8, fails with "This shortcut file is in a form VectorCraft
  can't read."
- importMatches.list, importMatches.set and importMatches.reset read
  and change the matches kept with the preferences; resetting the
  preferences keeps them. With Remember My Choices on (the default),
  Apply keeps the targets and codes the user chose, and for the rows it
  assigns, it also keeps the matched targets and the codes decoded by
  agreement. A later import proposes them.
- The name matcher compares a name with ids by exact match and shared
  words. It compares the name with labels, in English and in the
  interface language, by exact match, shared words and character
  pairs. It proposes a match only when one tool, command or panel
  scores at least 0.6 and at least 0.1 more than the next one.
- When Import Shortcut Set is opened from Keyboard Shortcuts, Cancel,
  Escape and ui.dialog.cancel close it and return to Keyboard Shortcuts
  as it was (dialogs::PARENT). A document's queued Missing Fonts dialog
  (#792) opens after Keyboard Shortcuts closes.
- File › Open has a "Shortcut sets" filter, and in the desktop app
  Keyboard Shortcuts' Import… shows .json and .kys files.

Docs: docs/mcp.md, docs/control-protocol.md, docs/development.md,
docs/target-app-parity.md, docs/roadmap.md.
This commit is contained in:
dotCooCoo authored and bflatastic committed 2026-10-14 13:22:07 +07:00
1 parent b076025a31
commit 8c14609370
36 files changed
+3137 -30

No files matched your search

+1 -1
View File
@@ -310,7 +310,7 @@ fn notice(lines: &[&str]) {
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened
/// (libraries, presets and plug-ins open wherever they are dropped).
/// (libraries, presets, plug-ins and shortcut sets open wherever they are dropped).
/// When the graphics are lost it hands the app to a new runner on a new canvas.
struct WebShell {
/// `None` once handed on.
+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
})
}
+387
View File
@@ -0,0 +1,387 @@
//! 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 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; 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;
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() {
if set.entries.len() >= MAX_ENTRIES {
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;
+321
View File
@@ -0,0 +1,321 @@
//! 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` share, as a Dice coefficient (0 to 1): for names written
/// without spaces.
fn pair_similarity(a: &[(char, char)], b: &[(char, char)]) -> f64 {
if a.is_empty() || b.is_empty() {
return 0.0;
}
let mut rest: Vec<&(char, char)> = b.iter().collect();
let mut shared = 0usize;
for p in a {
if let Some(i) = rest.iter().position(|q| *q == p) {
rest.swap_remove(i);
shared += 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.
struct Text {
folded: String,
words: Vec<String>,
pairs: Vec<(char, char)>,
}
impl Text {
fn of(s: &str) -> Self {
let folded = folded(s);
Self { pairs: pairs(&folded), 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)))
.chain(text.pairs.iter().filter_map(|p| self.by_pair.get(p)));
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"]);
}
}
+226
View File
@@ -0,0 +1,226 @@
//! 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));
}
#[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);
}
+69 -1
View File
@@ -1,6 +1,6 @@
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input,
//! mutated raster images (Photoshop documents among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`) and flattener preset
//! (`.vcflattener`) libraries, and native files (compressed, damaged, saved for older versions),
//! (`.vcflattener`) libraries, shortcut sets other apps save as text (`.kys`), and native files (compressed, damaged, saved for older versions),
//! must load as an error or as a document that then renders and exports, without a panic; nor may
//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records
//! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files
@@ -1011,6 +1011,74 @@ proptest! {
}
}
// ---------- shortcut sets ----------
/// Shortcut set `bytes` read with `shortcutSet.read`, with import matches saved to annotate it: an
/// error, or a set within the caps.
fn shortcut_set(bytes: &[u8]) -> Result<(), TestCaseError> {
let mut s = vectorcraft_engine::Session::new();
let matches = json!([
{"kind": "name", "name": "A", "target": "edit.undo"},
{"kind": "key", "name": "1", "target": "K"},
{"kind": "modifiers", "name": "-1", "target": "Cmd"},
]);
s.execute("importMatches.set", &json!({ "matches": matches })).unwrap();
let read = json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": "fuzz.kys"});
if let Ok(r) = no_panic(s.execute("shortcutSet.read", &read)) {
prop_assert!(r["entries"].as_array().map_or(0, Vec::len) <= 20_000);
prop_assert!(r["unread"].as_array().map_or(0, Vec::len) <= 1000);
}
Ok(())
}
/// A token of a text shortcut set: a name (escaped spaces), a field, an integer of any size and sign,
/// a brace, a lone backslash, a line end, NUL or bytes that aren't UTF-8.
fn arb_kys_token() -> impl Strategy<Value = Vec<u8>> {
prop_oneof![
"[A-Za-z ]{0,12}".prop_map(|n| format!("/{}", n.replace(' ', "\\ ")).into_bytes()),
prop::sample::select(vec!["/Key", "/Modifiers", "/Context", "/Represent"]).prop_map(|f| f.as_bytes().to_vec()),
any::<i64>().prop_map(|n| n.to_string().into_bytes()),
Just(b"99999999999999999999999".to_vec()),
prop::sample::select(vec!["{", "}", "\\", "\r\n", "\r", "\n"]).prop_map(|t| t.as_bytes().to_vec()),
prop::sample::select(vec![vec![0u8], vec![0xFF, 0xFE], vec![0xC3]]),
]
}
proptest! {
#![proptest_config(config())]
#[test]
fn shortcut_set_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..600), head in any::<bool>()) {
let mut data = if head { b"/Menus {".to_vec() } else { vec![] };
data.extend(bytes);
shortcut_set(&data)?;
}
#[test]
fn hostile_shortcut_sets_never_panic(tokens in prop::collection::vec(arb_kys_token(), 0..200), sep in prop::sample::select(vec![" ", "", "\t"])) {
let mut data = b"/Menus {".to_vec();
for t in tokens {
data.extend(sep.as_bytes());
data.extend(t);
}
shortcut_set(&data)?;
}
}
/// A shortcut set past any cap (nesting, entries, name length, file size) is read up to it and
/// marked truncated.
#[test]
fn shortcut_sets_past_the_caps_are_truncated() {
let read = |text: String| vectorcraft_engine::Session::new().execute("shortcutSet.read", &json!({ "data": text })).unwrap();
let deep = format!("/Menus {{ {} /K {{ /Key 1 }} }}", "/A { ".repeat(40));
let many = format!("/Menus {{ {} }}", "/E { /Key 1 } ".repeat(20_001));
let long = format!("/Menus {{ /{} {{ /Key 1 }} }}", "n".repeat(2000));
let big = format!("/Menus {{ /E {{ /Key 1 }} {} }}", " ".repeat(4 << 20));
for text in [deep, many, long, big] {
assert_eq!(read(text)["truncated"], true);
}
}
/// A small image of each raster format Place reads, with resolution metadata where it has some.
fn raster_samples() -> Vec<(&'static str, Vec<u8>)> {
let img = image::RgbImage::from_pixel(5, 3, image::Rgb([200, 40, 10]));
+21 -3
View File
@@ -80,6 +80,7 @@ pub mod save_style_library;
pub mod save_swatch_library;
pub mod script_check;
mod shapes;
pub mod shortcut_import;
pub mod slices;
pub mod spot_colors;
pub(crate) mod svg_options;
@@ -305,6 +306,7 @@ registry! {
LayerOptions: [layer_options::KIND] => layer_options::SPEC,
LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC,
RearrangeArtboards: [rearrange_artboards::KIND] => rearrange_artboards::SPEC,
ShortcutImport: [shortcut_import::KIND] => DialogSpec::window(shortcut_import::show, shortcut_import::confirm),
}
/// The button labels the shared dialog frame can show (OK, discard and the fixed Cancel/Close),
@@ -333,11 +335,24 @@ fn run_and_close(app: &mut VectorcraftApp, id: &str, params: Value) -> DialogRes
r
}
/// Close the open dialog as Cancel does, rolling back a live preview (`ui.dialog.cancel`).
/// The field of a dialog opened over another (Import Shortcut Set over Keyboard Shortcuts) that
/// holds the dialog under it, which Cancel and Escape show again.
pub(crate) const PARENT: &str = "__parent";
/// The dialog `d` was opened over, as it was.
pub(crate) fn parent(d: &Dialog) -> Option<Dialog> {
d.fields.get(PARENT).and_then(|v| serde::Deserialize::deserialize(v).ok())
}
/// Close the open dialog as Cancel does, rolling back a live preview and showing the dialog it was
/// opened over (`ui.dialog.cancel`).
pub fn cancel(app: &mut VectorcraftApp) {
missing_fonts::requeue_replaced(app);
if app.ui.dialog.take().is_some_and(|d| spec(&d.kind).preview) {
let _ = app.session.cancel_interaction();
if let Some(d) = app.ui.dialog.take() {
if spec(&d.kind).preview {
let _ = app.session.cancel_interaction();
}
app.ui.dialog = parent(&d);
}
settle(app);
}
@@ -541,3 +556,6 @@ mod tests_modal;
#[cfg(test)]
mod tests_rectangular_grid;
#[cfg(test)]
mod tests_shortcut_import;
@@ -0,0 +1,899 @@
//! Import Shortcut Set: a shortcut set another app saved as text (`.kys`, read by `shortcutSet.read`),
//! reviewed before it fills Edit › Keyboard Shortcuts' working set.
//!
//! Each entry's name is matched against the tools, commands and panels Keyboard Shortcuts lists
//! ([`vectorcraft_engine::namematch`]). Its key and modifier codes are decoded from the user's earlier
//! answers (`importMatches`), from the two codes the format's public description gives (`/Key 48` is
//! the 0 key and `/Modifiers 0` is none), and by agreement with VectorCraft's default shortcuts: each
//! entry whose match has a default votes for what its codes stand for, and a code is proposed when at
//! least two entries vote for it and four votes in five agree. The user answers the rest in the Keys
//! section. Apply assigns the rows that are ready, in the file's order, through
//! [`shortcut_editor::assign`]; when two rows want one shortcut, the later row takes it. Keyboard
//! Shortcuts then shows the set, and its OK keeps it.
//!
//! Fields: `name`, `start` (`defaults` or `current`), `remember`, `rows` ([`Row`]s), `keys` and `mods`
//! ([`Code`]s by code), `notes` (`[{line, text}]`: the parts of the file not read), `truncated`,
//! `__parent` (the Keyboard Shortcuts dialog it was opened from, which Apply, Cancel and Escape go back
//! to), `__recording` (the key code waiting for a key press), `__keysOpen` (the Keys section is open:
//! at first when a code is not known), and `__query` and `__attention` (the filter of the rows shown).
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use vectorcraft_engine::namematch::{Candidate, Matcher, Proposal};
use crate::shortcut_editor::{self, Entry};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{FilePick, VectorcraftApp, menus, widgets};
/// The dialog kind.
pub const KIND: &str = "shortcutImport";
/// The files Keyboard Shortcuts' Import… lists: VectorCraft's own sets and sets another app saved as
/// text.
pub const IMPORT_EXTS: &[&str] = &["json", "kys"];
/// The codes the format's public description gives: `/Key 48` is the 0 key.
const DESCRIBED_KEYS: &[(i64, &str)] = &[(48, "0")];
/// `/Modifiers 0` is none.
const DESCRIBED_MODS: &[(i64, &str)] = &[(0, "")];
/// The least votes a code needs for agreement to propose it.
const MIN_VOTES: usize = 2;
/// The share of the votes that must agree: four in five.
const AGREE: (usize, usize) = (4, 5);
/// The most entry names a code lists.
const NAMES_SHOWN: usize = 3;
/// The modifiers the Keys section offers, as Edit › Keyboard Shortcuts writes them ("" is none).
const MODIFIERS: &[&str] = &[
"",
"Cmd",
"Ctrl",
"Alt",
"Shift",
"Cmd+Ctrl",
"Cmd+Alt",
"Cmd+Shift",
"Ctrl+Alt",
"Ctrl+Shift",
"Alt+Shift",
"Cmd+Ctrl+Alt",
"Cmd+Ctrl+Shift",
"Cmd+Alt+Shift",
"Ctrl+Alt+Shift",
"Cmd+Ctrl+Alt+Shift",
];
/// The table's row height and column widths (points).
const ROW_H: f32 = 22.0;
const NAME_W: f32 = 200.0;
const TARGET_W: f32 = 230.0;
const CHORD_W: f32 = 90.0;
/// The tallest the Keys and Shortcuts lists get (points).
const KEYS_LIST_H: f32 = 140.0;
const ROWS_LIST_H: f32 = 280.0;
/// An entry of the file and what it becomes.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Row {
/// As the file writes it.
pub name: String,
/// The names of the blocks around it (`Menus › Edit`).
pub section: String,
pub line: usize,
pub key: Option<i64>,
pub modifiers: Option<i64>,
/// A key of [`shortcut_editor::entries`], or "" for no equivalent; none until chosen.
pub target: Option<String>,
/// Where the target came from: `saved` (an earlier import), `matched` (the name) or `you`.
pub from: String,
/// The candidates to choose from when the name matched none clearly, best first.
pub choices: Vec<String>,
/// Checked: Apply assigns it.
pub on: bool,
}
/// What a key or modifier code stands for.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Code {
/// A key, or modifiers, as Edit › Keyboard Shortcuts writes them: "" for a key with no equivalent
/// or for no modifiers. None until known.
pub text: Option<String>,
/// `saved` (an earlier import), `described` (the format's description), `agreement` or `you`; ""
/// while unknown.
pub from: String,
/// For `agreement`: the entries whose default shortcut agrees.
pub votes: usize,
/// The first few entries that use it.
pub names: Vec<String>,
}
/// What happens to a row on Apply.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
Ready,
/// Unchecked.
Off,
NeedsChoice,
NeedsKey,
NoEquivalent,
/// A tool's shortcut with Cmd, Alt or Ctrl.
ToolKey,
/// A later row takes the same shortcut.
SameShortcut,
/// A later row gives the same tool, command or panel its shortcut.
SameTarget,
}
impl Status {
pub fn label(self) -> &'static str {
match self {
Self::Ready => tl!("Ready"),
Self::Off => tl!("Off"),
Self::NeedsChoice => tl!("Needs a choice"),
Self::NeedsKey => tl!("Needs a key"),
Self::NoEquivalent => tl!("No equivalent"),
Self::ToolKey => tl!("Tools take a single key (optionally with Shift)"),
Self::SameShortcut => tl!("A later row takes this shortcut"),
Self::SameTarget => tl!("Same item as a later row"),
}
}
/// The user has something to answer or change.
pub fn needs_attention(self) -> bool {
matches!(self, Self::NeedsChoice | Self::NeedsKey | Self::ToolKey | Self::SameShortcut | Self::SameTarget)
}
}
/// The rows and codes of a review.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Review {
pub rows: Vec<Row>,
pub keys: BTreeMap<i64, Code>,
pub mods: BTreeMap<i64, Code>,
}
impl Review {
/// The review of `read`, `shortcutSet.read`'s result, with names matched against `candidates`
/// ([`candidates`]).
pub fn build(read: &Value, candidates: &[Candidate]) -> Self {
let matcher = Matcher::new(candidates);
let id = |i: usize| candidates.get(i).map(|c| c.id.clone());
let mut rows = vec![];
for e in read["entries"].as_array().into_iter().flatten() {
let name = e["name"].as_str().unwrap_or_default().to_string();
let (target, from, choices) = match e["target"].as_str() {
Some(t) => (Some(t.to_string()), "saved", vec![]),
None => match matcher.propose(&name) {
Proposal::Match(i, _) => (id(i), "matched", vec![]),
Proposal::Choice(best) => (None, "", best.into_iter().filter_map(id).collect()),
},
};
rows.push(Row {
section: e["section"].as_array().into_iter().flatten().filter_map(Value::as_str).collect::<Vec<_>>().join(" › "),
line: e["line"].as_u64().unwrap_or(0) as usize,
key: e["key"].as_i64(),
modifiers: e["modifiers"].as_i64(),
target,
from: from.into(),
choices,
on: true,
name,
});
}
let codes = |list: &Value, field: &str, described: &[(i64, &str)], of: fn(&Row) -> Option<i64>| -> BTreeMap<i64, Code> {
let mut out = BTreeMap::new();
for c in list.as_array().into_iter().flatten() {
let Some(value) = c["value"].as_i64() else { continue };
let names = rows.iter().filter(|r| of(r) == Some(value)).take(NAMES_SHOWN).map(|r| r.name.clone()).collect();
let (text, from) = match (c[field].as_str(), described.iter().find(|(v, _)| *v == value)) {
(Some(t), _) => (Some(t.to_string()), "saved"),
(None, Some((_, t))) => (Some(t.to_string()), "described"),
(None, None) => (None, ""),
};
out.insert(value, Code { text, from: from.into(), votes: 0, names });
}
out
};
let keys = codes(&read["codes"]["keys"], "key", DESCRIBED_KEYS, |r| r.key);
let mods = codes(&read["codes"]["modifiers"], "mods", DESCRIBED_MODS, |r| r.modifiers);
let mut review = Self { rows, keys, mods };
review.decode();
review
}
/// The review `d` holds.
pub fn of(d: &Dialog) -> Self {
fn field<T: serde::de::DeserializeOwned + Default>(d: &Dialog, k: &str) -> T {
d.fields.get(k).and_then(|v| T::deserialize(v).ok()).unwrap_or_default()
}
Self { rows: field(d, "rows"), keys: field(d, "keys"), mods: field(d, "mods") }
}
fn store(&self, d: &mut Dialog) {
d.fields.insert("rows".into(), serde_json::to_value(&self.rows).unwrap_or_else(|_| json!([])));
d.fields.insert("keys".into(), serde_json::to_value(&self.keys).unwrap_or_else(|_| json!({})));
d.fields.insert("mods".into(), serde_json::to_value(&self.mods).unwrap_or_else(|_| json!({})));
}
/// Codes that no earlier import, description or user answered take what agreement with
/// VectorCraft's default shortcuts proposes for them now.
pub fn decode(&mut self) {
let mut defaults: HashMap<&str, Option<(String, String)>> = HashMap::new();
let mut keys: BTreeMap<i64, HashMap<String, usize>> = BTreeMap::new();
let mut mods: BTreeMap<i64, HashMap<String, usize>> = BTreeMap::new();
for r in &self.rows {
let Some(t) = r.target.as_deref().filter(|t| !t.is_empty()) else { continue };
let default =
defaults.entry(t).or_insert_with(|| shortcut_editor::default_of(t).and_then(shortcut_editor::normalize).map(|c| split_chord(&c)));
let Some((m, k)) = default else { continue };
if let Some(code) = r.key {
*keys.entry(code).or_default().entry(k.clone()).or_default() += 1;
}
if let Some(code) = r.modifiers {
*mods.entry(code).or_default().entry(m.clone()).or_default() += 1;
}
}
for (codes, votes) in [(&mut self.keys, keys), (&mut self.mods, mods)] {
for (code, c) in codes.iter_mut().filter(|(_, c)| c.from.is_empty() || c.from == "agreement") {
let names = std::mem::take(&mut c.names);
*c = match votes.get(code).and_then(agreed) {
Some((text, votes)) => Code { text: Some(text), from: "agreement".into(), votes, names },
None => Code { names, ..Code::default() },
};
}
}
}
/// Row `r`'s shortcut as Edit › Keyboard Shortcuts writes it: `Some(None)` when its key has no
/// equivalent, none while one of its codes isn't known. An entry without `/Modifiers` takes none.
pub fn chord(&self, r: &Row) -> Option<Option<String>> {
let key = self.keys.get(&r.key?)?.text.as_deref()?;
// A key with no equivalent needs no modifiers.
if key.is_empty() {
return Some(None);
}
let mods = match r.modifiers {
Some(m) => self.mods.get(&m)?.text.as_deref()?,
None => "",
};
Some(shortcut_editor::normalize(&if mods.is_empty() { key.to_string() } else { format!("{mods}+{key}") }))
}
fn status(&self, r: &Row) -> (Status, Option<String>) {
if !r.on {
return (Status::Off, None);
}
let target = match r.target.as_deref() {
None => return (Status::NeedsChoice, None),
Some("") => return (Status::NoEquivalent, None),
Some(t) if !known(t) => return (Status::NeedsChoice, None),
Some(t) => t,
};
match self.chord(r) {
None => (Status::NeedsKey, None),
Some(None) => (Status::NoEquivalent, None),
Some(Some(c)) if target.starts_with("tool:") && !single_key(&c) => (Status::ToolKey, Some(c)),
Some(Some(c)) => (Status::Ready, Some(c)),
}
}
/// Each row's status, with the shortcut it takes. When ready rows give the same tool, command or
/// panel a shortcut, or want the same shortcut, the last of them does.
pub fn statuses(&self) -> Vec<(Status, Option<String>)> {
let mut out: Vec<(Status, Option<String>)> = self.rows.iter().map(|r| self.status(r)).collect();
let (mut targets, mut chords) = (HashSet::new(), HashSet::new());
for (r, (status, chord)) in self.rows.iter().zip(out.iter_mut()).rev() {
let (Status::Ready, Some(t), Some(c)) = (*status, r.target.as_deref(), chord.as_ref()) else { continue };
if targets.contains(t) {
*status = Status::SameTarget;
} else if chords.contains(c) {
*status = Status::SameShortcut;
} else {
targets.insert(t);
chords.insert(c.clone());
}
}
out
}
/// Some code the Keys section lists is not known yet.
fn asks(&self) -> bool {
let (keys, mods) = self.asked();
keys.iter().any(|k| self.keys.get(k).is_some_and(|c| c.text.is_none()))
|| mods.iter().any(|m| self.mods.get(m).is_some_and(|c| c.text.is_none()))
}
/// The codes the Keys section lists: those of the checked rows with a target, the unknown ones
/// first.
fn asked(&self) -> (Vec<i64>, Vec<i64>) {
let (mut keys, mut mods) = (BTreeSet::new(), BTreeSet::new());
for r in self.rows.iter().filter(|r| r.on && r.target.as_deref().is_some_and(|t| !t.is_empty())) {
keys.extend(r.key.filter(|k| self.keys.contains_key(k)));
mods.extend(r.modifiers.filter(|m| self.mods.contains_key(m)));
}
let unknown_first = |set: BTreeSet<i64>, codes: &BTreeMap<i64, Code>| {
let mut v: Vec<i64> = set.into_iter().collect();
v.sort_by_key(|c| codes.get(c).is_some_and(|c| c.text.is_some()));
v
};
(unknown_first(keys, &self.keys), unknown_first(mods, &self.mods))
}
}
/// The text most votes agree on, with its votes, when at least [`MIN_VOTES`] voted and four votes in
/// five agree.
fn agreed(votes: &HashMap<String, usize>) -> Option<(String, usize)> {
let total: usize = votes.values().sum();
let (text, n) = votes.iter().max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))?;
(total >= MIN_VOTES && n * AGREE.1 >= total * AGREE.0).then(|| (text.clone(), *n))
}
/// A shortcut as written (`Cmd+Shift+K`) split into its modifiers (`Cmd+Shift`, "" for none) and key.
fn split_chord(chord: &str) -> (String, String) {
match chord.rsplit_once('+') {
Some((m, k)) => (m.to_string(), k.to_string()),
None => (String::new(), chord.to_string()),
}
}
/// A shortcut a tool can take: one key, optionally with Shift.
fn single_key(chord: &str) -> bool {
crate::shortcuts::parse(chord).is_some_and(|sc| !(sc.modifiers.command || sc.modifiers.alt || sc.modifiers.ctrl))
}
/// What a name can stand for: Keyboard Shortcuts' entries, by their English labels and the labels in
/// the interface language.
pub fn candidates() -> Vec<Candidate> {
shortcut_editor::entries()
.iter()
.map(|e| {
let shown = tl!(&e.label);
let labels = if shown == e.label { vec![e.label.clone()] } else { vec![e.label.clone(), shown.to_string()] };
Candidate { id: e.key.clone(), labels }
})
.collect()
}
/// Keyboard Shortcuts' entry `key`.
fn entry(key: &str) -> Option<&'static Entry> {
static BY_KEY: OnceLock<HashMap<&'static str, &'static Entry>> = OnceLock::new();
BY_KEY.get_or_init(|| shortcut_editor::entries().iter().map(|e| (e.key.as_str(), e)).collect()).get(key).copied()
}
/// A tool, command or panel that Keyboard Shortcuts can give a shortcut.
fn known(key: &str) -> bool {
entry(key).is_some() || shortcut_editor::default_of(key).is_some()
}
/// How Keyboard Shortcuts names entry `e`: a tool by its label, the others after their menu.
fn shown(e: &Entry) -> String {
if e.is_tool || e.group.is_empty() { tl!(&e.label).to_string() } else { format!("{} › {}", tl!(&e.group), tl!(&e.label)) }
}
/// What a row's target shows.
fn target_label(target: Option<&str>) -> String {
match target {
None => tl!("Choose…").to_string(),
Some("") => tl!("No equivalent").to_string(),
Some(t) => entry(t).map_or_else(|| t.to_string(), shown),
}
}
/// Modifiers as written (`Cmd+Shift`, "" for none) as the menus show them.
fn pretty_mods(mods: &str) -> String {
if mods.is_empty() {
return tl!("None").to_string();
}
let shown = menus::pretty_shortcut(&format!("{mods}+K"));
shown.strip_suffix("+K").or_else(|| shown.strip_suffix('K')).unwrap_or(&shown).to_string()
}
/// Where a code's answer came from, as the Keys section says it.
fn source(c: &Code) -> String {
match c.from.as_str() {
"saved" => tl!("from your earlier imports").to_string(),
"described" => tl!("from the format's public description").to_string(),
"agreement" => crate::i18n::tn(c.votes as u64, "agrees with {n} default shortcut", "agrees with {n} default shortcuts"),
"you" => String::new(),
_ => tl!("not known yet").to_string(),
}
}
/// A file named as a shortcut set another app saves (`.kys`).
pub fn is_kys(path: &str) -> bool {
vectorcraft_engine::cmd::shortcutset::EXTS.contains(&vectorcraft_engine::cmd::fileio::extension(path).as_str())
}
/// A set another app saved as text: a `.kys` file, or text that starts with a slash name.
pub fn is_text_set(path: &str, bytes: &[u8]) -> bool {
let text = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
is_kys(path) || text.trim_ascii_start().starts_with(b"/")
}
/// Open the review of the shortcut set in `bytes`, a file called `name` (a path or a file name) →
/// `{entries, unread}`. An open Keyboard Shortcuts dialog is kept to go back to.
pub fn open(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<Value, String> {
// Only the part `shortcutSet.read` takes in.
let head = bytes.get(..vectorcraft_engine::cmd::shortcutset::READ_LIMIT).unwrap_or(bytes);
open_read(app, json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(head) }))
}
/// Open the review of the set `shortcutSet.read` reads with `params`.
fn open_read(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
let read = app.run("shortcutSet.read", params)?;
let review = Review::build(&read, &candidates());
let back = app.ui.dialog.take().filter(|d| d.kind == "shortcuts");
let mut d = Dialog::new(
KIND,
json!({
"name": read["set"],
"start": "defaults",
"remember": true,
"notes": read["unread"],
"truncated": read["truncated"],
super::PARENT: back.and_then(|b| serde_json::to_value(b).ok()),
"__recording": "",
"__query": "",
"__attention": false,
"__keysOpen": review.asks(),
}),
);
review.store(&mut d);
app.ui.dialog = Some(d);
Ok(json!({ "entries": review.rows.len(), "unread": read["unread"].as_array().map_or(0, Vec::len) }))
}
/// `ui.shortcutImportDialog`: the review of the file at `path`, or in `dataBase64` (called `name`),
/// else of a picked one. On the web the browser's file picker opens, and File › Open opens the
/// review of a `.kys`.
pub fn command(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
if let Some(b64) = s("dataBase64") {
return open_read(app, json!({ "name": s("name").unwrap_or_default(), "dataBase64": b64 }));
}
let path = match s("path") {
Some(path) => path,
None if app.services.open_async.is_some() => return crate::io::open_dialog(app).map(|_| Value::Null),
None => {
let pick = FilePick { filters: vec![("Shortcut sets", vectorcraft_engine::cmd::shortcutset::EXTS)], ..Default::default() };
crate::picks::open(app, &pick).ok_or("cancelled")?
}
};
open_read(app, json!({ "path": path }))
}
/// Apply: the ready rows assigned in the file's order over VectorCraft's defaults (or the current
/// shortcuts), and Keyboard Shortcuts shown with the result as its working set; its OK keeps it. With
/// Remember My Choices on, the matches and codes used are kept for later imports (`importMatches.set`).
/// → `{applied, rows}`
pub fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let review = Review::of(d);
let statuses = review.statuses();
let back = super::parent(d);
let mut ov: BTreeMap<String, String> = match (d.str("start") == "current", &back) {
(true, Some(b)) => b.fields.get("overrides").and_then(|v| BTreeMap::deserialize(v).ok()).unwrap_or_default(),
(true, None) => app.ui.shortcut_overrides.clone(),
(false, _) => BTreeMap::new(),
};
let mut applied = 0usize;
for (r, (status, chord)) in review.rows.iter().zip(&statuses) {
if let (Status::Ready, Some(t), Some(c)) = (status, r.target.as_deref(), chord)
&& shortcut_editor::assign(&mut ov, t, Some(c), true).is_ok()
{
applied += 1;
}
}
let remembered = if d.bool("remember") { remember(app, &review, &statuses) } else { Ok(()) };
let name = d.str("name");
let set = Some(name.trim()).filter(|n| !n.is_empty()).unwrap_or(shortcut_editor::CUSTOM).to_string();
app.ui.dialog = back;
if app.ui.dialog.as_ref().is_none_or(|b| b.kind != "shortcuts") {
shortcut_editor::open(app);
}
if let Some(k) = app.ui.dialog.as_mut() {
k.fields.insert("overrides".into(), serde_json::to_value(&ov).unwrap_or_else(|_| json!({})));
k.fields.insert("set".into(), json!(set));
k.fields.insert("__message".into(), json!(tl!("Imported. Press OK to keep the imported set.")));
}
if let Err(e) = remembered {
app.status(e);
}
Ok(json!({ "applied": applied, "rows": review.rows.len() }))
}
/// Keep for later imports the targets and codes the user chose, and those of the rows Apply assigns.
fn remember(app: &mut VectorcraftApp, review: &Review, statuses: &[(Status, Option<String>)]) -> Result<(), String> {
let mut matches = vec![];
let (mut used_keys, mut used_mods) = (BTreeSet::new(), BTreeSet::new());
for (r, (status, _)) in review.rows.iter().zip(statuses) {
let ready = *status == Status::Ready;
if let Some(t) = &r.target
&& r.from != "saved"
&& (ready || r.from == "you")
{
let mut m = json!({ "kind": "name", "name": r.name, "target": t });
if let Some(e) = entry(t) {
m["label"] = json!(e.label);
}
matches.push(m);
}
if ready {
used_keys.extend(r.key);
used_mods.extend(r.modifiers);
}
}
for (kind, codes, used) in [("key", &review.keys, &used_keys), ("modifiers", &review.mods, &used_mods)] {
for (code, c) in codes {
if let Some(text) = &c.text
&& (c.from == "you" || (c.from == "agreement" && used.contains(code)))
{
matches.push(json!({ "kind": kind, "name": code.to_string(), "target": text }));
}
}
}
if !matches.is_empty() {
app.run("importMatches.set", json!({ "matches": matches }))?;
}
Ok(())
}
/// The rows Apply leaves out and the parts of the file not read, a line each, after the set's name.
pub fn report(d: &Dialog, review: &Review, statuses: &[(Status, Option<String>)]) -> String {
let line = |n: usize, name: &str, status: &str| {
crate::i18n::fmt(tl!("Line {line}: {name}: {status}"), &[("line", &n.to_string()), ("name", name), ("status", status)])
};
let mut lines = vec![d.str("name")];
for (r, (status, _)) in review.rows.iter().zip(statuses) {
if *status != Status::Ready {
lines.push(line(r.line, &r.name, status.label()));
}
}
for n in d.fields.get("notes").and_then(Value::as_array).into_iter().flatten() {
lines.push(line(n["line"].as_u64().unwrap_or(0) as usize, n["text"].as_str().unwrap_or_default(), tl!("Not read")));
}
lines.join("\n") + "\n"
}
// ---------- dialog ----------
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(mut d) = app.ui.dialog.clone() else { return };
let mut review = Review::of(&d);
let before = review.clone();
let t = Tokens::get(ctx);
// A key press answers the key code being recorded, before the widgets see it; Escape stops.
if let Ok(code) = d.str("__recording").parse::<i64>() {
let ev = ctx.input(|i| {
i.events.iter().find_map(|e| match e {
egui::Event::Key { key, pressed: true, .. } if !crate::shortcuts::is_modifier(*key) => Some(*key),
_ => None,
})
});
if let Some(key) = ev {
ctx.input_mut(|i| i.events.clear());
d.fields.insert("__recording".into(), json!(""));
if key != egui::Key::Escape
&& let Some(text) = shortcut_editor::chord_from_event(key, egui::Modifiers::NONE)
&& let Some(c) = review.keys.get_mut(&code)
{
c.text = Some(text);
c.from = "you".into();
}
}
}
let statuses = review.statuses();
// The Keys and Shortcuts lists get the window's height less the rest of the dialog, as the frame
// before drew it.
let id = egui::Id::new("dialog-shortcut-import");
let rest = ctx.data(|m| m.get_temp::<f32>(id.with("rest"))).unwrap_or(0.0);
let (keys, mods) = review.asked();
let keys_open = d.bool("__keysOpen") && (!keys.is_empty() || !mods.is_empty());
let (keys_h, rows_h) = list_heights(ctx.content_rect().height() - rest, keys_open);
let (mut apply, mut cancel, mut copy, mut lists) = (false, false, false, 0.0);
crate::dialogs::modal::show(ctx, tl!("Import Shortcut Set"), id, -20.0, 20, |ui| {
ui.set_width(NAME_W + TARGET_W + CHORD_W + 220.0);
crate::dialogs::modal::heading(ui, tl!("Import Shortcut Set"));
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Name:")).color(t.text_dim));
let mut name = d.str("name");
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)).changed() {
d.fields.insert("name".into(), json!(name));
}
});
ui.add_space(4.0);
ui.label(
egui::RichText::new(tl!(
"Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left."
))
.color(t.text_dim)
.size(11.5),
);
lists = keys_section(ui, &mut d, &mut review, keys_h) + rows_section(ui, &mut d, &mut review, &statuses, rows_h);
ui.add_space(8.0);
ui.horizontal(|ui| {
let current = d.str("start") == "current";
if widgets::radio(ui, tl!("Start from VectorCraft Defaults"), !current, true) {
d.fields.insert("start".into(), json!("defaults"));
}
ui.add_space(12.0);
if widgets::radio(ui, tl!("Add to the Current Shortcuts"), current, true) {
d.fields.insert("start".into(), json!("current"));
}
});
let remember = d.bool("remember");
if widgets::check(ui, tl!("Remember My Choices"), remember, true) {
d.fields.insert("remember".into(), json!(!remember));
}
ui.add_space(4.0);
let ready = statuses.iter().filter(|(s, _)| *s == Status::Ready).count();
let mut summary =
crate::i18n::fmt(tl!("{ready} of {total} ready"), &[("ready", &ready.to_string()), ("total", &review.rows.len().to_string())]);
let notes = d.fields.get("notes").and_then(Value::as_array).map_or(0, Vec::len);
if notes > 0 {
summary.push_str(" · ");
summary.push_str(&crate::i18n::tn(notes as u64, "{n} part of the file wasn't read", "{n} parts of the file weren't read"));
}
if d.bool("truncated") {
summary.push_str(" · ");
summary.push_str(tl!("The file is too large to read in full, so only its first part is listed."));
}
ui.label(egui::RichText::new(summary).color(t.text_dim).size(11.5));
ui.add_space(12.0);
ui.horizontal(|ui| {
if widgets::secondary_button(ui, tl!("Copy Report")).clicked() {
copy = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("Apply")).clicked() {
apply = true;
}
ui.add_space(8.0);
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
cancel = true;
}
});
});
});
if let Some(window) = ctx.memory(|m| m.area_rect(id)) {
ctx.data_mut(|m| m.insert_temp(id.with("rest"), window.height() - lists));
}
if review != before {
if review.rows.iter().map(|r| &r.target).ne(before.rows.iter().map(|r| &r.target)) {
review.decode();
}
review.store(&mut d);
}
if copy {
app.clipboard_out = Some(report(&d, &review, &review.statuses()));
app.status(tl!("Report copied"));
}
app.ui.dialog = Some(d);
if cancel {
crate::dialogs::cancel(app);
} else if apply && let Err(e) = crate::dialogs::confirm(app) {
app.status(e);
}
}
/// The heights of the Keys list (when `keys_open`) and the Shortcuts list when `room` points of the
/// window are left for them: their tallest, or both shorter by the same share, down to a tenth.
fn list_heights(room: f32, keys_open: bool) -> (f32, f32) {
let tallest = if keys_open { KEYS_LIST_H + ROWS_LIST_H } else { ROWS_LIST_H };
let share = (room / tallest).clamp(0.1, 1.0);
(KEYS_LIST_H * share, ROWS_LIST_H * share)
}
/// The Keys section: the codes to answer or check, each with a field for its key or modifiers, where
/// its answer came from and the first entries that use it, in a list at most `height` points tall.
/// Not shown when there are none. → the list's height (0 while the section is closed).
fn keys_section(ui: &mut egui::Ui, d: &mut Dialog, review: &mut Review, height: f32) -> f32 {
let (keys, mods) = review.asked();
if keys.is_empty() && mods.is_empty() {
return 0.0;
}
let t = Tokens::get(ui.ctx());
ui.add_space(8.0);
let open = d.bool("__keysOpen");
if widgets::section_toggle(ui, tl!("Keys"), open) {
d.fields.insert("__keysOpen".into(), json!(!open));
}
if !open {
return 0.0;
}
ui.label(
egui::RichText::new(tl!(
"The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet."
))
.color(t.text_dim)
.size(11.5),
);
let recording = d.str("__recording");
let list = egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
scrolling_list("shortcut-import-keys", height).auto_shrink([false, true]).show(ui, |ui| {
for code in keys {
let Some(c) = review.keys.get_mut(&code) else { continue };
let waiting = recording == code.to_string();
ui.horizontal(|ui| {
cell(ui, 110.0, |ui| ui.label(egui::RichText::new(format!("/Key {code}")).color(t.text_dim)));
let label = match (waiting, c.text.as_deref()) {
(true, _) => tl!("Press a key…").to_string(),
(false, None) => "?".to_string(),
(false, Some("")) => tl!("No equivalent").to_string(),
(false, Some(k)) => menus::pretty_shortcut(k),
};
let b = ui
.add_sized([120.0, ROW_H], egui::Button::new(label).selected(waiting))
.on_hover_text(tl!("Click, then press the key this code stands for (Esc cancels)"));
if b.clicked() {
d.fields.insert("__recording".into(), json!(code.to_string()));
}
if widgets::icon_button(ui, "x", tl!("No equivalent"), c.text.as_deref() == Some(""), 18.0).clicked() {
c.text = Some(String::new());
c.from = "you".into();
}
code_notes(ui, c);
});
}
for code in mods {
let Some(c) = review.mods.get_mut(&code) else { continue };
ui.horizontal(|ui| {
cell(ui, 110.0, |ui| ui.label(egui::RichText::new(format!("/Modifiers {code}")).color(t.text_dim)));
let current = c.text.as_deref().map_or_else(|| "?".to_string(), pretty_mods);
let options: Vec<String> = MODIFIERS.iter().map(|m| pretty_mods(m)).collect();
let names: Vec<&str> = options.iter().map(String::as_str).collect();
if let Some(m) =
widgets::dropdown_names(ui, ("shortcut-import-mods", code), &current, &names, 144.0).and_then(|i| MODIFIERS.get(i))
{
c.text = Some(m.to_string());
c.from = "you".into();
}
code_notes(ui, c);
});
}
})
});
list.inner.inner_rect.height()
}
/// A vertical list that scrolls when its contents are taller than `height` points, and is then
/// `height` points tall, also under egui's 64-point minimum.
fn scrolling_list(id: &str, height: f32) -> egui::ScrollArea {
egui::ScrollArea::vertical().id_salt(id).max_height(height).min_scrolled_height(height)
}
/// A cell `w` points wide with its contents on the left.
fn cell<R>(ui: &mut egui::Ui, w: f32, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
ui.allocate_ui_with_layout(egui::vec2(w, ROW_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(w);
add(ui)
})
.inner
}
/// Where code `c`'s answer came from, and the first entries that use it.
fn code_notes(ui: &mut egui::Ui, c: &Code) {
let t = Tokens::get(ui.ctx());
let from = source(c);
if !from.is_empty() {
ui.label(egui::RichText::new(from).color(t.text_dim).size(11.0));
}
let names = c.names.join(", ");
ui.add(egui::Label::new(egui::RichText::new(names).color(t.text_dim).size(11.0).italics()).truncate());
}
/// The rows: a check box, the name as the file writes it, what it stands for in VectorCraft (a
/// searchable picker), the shortcut and the status, in a list `height` points tall. → the list's
/// height.
fn rows_section(ui: &mut egui::Ui, d: &mut Dialog, review: &mut Review, statuses: &[(Status, Option<String>)], height: f32) -> f32 {
let t = Tokens::get(ui.ctx());
ui.add_space(8.0);
ui.horizontal(|ui| {
widgets::section_header(ui, tl!("Shortcuts"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let attention = d.bool("__attention");
if widgets::check(ui, tl!("Needs Attention Only"), attention, true) {
d.fields.insert("__attention".into(), json!(!attention));
}
ui.add_space(8.0);
let mut q = d.str("__query");
if ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search")).desired_width(180.0)).changed() {
d.fields.insert("__query".into(), json!(q));
}
});
});
let query = d.str("__query").to_lowercase();
let attention = d.bool("__attention");
let visible: Vec<usize> = review
.rows
.iter()
.zip(statuses)
.enumerate()
.filter(|(_, (r, (status, _)))| {
(!attention || status.needs_attention())
&& (query.is_empty() || r.name.to_lowercase().contains(&query) || target_label(r.target.as_deref()).to_lowercase().contains(&query))
})
.map(|(i, _)| i)
.collect();
let list = egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.add_space(22.0);
for (label, w) in [(tl!("In the File"), NAME_W), (tl!("In VectorCraft"), TARGET_W), (tl!("Shortcut"), CHORD_W)] {
cell(ui, w, |ui| ui.label(egui::RichText::new(label).color(t.text_dim).size(11.0)));
}
ui.label(egui::RichText::new(tl!("Status")).color(t.text_dim).size(11.0));
});
scrolling_list("shortcut-import-rows", height).auto_shrink([false, false]).show_rows(ui, ROW_H, visible.len(), |ui, range| {
for i in range.filter_map(|v| visible.get(v).copied()) {
let (status, chord) = statuses.get(i).cloned().unwrap_or((Status::NeedsChoice, None));
let Some(r) = review.rows.get_mut(i) else { continue };
ui.horizontal(|ui| {
if widgets::check(ui, "", r.on, true) {
r.on = !r.on;
}
let place = crate::i18n::fmt(tl!("Line {line}"), &[("line", &r.line.to_string())]);
let place = if r.section.is_empty() { place } else { format!("{} · {place}", r.section) };
cell(ui, NAME_W, |ui| ui.add(egui::Label::new(egui::RichText::new(&r.name).color(t.text)).truncate()).on_hover_text(place));
if let Some(target) = target_picker(ui, i, r) {
r.target = Some(target);
r.from = "you".into();
}
let shortcut = chord.as_deref().map_or_else(|| "—".to_string(), menus::pretty_shortcut);
cell(ui, CHORD_W, |ui| ui.label(egui::RichText::new(shortcut).color(t.text)));
let color = match status {
Status::Ready => t.text,
Status::Off | Status::NoEquivalent => t.text_dim,
_ => t.warning,
};
ui.add(egui::Label::new(egui::RichText::new(status.label()).color(color).size(11.5)).truncate());
});
}
})
});
list.inner.inner_rect.height()
}
/// Row `r`'s target picker: "No equivalent" and the row's choices, then Keyboard Shortcuts' entries
/// that match the search. → the target picked ("" for no equivalent).
fn target_picker(ui: &mut egui::Ui, i: usize, r: &Row) -> Option<String> {
let current = target_label(r.target.as_deref());
widgets::combo(ui, ("shortcut-import-target", i), &current, TARGET_W, true, |ui| {
let field = ui.id().with("query");
if ui.memory(|m| m.focused().is_none()) {
ui.memory_mut(|m| m.request_focus(field.with("edit")));
}
let q = widgets::search_field(ui, field, tl!("Search")).to_lowercase();
let mut picked = None;
let mut option = |ui: &mut egui::Ui, key: &str, label: &str| {
if ui.add(egui::Button::selectable(r.target.as_deref() == Some(key), label)).clicked() {
picked = Some(key.to_string());
}
};
option(ui, "", tl!("No equivalent"));
let list: Vec<&Entry> = if q.is_empty() {
for e in r.choices.iter().filter_map(|c| entry(c)) {
option(ui, &e.key, &shown(e));
}
ui.separator();
shortcut_editor::entries().iter().collect()
} else {
shortcut_editor::entries().iter().filter(|e| shown(e).to_lowercase().contains(&q) || e.key.to_lowercase().contains(&q)).collect()
};
egui::ScrollArea::vertical().max_height(300.0).show_rows(ui, ROW_H, list.len(), |ui, range| {
for e in range.filter_map(|k| list.get(k)) {
option(ui, &e.key, &shown(e));
}
});
if picked.is_some() {
ui.close();
}
picked
})
}
@@ -0,0 +1,467 @@
//! Import Shortcut Set: shortcut sets another app saved as text, reviewed and applied to Keyboard
//! Shortcuts' working set. Every set here is written for the test, with invented names and codes.
use std::collections::BTreeMap;
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use super::shortcut_import::{Code, KIND, Review, Row, Status, report};
use super::*;
use crate::{io, shortcut_editor, theme};
fn app() -> VectorcraftApp {
VectorcraftApp::new(Session::new(), Default::default())
}
/// One headless frame of the dialog layer, with `events`.
fn frame(app: &mut VectorcraftApp, events: Vec<egui::Event>) {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
}
fn press(key: egui::Key) -> egui::Event {
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }
}
fn dialog(app: &VectorcraftApp) -> &Dialog {
app.ui.dialog.as_ref().unwrap()
}
fn set(app: &mut VectorcraftApp, key: &str, v: Value) {
app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), v);
}
fn remember(app: &mut VectorcraftApp, matches: Value) {
app.run("importMatches.set", json!({ "matches": matches })).unwrap();
}
fn row(name: &str, target: &str, key: i64, modifiers: Option<i64>) -> Row {
Row { name: name.into(), target: Some(target.into()), from: "matched".into(), key: Some(key), modifiers, on: true, ..Default::default() }
}
/// A review of `rows` with every code unknown, decoded.
fn decoded(rows: Vec<Row>) -> Review {
let mut r = Review { rows: rows.clone(), ..Default::default() };
for row in rows {
r.keys.insert(row.key.unwrap(), Code::default());
if let Some(m) = row.modifiers {
r.mods.insert(m, Code::default());
}
}
r.decode();
r
}
fn text(codes: &BTreeMap<i64, Code>, code: i64) -> Option<&str> {
codes[&code].text.as_deref()
}
#[test]
fn agreement_decodes_codes_that_defaults_share() {
for (id, default) in [("edit.undo", "Cmd+Z"), ("edit.redo", "Cmd+Shift+Z"), ("edit.copy", "Cmd+C"), ("edit.cut", "Cmd+X"), ("tool:pen", "P")] {
assert_eq!(shortcut_editor::default_of(id), Some(default), "{id}");
}
let r = decoded(vec![
row("Acme Undo", "edit.undo", 901, Some(11)),
row("Acme Redo", "edit.redo", 901, Some(12)),
row("Acme Copy", "edit.copy", 902, Some(11)),
row("Acme Cut", "edit.cut", 903, Some(11)),
row("Acme Pen", "tool:pen", 904, Some(10)),
]);
assert_eq!(text(&r.keys, 901), Some("Z"), "two entries agree");
assert_eq!((r.keys[&901].from.as_str(), r.keys[&901].votes), ("agreement", 2));
assert_eq!(text(&r.keys, 902), None, "one vote isn't enough");
assert_eq!(text(&r.mods, 11), Some("Cmd"));
assert_eq!((text(&r.mods, 12), text(&r.mods, 10)), (None, None));
let statuses = r.statuses();
assert_eq!(statuses[0], (Status::Ready, Some("Cmd+Z".into())));
assert_eq!(statuses[1].0, Status::NeedsKey, "its modifiers aren't known");
assert_eq!(statuses[2].0, Status::NeedsKey);
}
#[test]
fn disagreement_leaves_codes_undecided() {
// Three different keys for one code: nothing is proposed.
let r = decoded(vec![row("A", "edit.undo", 905, None), row("B", "edit.copy", 905, None), row("C", "edit.cut", 905, None)]);
assert_eq!(text(&r.keys, 905), None);
// Four in five agree: proposed. Three in four: not.
let five = ["edit.undo", "edit.redo", "edit.undo", "edit.redo", "edit.copy"];
let r = decoded(five.iter().enumerate().map(|(i, t)| row(&format!("E{i}"), t, 906, None)).collect());
assert_eq!((text(&r.keys, 906), r.keys[&906].votes), (Some("Z"), 4));
let four = ["edit.undo", "edit.redo", "edit.undo", "edit.copy"];
let r = decoded(four.iter().enumerate().map(|(i, t)| row(&format!("E{i}"), t, 907, None)).collect());
assert_eq!(text(&r.keys, 907), None);
// Rows without a target, or with no equivalent, don't vote; an answer that isn't agreement stays.
let mut r = decoded(vec![row("A", "edit.undo", 908, None), row("B", "", 908, None)]);
assert_eq!(text(&r.keys, 908), None);
r.keys.insert(908, Code { text: Some("Q".into()), from: "you".into(), ..Default::default() });
r.rows.push(row("C", "edit.redo", 908, None));
r.decode();
assert_eq!(text(&r.keys, 908), Some("Q"));
}
#[test]
fn a_key_with_no_equivalent_needs_no_modifiers() {
let mut r = decoded(vec![row("A", "edit.undo", 940, Some(41))]);
assert_eq!((text(&r.keys, 940), text(&r.mods, 41)), (None, None));
r.keys.insert(940, Code { text: Some(String::new()), from: "you".into(), ..Default::default() });
assert_eq!(r.statuses()[0], (Status::NoEquivalent, None), "the unknown modifiers don't matter");
}
const SET: &str = "/Menus {\n /Edit {\n /Acme\\ Undo { /Context 0 /Modifiers 11 /Represent 0 /Key 902 }\n /Acme\\ Copy { /Modifiers 11 /Key 902 }\n }\n /Acme\\ Pen { /Modifiers 11 /Key 901 }\n /Acme\\ Gadget { /Modifiers 0 /Key 48 }\n}\n";
/// The answers an earlier import left for [`SET`].
fn earlier_answers(app: &mut VectorcraftApp) {
remember(
app,
json!([
{"kind": "name", "name": "Acme Undo", "target": "edit.undo"},
{"kind": "name", "name": "Acme Copy", "target": "edit.copy"},
{"kind": "name", "name": "Acme Pen", "target": "tool:pen"},
{"kind": "name", "name": "Acme Gadget", "target": ""},
{"kind": "key", "name": "901", "target": "K"},
{"kind": "key", "name": "902", "target": "J"},
{"kind": "modifiers", "name": "11", "target": "Cmd"},
]),
);
}
#[test]
fn kys_files_open_the_review() {
let mut app = app();
earlier_answers(&mut app);
io::open_bytes(&mut app, "Acme Keys.kys", SET.as_bytes(), None).unwrap();
let d = dialog(&app);
assert_eq!((d.kind.as_str(), d.str("name"), d.str("start"), d.bool("remember")), (KIND, "Acme Keys".into(), "defaults".into(), true));
let r = Review::of(d);
let names: Vec<(&str, &str, Option<&str>)> = r.rows.iter().map(|r| (r.name.as_str(), r.section.as_str(), r.target.as_deref())).collect();
assert_eq!(
names,
[
("Acme Undo", "Menus › Edit", Some("edit.undo")),
("Acme Copy", "Menus › Edit", Some("edit.copy")),
("Acme Pen", "Menus", Some("tool:pen")),
("Acme Gadget", "Menus", Some(""))
]
);
assert!(r.rows.iter().all(|r| r.from == "saved"));
assert_eq!((text(&r.keys, 48), r.keys[&48].from.as_str()), (Some("0"), "described"), "the format's description");
assert_eq!((text(&r.mods, 0), r.mods[&0].from.as_str()), (Some(""), "described"));
let statuses: Vec<Status> = r.statuses().into_iter().map(|s| s.0).collect();
assert_eq!(statuses, [Status::SameShortcut, Status::Ready, Status::ToolKey, Status::NoEquivalent]);
frame(&mut app, vec![]);
assert_eq!(dialog(&app).kind, KIND);
// A file in another form says so, and opens nothing.
app.ui.dialog = None;
let e = io::open_bytes(&mut app, "Other.kys", b"<?xml version=\"1.0\"?><shortcuts/>", None).unwrap_err();
assert_eq!(e, vectorcraft_engine::cmd::shortcutset::NOT_READABLE);
assert!(app.ui.dialog.is_none());
}
#[test]
fn kys_rows_go_through_assign() {
let mut app = app();
earlier_answers(&mut app);
io::open_bytes(&mut app, "Acme Keys.kys", SET.as_bytes(), None).unwrap();
let d = dialog(&app).clone();
assert_eq!(confirm(&mut app).unwrap(), json!({"applied": 1, "rows": 4}));
// Keyboard Shortcuts shows the set: the later row took ⌘J from Join's default, and the tool kept P.
assert_eq!(shortcut_editor::default_of("path.join"), Some("Cmd+J"));
let k = dialog(&app);
assert_eq!((k.kind.as_str(), k.str("set")), ("shortcuts", "Acme Keys".into()));
let ov: BTreeMap<String, String> = serde_json::from_value(k.fields["overrides"].clone()).unwrap();
assert_eq!(ov, BTreeMap::from([("edit.copy".to_string(), "Cmd+J".to_string()), ("path.join".to_string(), String::new())]));
assert_eq!(k.str("__message"), "Imported. Press OK to keep the imported set.");
assert!(app.ui.shortcut_overrides.is_empty(), "nothing is kept before OK");
// The rows left out are reported, with their line.
let r = Review::of(&d);
let text = report(&d, &r, &r.statuses());
assert!(text.starts_with("Acme Keys\nLine 3: Acme Undo: A later row takes this shortcut\n"), "{text}");
assert!(text.contains("Line 6: Acme Pen: Tools take a single key (optionally with Shift)\n") && !text.contains("Acme Copy"), "{text}");
}
#[test]
fn rows_for_the_same_item_leave_the_earlier_out() {
let mut app = app();
earlier_answers(&mut app);
remember(&mut app, json!([{"kind": "name", "name": "Acme Copy Again", "target": "edit.copy"}]));
let kys = "/Menus {\n /Acme\\ Copy { /Modifiers 11 /Key 902 }\n /Acme\\ Copy\\ Again { /Modifiers 11 /Key 901 }\n}\n";
io::open_bytes(&mut app, "Twice.kys", kys.as_bytes(), None).unwrap();
let r = Review::of(dialog(&app));
assert_eq!(r.statuses().iter().map(|s| s.0).collect::<Vec<_>>(), [Status::SameTarget, Status::Ready]);
assert_eq!(confirm(&mut app).unwrap()["applied"], 1);
let ov = &dialog(&app).fields["overrides"];
assert_eq!(ov["edit.copy"], "Cmd+K");
assert!(ov.get("path.join").is_none(), "Join keeps ⌘J: the earlier row isn't applied");
}
#[test]
fn cancel_and_apply_go_back_to_keyboard_shortcuts() {
let mut app = app();
earlier_answers(&mut app);
shortcut_editor::open(&mut app);
set(&mut app, "overrides", json!({"edit.preferences": "Cmd+Alt+Shift+9"}));
super::shortcut_import::open(&mut app, "/sets/Acme Keys.kys", SET.as_bytes()).unwrap();
assert_eq!(dialog(&app).kind, KIND);
cancel(&mut app);
assert_eq!((dialog(&app).kind.as_str(), dialog(&app).fields["overrides"].clone()), ("shortcuts", json!({"edit.preferences": "Cmd+Alt+Shift+9"})));
// Escape goes back the same way, and closes Keyboard Shortcuts after that.
super::shortcut_import::open(&mut app, "/sets/Acme Keys.kys", SET.as_bytes()).unwrap();
let escape = |app: &mut VectorcraftApp| {
let ctx = egui::Context::default();
let mut out =
ctx.run_ui(egui::RawInput { events: vec![press(egui::Key::Escape)], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx()));
out.textures_delta.clear();
};
escape(&mut app);
assert_eq!((dialog(&app).kind.as_str(), dialog(&app).fields["overrides"].clone()), ("shortcuts", json!({"edit.preferences": "Cmd+Alt+Shift+9"})));
escape(&mut app);
assert!(app.ui.dialog.is_none());
shortcut_editor::open(&mut app);
set(&mut app, "overrides", json!({"edit.preferences": "Cmd+Alt+Shift+9"}));
// Added to the current shortcuts: the working set keeps its own change.
super::shortcut_import::open(&mut app, "/sets/Acme Keys.kys", SET.as_bytes()).unwrap();
set(&mut app, "start", json!("current"));
confirm(&mut app).unwrap();
assert_eq!(dialog(&app).fields["overrides"], json!({"edit.copy": "Cmd+J", "edit.preferences": "Cmd+Alt+Shift+9", "path.join": ""}));
// OK keeps it.
confirm(&mut app).unwrap();
assert_eq!((app.ui.shortcut_set.as_str(), app.ui.shortcut_overrides.len()), ("Acme Keys", 3));
}
/// A document's Missing Fonts dialog that Keyboard Shortcuts replaced waits while Cancel goes back
/// from Import Shortcut Set to Keyboard Shortcuts, and opens again once Keyboard Shortcuts closes.
#[test]
fn cancel_goes_back_to_keyboard_shortcuts_before_a_waiting_missing_fonts_dialog() {
let mut app = app();
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("text.create", json!({"x": 10, "y": 40, "text": "Type", "font": "No Such Font Family"})).unwrap();
app.run("ui.missingFontsDialog", json!({})).unwrap();
assert_eq!(dialog(&app).kind, missing_fonts::KIND);
shortcut_editor::open(&mut app);
set(&mut app, "overrides", json!({"edit.preferences": "Cmd+Alt+Shift+9"}));
super::shortcut_import::open(&mut app, "/sets/Acme Keys.kys", SET.as_bytes()).unwrap();
cancel(&mut app);
assert_eq!((dialog(&app).kind.as_str(), dialog(&app).fields["overrides"].clone()), ("shortcuts", json!({"edit.preferences": "Cmd+Alt+Shift+9"})));
cancel(&mut app);
assert_eq!(dialog(&app).kind, missing_fonts::KIND);
assert_eq!(dialog(&app).fields["fonts"][0]["family"], "No Such Font Family");
}
/// A set dropped on the window opens the review when no document is open, from its path (desktop)
/// or from its bytes (web).
#[test]
fn a_set_dropped_with_no_document_open_opens_the_review() {
let mut app = app();
let dir = vectorcraft_testkit::temp_dir("shortcut-import-drop");
let kys = dir.join("Dropped.kys");
std::fs::write(&kys, SET).unwrap();
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
let target = app.drop_target("Dropped.kys", None, false);
assert_eq!(target, crate::place::DropTarget::Open);
crate::place::drop_files(&mut app, vec![(target, ("Dropped.kys".into(), Some(kys.to_string_lossy().into_owned()), vec![]))]);
assert_eq!((dialog(&app).kind.as_str(), dialog(&app).str("name"), Review::of(dialog(&app)).rows.len()), (KIND, "Dropped".into(), 4));
app.ui.dialog = None;
app.services.read = None;
crate::place::drop_files(&mut app, vec![(target, ("Sent.kys".into(), None, SET.as_bytes().to_vec()))]);
assert_eq!((dialog(&app).kind.as_str(), dialog(&app).str("name")), (KIND, "Sent".into()));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn remembered_choices_answer_the_next_import() {
let mut app = app();
let kys = "/Menus {\n /Acme\\ Undo { /Modifiers 21 /Key 931 }\n /Acme\\ Thing { /Modifiers 21 /Key 932 }\n}\n";
io::open_bytes(&mut app, "Fresh.kys", kys.as_bytes(), None).unwrap();
let r = Review::of(dialog(&app));
assert_eq!(r.rows[0].target.as_deref(), Some("edit.undo"), "proposed from the name");
assert_eq!(r.rows[0].from, "matched");
assert_eq!(r.statuses().iter().map(|s| s.0).collect::<Vec<_>>(), [Status::NeedsKey, Status::NeedsChoice]);
assert!(dialog(&app).bool("__keysOpen"), "codes to answer open the Keys section");
// The user answers the codes and picks the second row's target.
let mut r = r;
r.keys.insert(931, Code { text: Some("Z".into()), from: "you".into(), ..r.keys[&931].clone() });
r.mods.insert(21, Code { text: Some("Cmd+Alt".into()), from: "you".into(), ..r.mods[&21].clone() });
r.rows[1].target = Some(String::new());
r.rows[1].from = "you".into();
set(&mut app, "rows", serde_json::to_value(&r.rows).unwrap());
set(&mut app, "keys", serde_json::to_value(&r.keys).unwrap());
set(&mut app, "mods", serde_json::to_value(&r.mods).unwrap());
confirm(&mut app).unwrap();
let kept = app.run("importMatches.list", json!({})).unwrap();
let kept: Vec<(&str, &str, &str)> =
kept.as_array().unwrap().iter().map(|m| (m["kind"].as_str().unwrap(), m["name"].as_str().unwrap(), m["target"].as_str().unwrap())).collect();
assert_eq!(kept, [("name", "Acme Undo", "edit.undo"), ("name", "Acme Thing", ""), ("key", "931", "Z"), ("modifiers", "21", "Cmd+Alt")]);
assert_eq!(app.run("importMatches.list", json!({"kind": "name"})).unwrap()[0]["label"], "Undo");
// The next import asks nothing.
app.ui.dialog = None;
io::open_bytes(&mut app, "Fresh.kys", kys.as_bytes(), None).unwrap();
let r = Review::of(dialog(&app));
assert!(r.rows.iter().all(|r| r.from == "saved"));
assert_eq!(r.statuses().iter().map(|s| s.0).collect::<Vec<_>>(), [Status::Ready, Status::NoEquivalent]);
assert_eq!(r.statuses()[0].1.as_deref(), Some("Cmd+Alt+Z"));
// The saved codes stay listed, in a Keys section that starts closed, and can be answered again.
assert!(!dialog(&app).bool("__keysOpen"));
set(&mut app, "__keysOpen", json!(true));
frame(&mut app, vec![]);
let mut r = r;
r.keys.insert(931, Code { text: Some("Y".into()), from: "you".into(), ..r.keys[&931].clone() });
set(&mut app, "keys", serde_json::to_value(&r.keys).unwrap());
confirm(&mut app).unwrap();
assert_eq!(importmatches_target(&app, "key", "931"), Some("Y".to_string()));
// Without Remember My Choices nothing more is kept.
app.ui.dialog = None;
io::open_bytes(&mut app, "Fresh.kys", kys.as_bytes(), None).unwrap();
set(&mut app, "remember", json!(false));
let before = app.session.prefs.import_matches.clone();
let mut r = Review::of(dialog(&app));
r.keys.insert(931, Code { text: Some("W".into()), from: "you".into(), ..r.keys[&931].clone() });
set(&mut app, "keys", serde_json::to_value(&r.keys).unwrap());
confirm(&mut app).unwrap();
assert_eq!(app.session.prefs.import_matches, before);
}
/// What the import matches say `name` of `kind` stands for.
fn importmatches_target(app: &VectorcraftApp, kind: &str, name: &str) -> Option<String> {
vectorcraft_engine::cmd::importmatches::targets(&app.session.prefs).get(&(kind, name)).map(|t| t.to_string())
}
#[test]
fn a_key_press_answers_the_code_being_recorded() {
let mut app = app();
let kys = "/Menus {\n /Acme\\ Undo { /Modifiers 21 /Key 931 }\n /Acme\\ Redo { /Modifiers 21 /Key 931 }\n}\n";
io::open_bytes(&mut app, "Keys.kys", kys.as_bytes(), None).unwrap();
frame(&mut app, vec![]);
set(&mut app, "__recording", json!("931"));
assert!(shortcut_editor::is_recording(&app), "Escape stops the recording, not the dialog");
// A modifier's own key press waits for the key.
frame(&mut app, vec![press(egui::Key::ShiftLeft)]);
assert_eq!(dialog(&app).str("__recording"), "931");
frame(&mut app, vec![press(egui::Key::K)]);
let r = Review::of(dialog(&app));
assert_eq!((text(&r.keys, 931), r.keys[&931].from.as_str(), dialog(&app).str("__recording")), (Some("K"), "you", String::new()));
// Escape stops recording and keeps the answer.
set(&mut app, "__recording", json!("931"));
frame(&mut app, vec![press(egui::Key::Escape)]);
assert_eq!((text(&Review::of(dialog(&app)).keys, 931), dialog(&app).str("__recording")), (Some("K"), String::new()));
}
#[test]
fn keyboard_shortcuts_import_reads_both_kinds_of_set() {
let mut app = app();
let dir = vectorcraft_testkit::temp_dir("shortcut-import-path");
let kys = dir.join("On Disk.kys");
std::fs::write(&kys, SET).unwrap();
let kys = kys.to_string_lossy().to_string();
let files: BTreeMap<&str, Vec<u8>> = BTreeMap::from([
(
"/sets/Mine.json",
serde_json::to_vec(&shortcut_editor::export_json("Mine", &BTreeMap::from([("tool:pen".into(), "Shift+9".into())]))).unwrap(),
),
("/sets/Text.json", b"\xEF\xBB\xBF /Menus { /Acme\\ Undo { /Key 1 } }".to_vec()),
]);
let reads = std::rc::Rc::new(std::cell::Cell::new(0));
let counted = reads.clone();
app.services.read = Some(Box::new(move |p| {
counted.set(counted.get() + 1);
files.get(p).cloned().ok_or_else(|| format!("no file {p}"))
}));
app.run("shortcuts.import", json!({"path": "/sets/Mine.json"})).unwrap();
assert_eq!(app.ui.shortcut_set, "Mine");
// A `.kys` file is read by `shortcutSet.read`, up to its cap, and not through the app's file reader.
let before = reads.get();
app.run("shortcuts.import", json!({ "path": kys })).unwrap();
assert_eq!((dialog(&app).kind.as_str(), dialog(&app).str("name"), reads.get()), (KIND, "On Disk".into(), before));
app.run("shortcuts.import", json!({"path": "/sets/Text.json"})).unwrap();
assert_eq!(dialog(&app).str("name"), "Text", "text that starts with a slash name is a text set");
// File › Open and the command open it from a path, the command also from its bytes.
app.ui.dialog = None;
let before = reads.get();
app.run("file.open", json!({ "path": kys })).unwrap();
assert_eq!((Review::of(dialog(&app)).rows.len(), reads.get()), (4, before));
assert_eq!(app.ui.recent_files.first(), Some(&kys), "File › Open lists it in Open Recent Files");
app.ui.dialog = None;
app.run("ui.shortcutImportDialog", json!({ "path": kys })).unwrap();
assert_eq!((Review::of(dialog(&app)).rows.len(), dialog(&app).str("name")), (4, "On Disk".into()));
let b64 = vectorcraft_format::base64_encode(SET.as_bytes());
let r = app.run("ui.shortcutImportDialog", json!({"dataBase64": b64, "name": "Sent.kys"})).unwrap();
assert_eq!((r["entries"].as_u64(), dialog(&app).str("name")), (Some(4), "Sent".into()));
// A file past the reader's cap opens with what fits, marked truncated.
let big = format!("/Menus {{ /Acme\\ Undo {{ /Key 1 }} {} }}", " ".repeat(5 << 20));
io::open_bytes(&mut app, "Big.kys", big.as_bytes(), None).unwrap();
assert_eq!((Review::of(dialog(&app)).rows.len(), dialog(&app).bool("truncated")), (1, true));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn many_rows_draw_and_filter() {
let mut app = app();
let body: String = (0..2000).map(|i| format!(" /Acme\\ Entry\\ {i} {{ /Modifiers {} /Key {} }}\n", i % 7, 1000 + i % 90)).collect();
io::open_bytes(&mut app, "Big.kys", format!("/Menus {{\n{body}}}\n").as_bytes(), None).unwrap();
frame(&mut app, vec![]);
set(&mut app, "__attention", json!(true));
set(&mut app, "__query", json!("entry 19"));
frame(&mut app, vec![]);
assert_eq!(Review::of(dialog(&app)).rows.len(), 2000);
// Notes on the parts not read are kept for the report.
app.ui.dialog = None;
io::open_bytes(&mut app, "Odd.kys", b"/Menus { /Acme\\ Undo { /Key 1 /Bogus 2 } junk }", None).unwrap();
let d = dialog(&app).clone();
let r = Review::of(&d);
let text = report(&d, &r, &r.statuses());
assert!(text.contains("Line 1: /Bogus 2: Not read\n") && text.contains("Line 1: junk: Not read\n"), "{text}");
frame(&mut app, vec![]);
}
/// The texts the sixth frame of the dialog layer painted on `screen`, each with its rectangle and
/// the rectangle it was clipped to.
fn painted_on(app: &mut VectorcraftApp, screen: egui::Rect) -> Vec<(String, egui::Rect, egui::Rect)> {
fn collect(s: &egui::Shape, clip: egui::Rect, v: &mut Vec<(String, egui::Rect, egui::Rect)>) {
match s {
egui::Shape::Text(t) => v.push((t.galley.text().to_string(), t.visual_bounding_rect(), clip)),
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, clip, v)),
_ => {}
}
}
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let mut texts = vec![];
for _ in 0..6 {
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
texts.clear();
for c in &out.shapes {
collect(&c.shape, c.clip_rect, &mut texts);
}
}
texts
}
/// On the app's smallest window (800 × 500 points), a large set shows the heading, Copy Report,
/// Cancel and Apply, with the Keys section open or closed: its lists get shorter.
#[test]
fn a_large_set_keeps_its_buttons_on_a_small_window() {
let mut app = app();
let body: String = (0..400).map(|i| format!(" /Acme\\ Entry\\ {i} {{ /Modifiers {} /Key {} }}\n", i % 7, 1000 + i % 90)).collect();
io::open_bytes(&mut app, "Big.kys", format!("/Menus {{\n{body}}}\n").as_bytes(), None).unwrap();
// Every row has a target, so the Keys section lists the set's 97 codes.
let mut r = Review::of(dialog(&app));
for row in &mut r.rows {
row.target = Some("edit.undo".into());
}
set(&mut app, "rows", serde_json::to_value(&r.rows).unwrap());
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 500.0));
for open in [true, false] {
set(&mut app, "__keysOpen", json!(open));
let texts = painted_on(&mut app, screen);
for label in ["Import Shortcut Set", "Copy Report", "Cancel", "Apply"] {
let (_, rect, clip) = texts.iter().find(|(t, ..)| t == label).unwrap_or_else(|| panic!("Keys open: {open}: no {label}"));
assert!(screen.contains_rect(*rect) && clip.contains_rect(*rect), "Keys open: {open}: {label} at {rect:?}, clipped to {clip:?}");
}
}
}
+42
View File
@@ -1379,6 +1379,46 @@
Keyboard Shortcut Conflicts Konflikte bei Tastaturbefehlen
Keyboard Shortcut Set Tastaturbefehlssatz
Keyboard Shortcuts… Tastaturbefehle…
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set Tastaturbefehlssatz importieren
Import Shortcut Set… Tastaturbefehlssatz importieren…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Jeder Eintrag der Datei wird einem Werkzeug, einem Befehl oder einem Bedienfeld von VectorCraft zugeordnet. Prüfen Sie die Zuordnungen und beantworten Sie, was noch offen ist.
Keys Tasten
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. Die Datei speichert Tasten und Sondertasten als Codes. Prüfen Sie die vorgeschlagenen Bedeutungen und beantworten Sie die Codes, die noch nicht bekannt sind.
Click, then press the key this code stands for (Esc cancels) Klicken Sie und drücken Sie dann die Taste, für die dieser Code steht (Esc bricht ab)
Press a key… Taste drücken…
from your earlier imports aus Ihren früheren Importen
from the format's public description aus der öffentlichen Beschreibung des Formats
not known yet noch nicht bekannt
Shortcuts Tastaturbefehle
Shortcut Tastaturbefehl
In the File In der Datei
In VectorCraft In VectorCraft
Needs Attention Only Nur Einträge mit Handlungsbedarf
Ready Bereit
Needs a choice Auswahl erforderlich
Needs a key Taste erforderlich
No equivalent Keine Entsprechung
Tools take a single key (optionally with Shift) Werkzeuge verwenden eine einzelne Taste (optional mit Shift)
A later row takes this shortcut Eine spätere Zeile übernimmt diesen Tastaturbefehl
Same item as a later row Gleiches Element wie eine spätere Zeile
Start from VectorCraft Defaults Von den VectorCraft-Standardwerten ausgehen
Add to the Current Shortcuts Zu den aktuellen Tastaturbefehlen hinzufügen
Remember My Choices Meine Auswahl merken
{ready} of {total} ready {ready} von {total} bereit
The file is too large to read in full, so only its first part is listed. Die Datei ist zu groß, um sie vollständig zu lesen. Daher wird nur ihr erster Teil aufgeführt.
Copy Report Bericht kopieren
Report copied Bericht kopiert
Line {line} Zeile {line}
Line {line}: {name}: {status} Zeile {line}: {name}: {status}
Not read Nicht gelesen
Imported. Press OK to keep the imported set. Importiert. Klicken Sie auf „OK“, um den importierten Satz zu behalten.
Read Shortcut Set File Tastaturbefehlssatz-Datei lesen
Import Matches Import-Zuordnungen
Remember Import Matches Import-Zuordnungen merken
Forget Import Matches Import-Zuordnungen vergessen
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts stimmt mit {n} Standard-Tastaturbefehl überein|stimmt mit {n} Standard-Tastaturbefehlen überein
@plural {n} part of the file wasn't read|{n} parts of the file weren't read {n} Teil der Datei wurde nicht gelesen|{n} Teile der Datei wurden nicht gelesen
Keywords: Stichwörter:
Knife Messer
Knockout Group Aussparungsgruppe
@@ -3426,6 +3466,7 @@ theme Appearance Erscheinungsbild
@msg Split Into Grid: select paths large enough for the grid In Raster teilen: Wählen Sie Pfade aus, die groß genug für das Raster sind
@msg Targa files are RGB: colorModel rgb Targa-Dateien sind RGB: colorModel rgb
@msg Text Wrap: select a wrap object Textumfluss: Wählen Sie ein Umflussobjekt aus
@msg This shortcut file is in a form VectorCraft can't read. Diese Tastaturbefehlsdatei liegt in einem Format vor, das VectorCraft nicht lesen kann.
@msg `aspect` must be a number (%) `aspect` muss eine Zahl sein (%)
@msg `at` must be [x, y] `at` muss [x, y] sein
@msg `color` must be a colour `color` muss eine Farbe sein
@@ -3660,6 +3701,7 @@ theme Appearance Erscheinungsbild
@msg not a VectorCraft file: {0} keine VectorCraft-Datei: {0}
@msg not a VectorCraft shortcut set (missing `overrides`) kein VectorCraft-Tastaturbefehlssatz (`overrides` fehlt)
@msg not a graphic style library (format `{_1}`) keine Grafikstil-Bibliothek (Format `{_1}`)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace kein Tastaturbefehlssatz: Der Text beginnt nicht mit einem Namen mit Schrägstrich und einer öffnenden geschweiften Klammer
@msg not a stored central record kein gespeicherter zentraler Eintrag
@msg not a valid plug-in module: {0} kein gültiges Plug-in-Modul: {0}
@msg not an EMF or WMF file keine EMF- oder WMF-Datei
Can't render this file because it contains an unexpected character in line 2 and column 73.
+42
View File
@@ -1397,6 +1397,46 @@
Keyboard Shortcut Conflicts Conflictos de métodos abreviados de teclado
Keyboard Shortcut Set Conjunto de métodos abreviados de teclado
Keyboard Shortcuts… Métodos abreviados de teclado…
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set Importar conjunto de métodos abreviados
Import Shortcut Set… Importar conjunto de métodos abreviados…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Cada entrada del archivo se asocia con una herramienta, un comando o un panel de VectorCraft. Revisa las asociaciones y responde lo que falta.
Keys Teclas
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. El archivo guarda las teclas y los modificadores como códigos. Revisa los significados propuestos y responde los códigos que aún no se conocen.
Click, then press the key this code stands for (Esc cancels) Haz clic y luego presiona la tecla que representa este código (Esc cancela)
Press a key… Presiona una tecla…
from your earlier imports de tus importaciones anteriores
from the format's public description según la descripción pública del formato
not known yet aún desconocido
Shortcuts Métodos abreviados
Shortcut Método abreviado
In the File En el archivo
In VectorCraft En VectorCraft
Needs Attention Only Solo lo que requiere atención
Ready Listo
Needs a choice Requiere una elección
Needs a key Requiere una tecla
No equivalent Sin equivalente
Tools take a single key (optionally with Shift) Las herramientas usan una sola tecla (opcionalmente con Shift)
A later row takes this shortcut Una fila posterior usa este método abreviado
Same item as a later row Mismo elemento que una fila posterior
Start from VectorCraft Defaults Partir de los valores predeterminados de VectorCraft
Add to the Current Shortcuts Agregar a los métodos abreviados actuales
Remember My Choices Recordar mis elecciones
{ready} of {total} ready {ready} de {total} listos
The file is too large to read in full, so only its first part is listed. El archivo es demasiado grande para leerlo completo, así que solo se muestra su primera parte.
Copy Report Copiar informe
Report copied Informe copiado
Line {line} Línea {line}
Line {line}: {name}: {status} Línea {line}: {name}: {status}
Not read No leído
Imported. Press OK to keep the imported set. Importado. Haz clic en Aceptar para conservar el conjunto importado.
Read Shortcut Set File Leer archivo de conjunto de métodos abreviados
Import Matches Asociaciones de importación
Remember Import Matches Recordar asociaciones de importación
Forget Import Matches Olvidar asociaciones de importación
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts coincide con {n} método abreviado predeterminado|coincide con {n} métodos abreviados predeterminados
@plural {n} part of the file wasn't read|{n} parts of the file weren't read no se leyó {n} parte del archivo|no se leyeron {n} partes del archivo
Keywords: Palabras clave:
Knife Cuchilla
Knockout Group Grupo de exclusión
@@ -3526,6 +3566,7 @@ axis Both Ambos
@msg Split Into Grid: select paths large enough for the grid Dividir en cuadrícula: selecciona trazados lo bastante grandes para la cuadrícula
@msg Targa files are RGB: colorModel rgb los archivos Targa son RGB: colorModel rgb
@msg Text Wrap: select a wrap object Ceñir texto: selecciona un objeto para ceñir
@msg This shortcut file is in a form VectorCraft can't read. Este archivo de métodos abreviados tiene un formato que VectorCraft no puede leer.
@msg `aspect` must be a number (%) `aspect` debe ser un número (%)
@msg `at` must be [x, y] `at` debe ser [x, y]
@msg `color` must be a colour `color` debe ser un color
@@ -3759,6 +3800,7 @@ axis Both Ambos
@msg not a VectorCraft file: {0} no es un archivo de VectorCraft: {0}
@msg not a VectorCraft shortcut set (missing `overrides`) no es un conjunto de atajos de VectorCraft (falta `overrides`)
@msg not a graphic style library (format `{_1}`) no es una biblioteca de estilos gráficos (formato `{_1}`)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace no es un conjunto de métodos abreviados: el texto no empieza con un nombre con barra y una llave de apertura
@msg not a stored central record no es un registro central almacenado
@msg not a valid plug-in module: {0} no es un módulo de plugin válido: {0}
@msg not an EMF or WMF file no es un archivo EMF ni WMF
Can't render this file because it contains an unexpected character in line 2 and column 73.
+42
View File
@@ -1396,6 +1396,46 @@
Keyboard Shortcut Conflicts Conflits de raccourcis clavier
Keyboard Shortcut Set Jeu de raccourcis clavier
Keyboard Shortcuts… Raccourcis clavier…
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set Importer un jeu de raccourcis
Import Shortcut Set… Importer un jeu de raccourcis…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Chaque entrée du fichier est mise en correspondance avec un outil, une commande ou un panneau de VectorCraft. Vérifiez les correspondances et répondez à ce qui reste.
Keys Touches
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. Le fichier enregistre les touches et les modificateurs sous forme de codes. Vérifiez les significations proposées et indiquez les codes encore inconnus.
Click, then press the key this code stands for (Esc cancels) Cliquez, puis appuyez sur la touche que ce code représente (Échap pour annuler)
Press a key… Appuyez sur une touche…
from your earlier imports d’après vos importations précédentes
from the format's public description d’après la description publique du format
not known yet encore inconnu
Shortcuts Raccourcis
Shortcut Raccourci
In the File Dans le fichier
In VectorCraft Dans VectorCraft
Needs Attention Only À vérifier uniquement
Ready Prêt
Needs a choice Choix nécessaire
Needs a key Touche nécessaire
No equivalent Aucun équivalent
Tools take a single key (optionally with Shift) Les outils prennent une seule touche (éventuellement avec Maj)
A later row takes this shortcut Une ligne suivante prend ce raccourci
Same item as a later row Même élément qu’une ligne suivante
Start from VectorCraft Defaults Partir des valeurs par défaut de VectorCraft
Add to the Current Shortcuts Ajouter aux raccourcis actuels
Remember My Choices Mémoriser mes choix
{ready} of {total} ready {ready} sur {total} prêts
The file is too large to read in full, so only its first part is listed. Le fichier est trop volumineux pour être lu en entier, seule sa première partie est donc listée.
Copy Report Copier le rapport
Report copied Rapport copié
Line {line} Ligne {line}
Line {line}: {name}: {status} Ligne {line}, {name}, {status}
Not read Non lu
Imported. Press OK to keep the imported set. Importé. Cliquez sur OK pour conserver le jeu importé.
Read Shortcut Set File Lire un fichier de jeu de raccourcis
Import Matches Correspondances d’importation
Remember Import Matches Mémoriser les correspondances d’importation
Forget Import Matches Oublier les correspondances d’importation
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts concorde avec {n} raccourci par défaut|concorde avec {n} raccourcis par défaut
@plural {n} part of the file wasn't read|{n} parts of the file weren't read {n} partie du fichier n’a pas été lue|{n} parties du fichier n’ont pas été lues
Keywords: Mots-clés :
Knife Cutter
Knockout Group Groupe perçant
@@ -3523,6 +3563,7 @@ axis Both Les deux
@msg Split Into Grid: select paths large enough for the grid Découper en grille : sélectionnez des tracés assez grands pour la grille
@msg Targa files are RGB: colorModel rgb Les fichiers Targa sont en RVB : colorModel rgb
@msg Text Wrap: select a wrap object Habillage de texte : sélectionnez un objet d’habillage
@msg This shortcut file is in a form VectorCraft can't read. Ce fichier de raccourcis est dans un format que VectorCraft ne sait pas lire.
@msg `aspect` must be a number (%) `aspect` doit être un nombre (%)
@msg `at` must be [x, y] `at` doit être [x, y]
@msg `color` must be a colour `color` doit être une couleur
@@ -3756,6 +3797,7 @@ axis Both Les deux
@msg not a VectorCraft file: {0} ce n’est pas un fichier VectorCraft : {0}
@msg not a VectorCraft shortcut set (missing `overrides`) ce n’est pas un jeu de raccourcis VectorCraft (`overrides` manquant)
@msg not a graphic style library (format `{_1}`) ce n’est pas une bibliothèque de styles graphiques (format `{_1}`)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace ce n’est pas un jeu de raccourcis, car le texte ne commence pas par un nom précédé d’une barre oblique et une accolade ouvrante
@msg not a stored central record ce n’est pas un enregistrement central stocké
@msg not a valid plug-in module: {0} ce n’est pas un module de plug-in valide : {0}
@msg not an EMF or WMF file ce n’est pas un fichier EMF ou WMF
Can't render this file because it contains an unexpected character in line 2 and column 73.
+42
View File
@@ -1397,6 +1397,46 @@
Keyboard Shortcut Conflicts Conflitti tra scelte rapide da tastiera
Keyboard Shortcut Set Set di scelte rapide da tastiera
Keyboard Shortcuts… Scelte rapide da tastiera…
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set Importa set di scelte rapide
Import Shortcut Set… Importa set di scelte rapide…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Ogni voce del file viene abbinata a uno strumento, un comando o un pannello di VectorCraft. Controlla gli abbinamenti e rispondi a ciò che resta.
Keys Tasti
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. Il file registra tasti e modificatori come codici. Controlla i significati proposti e indica i codici ancora sconosciuti.
Click, then press the key this code stands for (Esc cancels) Fai clic, quindi premi il tasto che questo codice rappresenta (Esc annulla)
Press a key… Premi un tasto…
from your earlier imports dalle tue importazioni precedenti
from the format's public description dalla descrizione pubblica del formato
not known yet ancora sconosciuto
Shortcuts Scelte rapide
Shortcut Scelta rapida
In the File Nel file
In VectorCraft In VectorCraft
Needs Attention Only Solo da controllare
Ready Pronto
Needs a choice Serve una scelta
Needs a key Serve un tasto
No equivalent Nessun equivalente
Tools take a single key (optionally with Shift) Gli strumenti accettano un solo tasto (facoltativamente con Shift)
A later row takes this shortcut Una riga successiva usa questa scelta rapida
Same item as a later row Stessa voce di una riga successiva
Start from VectorCraft Defaults Parti dai valori predefiniti di VectorCraft
Add to the Current Shortcuts Aggiungi alle scelte rapide attuali
Remember My Choices Ricorda le mie scelte
{ready} of {total} ready {ready} su {total} pronti
The file is too large to read in full, so only its first part is listed. Il file è troppo grande per essere letto per intero, quindi ne viene elencata solo la prima parte.
Copy Report Copia rapporto
Report copied Rapporto copiato
Line {line} Riga {line}
Line {line}: {name}: {status} Riga {line}: {name}: {status}
Not read Non letto
Imported. Press OK to keep the imported set. Importato. Premi OK per mantenere il set importato.
Read Shortcut Set File Leggi file di set di scelte rapide
Import Matches Abbinamenti di importazione
Remember Import Matches Ricorda abbinamenti di importazione
Forget Import Matches Dimentica abbinamenti di importazione
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts concorda con {n} scelta rapida predefinita|concorda con {n} scelte rapide predefinite
@plural {n} part of the file wasn't read|{n} parts of the file weren't read {n} parte del file non è stata letta|{n} parti del file non sono state lette
Keywords: Parole chiave:
Knife Coltello
Knockout Group Gruppo con foratura
@@ -3526,6 +3566,7 @@ axis Both Entrambi
@msg Split Into Grid: select paths large enough for the grid Dividi in griglia: seleziona tracciati abbastanza grandi per la griglia
@msg Targa files are RGB: colorModel rgb I file Targa sono RGB: colorModel rgb
@msg Text Wrap: select a wrap object Testo attorno: seleziona un oggetto di avvolgimento
@msg This shortcut file is in a form VectorCraft can't read. Questo file di scelte rapide è in un formato che VectorCraft non può leggere.
@msg `aspect` must be a number (%) `aspect` deve essere un numero (%)
@msg `at` must be [x, y] `at` deve essere [x, y]
@msg `color` must be a colour `color` deve essere un colore
@@ -3759,6 +3800,7 @@ axis Both Entrambi
@msg not a VectorCraft file: {0} non è un file VectorCraft: {0}
@msg not a VectorCraft shortcut set (missing `overrides`) non è un set di scelte rapide VectorCraft (manca `overrides`)
@msg not a graphic style library (format `{_1}`) non è una libreria di stili grafici (formato `{_1}`)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace non è un set di scelte rapide: il testo non inizia con un nome preceduto da una barra e una parentesi graffa aperta
@msg not a stored central record non è un record centrale archiviato
@msg not a valid plug-in module: {0} non è un modulo plug-in valido: {0}
@msg not an EMF or WMF file non è un file EMF o WMF
Can't render this file because it contains an unexpected character in line 2 and column 73.
+42
View File
@@ -1603,6 +1603,46 @@
Keyboard Increment キー入力
Keyboard Shortcut Conflicts キーボードショートカットの競合
Keyboard Shortcut Set キーボードショートカットセット
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set ショートカットセットを読み込み
Import Shortcut Set… ショートカットセットを読み込み…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. ファイルの各項目を VectorCraft のツール、コマンド、パネルに対応付けます。対応を確認して、残りの項目を指定してください。
Keys キー
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. このファイルはキーと修飾キーをコードで記録しています。提案された意味を確認して、まだ分からないコードを指定してください。
Click, then press the key this code stands for (Esc cancels) クリックしてから、このコードが表すキーを押します (Esc でキャンセル)
Press a key… キーを押してください…
from your earlier imports 以前の読み込みより
from the format's public description 形式の公開されている説明より
not known yet 未確定
Shortcuts ショートカット
Shortcut ショートカット
In the File ファイル内の名前
In VectorCraft VectorCraft での対応
Needs Attention Only 要確認の行のみ
Ready 適用可能
Needs a choice 選択が必要
Needs a key キーが必要
No equivalent 対応なし
Tools take a single key (optionally with Shift) ツールには 1 つのキーのみ割り当てられます (Shift との組み合わせは可)
A later row takes this shortcut 後の行がこのショートカットを使用
Same item as a later row 後の行と同じ項目
Start from VectorCraft Defaults VectorCraft の初期設定から開始
Add to the Current Shortcuts 現在のショートカットに追加
Remember My Choices 選択内容を記憶
{ready} of {total} ready {total} 件中 {ready} 件が適用可能
The file is too large to read in full, so only its first part is listed. ファイルが大きすぎて全体を読み込めないため、最初の部分のみを表示しています。
Copy Report レポートをコピー
Report copied レポートをコピーしました
Line {line} {line} 行目
Line {line}: {name}: {status} {line} 行目: {name}: {status}
Not read 読み込まれていません
Imported. Press OK to keep the imported set. 読み込みました。読み込んだセットを保持するには OK を押してください。
Read Shortcut Set File ショートカットセットファイルを読む
Import Matches 読み込み時の対応付け
Remember Import Matches 読み込み時の対応付けを記憶
Forget Import Matches 読み込み時の対応付けを消去
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts {n} 個の初期設定ショートカットと一致
@plural {n} part of the file wasn't read|{n} parts of the file weren't read ファイルの {n} 箇所を読み込めませんでした
Keywords: キーワード:
Knife ナイフ
Knockout Group グループの抜き
@@ -4008,6 +4048,7 @@ theme Appearance 外観
@msg Discarded {_1} recovered document(s) 復帰用データのドキュメントを {_1} 件破棄しました
@msg Restored {_1} recovered document(s): save them to keep them 復帰用データからドキュメントを {_1} 件復元しました: 残すには保存してください
@msg Saving recovery data for {_1} {_1} の復帰用データを保存中
@msg This shortcut file is in a form VectorCraft can't read. このショートカットファイルは VectorCraft で読み込めない形式です。
@msg bad shortcut ショートカットが正しくありません
@msg can't parse shortcut `{c}` ショートカット `{c}` を解釈できません
@msg can't parse shortcut `{s}` for `{k}` `{k}` のショートカット `{s}` を解釈できません
@@ -4015,6 +4056,7 @@ theme Appearance 外観
@msg no file reader ファイルの読み込み機能がありません
@msg no file writer ファイルの書き込み機能がありません
@msg not a VectorCraft shortcut set (missing `overrides`) VectorCraft のショートカットセットではありません (`overrides` がありません)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace ショートカットセットではありません: テキストの先頭にスラッシュ付きの名前と開き波かっこがありません
@msg shortcut for `{k}` must be a string `{k}` のショートカットは文字列で指定してください
@msg tool shortcuts must be a single key (optionally with Shift) ツールのショートカットには 1 つのキーを指定してください (Shift との組み合わせも可)
@msg unknown preset (one of {_1}) 不明なプリセットです ({_1} のいずれかを指定してください)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+42
View File
@@ -1395,6 +1395,46 @@
Keyboard Shortcut Conflicts Конфликты сочетаний клавиш
Keyboard Shortcut Set Набор сочетаний клавиш
Keyboard Shortcuts… Сочетания клавиш…
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set Импорт набора сочетаний клавиш
Import Shortcut Set… Импортировать набор сочетаний клавиш…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Каждая запись файла сопоставляется с инструментом, командой или панелью VectorCraft. Проверьте сопоставления и ответьте на оставшиеся вопросы.
Keys Клавиши
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. Файл записывает клавиши и модификаторы в виде кодов. Проверьте предложенные значения и укажите коды, которые ещё неизвестны.
Click, then press the key this code stands for (Esc cancels) Щёлкните, затем нажмите клавишу, которую обозначает этот код (Esc отменяет)
Press a key… Нажмите клавишу…
from your earlier imports по вашим прошлым импортам
from the format's public description по общедоступному описанию формата
not known yet пока неизвестно
Shortcuts Сочетания клавиш
Shortcut Сочетание клавиш
In the File В файле
In VectorCraft В VectorCraft
Needs Attention Only Только требующие внимания
Ready Готово
Needs a choice Нужен выбор
Needs a key Нужна клавиша
No equivalent Нет соответствия
Tools take a single key (optionally with Shift) Инструментам назначается одна клавиша (можно с Shift)
A later row takes this shortcut Это сочетание забирает строка ниже
Same item as a later row Тот же элемент, что и в строке ниже
Start from VectorCraft Defaults Начать со значений VectorCraft по умолчанию
Add to the Current Shortcuts Добавить к текущим сочетаниям клавиш
Remember My Choices Запомнить мой выбор
{ready} of {total} ready Готово: {ready} из {total}
The file is too large to read in full, so only its first part is listed. Файл слишком большой, чтобы прочитать его целиком, поэтому показана только его первая часть.
Copy Report Копировать отчёт
Report copied Отчёт скопирован
Line {line} Строка {line}
Line {line}: {name}: {status} Строка {line}: {name}: {status}
Not read Не прочитано
Imported. Press OK to keep the imported set. Импортировано. Нажмите «ОК», чтобы сохранить импортированный набор.
Read Shortcut Set File Прочитать файл набора сочетаний клавиш
Import Matches Сопоставления импорта
Remember Import Matches Запомнить сопоставления импорта
Forget Import Matches Забыть сопоставления импорта
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts совпадает с {n} сочетанием по умолчанию|совпадает с {n} сочетаниями по умолчанию|совпадает с {n} сочетаниями по умолчанию
@plural {n} part of the file wasn't read|{n} parts of the file weren't read не прочитана {n} часть файла|не прочитаны {n} части файла|не прочитано {n} частей файла
Keywords: Ключевые слова:
Knife Нож
Knockout Group Группа с вырезанием
@@ -3522,6 +3562,7 @@ axis Both Оба
@msg Split Into Grid: select paths large enough for the grid Разделение на сетку: выделите контуры, достаточно большие для сетки
@msg Targa files are RGB: colorModel rgb файлы Targa являются RGB: colorModel rgb
@msg Text Wrap: select a wrap object Обтекание текстом: выделите объект обтекания
@msg This shortcut file is in a form VectorCraft can't read. Этот файл сочетаний клавиш записан в формате, который VectorCraft не может прочитать.
@msg `aspect` must be a number (%) `aspect` должно быть числом (%)
@msg `at` must be [x, y] `at` должно быть [x, y]
@msg `color` must be a colour `color` должно быть цветом
@@ -3755,6 +3796,7 @@ axis Both Оба
@msg not a VectorCraft file: {0} не является файлом VectorCraft: {0}
@msg not a VectorCraft shortcut set (missing `overrides`) не является набором сочетаний клавиш VectorCraft (отсутствует `overrides`)
@msg not a graphic style library (format `{_1}`) не является библиотекой графических стилей (формат `{_1}`)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace это не набор сочетаний клавиш: текст не начинается с имени с косой чертой и открывающей фигурной скобки
@msg not a stored central record не является сохранённой центральной записью
@msg not a valid plug-in module: {0} не является допустимым модулем: {0}
@msg not an EMF or WMF file не является файлом EMF или WMF
Can't render this file because it contains an unexpected character in line 2 and column 73.
+42
View File
@@ -1391,6 +1391,46 @@
Keyboard Shortcut Conflicts Конфлікти поєднань клавіш
Keyboard Shortcut Set Набір клавіш
Keyboard Shortcuts… Поєднання клавіш…
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set Імпорт набору сполучень клавіш
Import Shortcut Set… Імпортувати набір сполучень клавіш…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Кожен запис файлу зіставляється з інструментом, командою або панеллю VectorCraft. Перевірте зіставлення й дайте відповідь на решту.
Keys Клавіші
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. Файл записує клавіші та модифікатори як коди. Перевірте запропоновані значення й укажіть коди, які ще невідомі.
Click, then press the key this code stands for (Esc cancels) Клацніть, потім натисніть клавішу, яку позначає цей код (Esc скасовує)
Press a key… Натисніть клавішу…
from your earlier imports з ваших попередніх імпортів
from the format's public description за загальнодоступним описом формату
not known yet ще невідомо
Shortcuts Сполучення клавіш
Shortcut Сполучення клавіш
In the File У файлі
In VectorCraft У VectorCraft
Needs Attention Only Лише ті, що потребують уваги
Ready Готово
Needs a choice Потрібен вибір
Needs a key Потрібна клавіша
No equivalent Немає відповідника
Tools take a single key (optionally with Shift) Інструментам призначається одна клавіша (можна із Shift)
A later row takes this shortcut Це сполучення забирає рядок нижче
Same item as a later row Той самий елемент, що й у рядку нижче
Start from VectorCraft Defaults Почати зі значень VectorCraft за промовчанням
Add to the Current Shortcuts Додати до поточних сполучень клавіш
Remember My Choices Запам’ятати мій вибір
{ready} of {total} ready Готово: {ready} з {total}
The file is too large to read in full, so only its first part is listed. Файл завеликий, щоб прочитати його повністю, тому показано лише його першу частину.
Copy Report Копіювати звіт
Report copied Звіт скопійовано
Line {line} Рядок {line}
Line {line}: {name}: {status} Рядок {line}: {name}: {status}
Not read Не прочитано
Imported. Press OK to keep the imported set. Імпортовано. Натисніть «ОК», щоб зберегти імпортований набір.
Read Shortcut Set File Прочитати файл набору сполучень клавіш
Import Matches Зіставлення імпорту
Remember Import Matches Запам’ятати зіставлення імпорту
Forget Import Matches Забути зіставлення імпорту
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts збігається з {n} сполученням за промовчанням|збігається з {n} сполученнями за промовчанням|збігається з {n} сполученнями за промовчанням
@plural {n} part of the file wasn't read|{n} parts of the file weren't read не прочитано {n} частину файлу|не прочитано {n} частини файлу|не прочитано {n} частин файлу
Keywords: Ключові слова:
Knife Ніж
Knockout Group Група з вирізуванням
@@ -3510,6 +3550,7 @@ axis Both Обидві
@msg Split Into Grid: select paths large enough for the grid Поділ на сітку: виділіть контури, досить великі для сітки
@msg Targa files are RGB: colorModel rgb Файли Targa є RGB: colorModel rgb
@msg Text Wrap: select a wrap object Обтікання текстом: виділіть об’єкт обтікання
@msg This shortcut file is in a form VectorCraft can't read. Цей файл сполучень клавіш записано у форматі, який VectorCraft не може прочитати.
@msg `aspect` must be a number (%) `aspect` має бути числом (%)
@msg `at` must be [x, y] `at` має бути [x, y]
@msg `color` must be a colour `color` має бути кольором
@@ -3743,6 +3784,7 @@ axis Both Обидві
@msg not a VectorCraft file: {0} не є файлом VectorCraft: {0}
@msg not a VectorCraft shortcut set (missing `overrides`) не є набором поєднань клавіш VectorCraft (відсутнє `overrides`)
@msg not a graphic style library (format `{_1}`) не є бібліотекою графічних стилів (формат `{_1}`)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace це не набір сполучень клавіш: текст не починається з імені зі скісною рискою та відкривної фігурної дужки
@msg not a stored central record не є збереженим центральним записом
@msg not a valid plug-in module: {0} не є допустимим модулем: {0}
@msg not an EMF or WMF file не є файлом EMF або WMF
Can't render this file because it contains an unexpected character in line 713 and column 40.
+38
View File
@@ -3293,6 +3293,44 @@ imageTrace Outline 轮廓
Set: 集:
Shortcut 快捷键
Use Default 使用默认值
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set 导入快捷键组
Import Shortcut Set… 导入快捷键组…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. 文件中的每个条目都会对应到 VectorCraft 的工具、命令或面板。请检查对应结果,并回答剩余的条目。
Keys 按键
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. 文件以代码记录按键和修饰键。请检查建议的含义,并回答尚未确定的代码。
Click, then press the key this code stands for (Esc cancels) 单击后按下此代码代表的键 (Esc 取消)
Press a key… 请按一个键…
from your earlier imports 来自你之前的导入
from the format's public description 依据格式的公开说明
not known yet 尚未确定
Shortcuts 快捷键
In the File 文件中的名称
In VectorCraft VectorCraft 中的对应
Needs Attention Only 仅显示需要处理的条目
Ready 就绪
Needs a choice 需要选择
Needs a key 需要按键
No equivalent 无对应项
Tools take a single key (optionally with Shift) 工具只能使用单个键 (可搭配 Shift)
A later row takes this shortcut 后面的行会使用此快捷键
Same item as a later row 与后面某一行的项目相同
Start from VectorCraft Defaults 从 VectorCraft 默认开始
Add to the Current Shortcuts 添加到当前快捷键
Remember My Choices 记住我的选择
{ready} of {total} ready {total} 个中有 {ready} 个就绪
The file is too large to read in full, so only its first part is listed. 文件太大,无法完整读取,因此只列出开头部分。
Copy Report 复制报告
Report copied 已复制报告
Line {line} 第 {line} 行
Line {line}: {name}: {status} 第 {line} 行:{name}:{status}
Not read 未读取
Read Shortcut Set File 读取快捷键组文件
Import Matches 导入对应
Remember Import Matches 记住导入对应
Forget Import Matches 清除导入对应
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts 与 {n} 个默认快捷键一致
@plural {n} part of the file wasn't read|{n} parts of the file weren't read 文件中有 {n} 个部分未读取
RGB: RGB:
Anchors 锚点
Image Trace (Make and Expand) 图像描摹 (创建并扩展)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+40
View File
@@ -1372,6 +1372,46 @@
Keyboard Shortcut Conflicts 鍵盤快速鍵衝突
Keyboard Shortcut Set 鍵盤快速鍵組
Keyboard Shortcuts… 鍵盤快速鍵…
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set 匯入快速鍵組
Import Shortcut Set… 匯入快速鍵組…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. 檔案中的每個項目都會對應到 VectorCraft 的工具、指令或面板。請檢查對應結果,並回答其餘項目。
Keys 按鍵
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. 檔案以代碼記錄按鍵與輔助鍵。請檢查建議的意義,並回答尚未得知的代碼。
Click, then press the key this code stands for (Esc cancels) 按一下,然後按下此代碼代表的按鍵 (Esc 取消)
Press a key… 請按下按鍵…
from your earlier imports 依據您先前的匯入
from the format's public description 依據格式的公開說明
not known yet 尚未得知
Shortcuts 快速鍵
Shortcut 快速鍵
In the File 檔案中的名稱
In VectorCraft VectorCraft 中的對應
Needs Attention Only 僅顯示需要處理的項目
Ready 就緒
Needs a choice 需要選擇
Needs a key 需要按鍵
No equivalent 沒有對應項目
Tools take a single key (optionally with Shift) 工具只能指定單一按鍵 (可搭配 Shift)
A later row takes this shortcut 後面的列會使用此快速鍵
Same item as a later row 與後面某一列的項目相同
Start from VectorCraft Defaults 從 VectorCraft 預設值開始
Add to the Current Shortcuts 加入目前的快速鍵
Remember My Choices 記住我的選擇
{ready} of {total} ready {total} 個中有 {ready} 個就緒
The file is too large to read in full, so only its first part is listed. 檔案太大,無法完整讀取,因此只列出開頭的部分。
Copy Report 複製報告
Report copied 已複製報告
Line {line} 第 {line} 行
Line {line}: {name}: {status} 第 {line} 行:{name}:{status}
Not read 未讀取
Imported. Press OK to keep the imported set. 已匯入。按一下「確定」以保留匯入的快速鍵組。
Read Shortcut Set File 讀取快速鍵組檔案
Import Matches 匯入對應
Remember Import Matches 記住匯入對應
Forget Import Matches 清除匯入對應
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts 與 {n} 個預設快速鍵一致
@plural {n} part of the file wasn't read|{n} parts of the file weren't read 檔案中有 {n} 個部分未讀取
Keywords: 關鍵字:
Knife 美工刀
Knockout Group 去底群組
Can't render this file because it contains an unexpected character in line 6 and column 47.
+23 -9
View File
@@ -16,10 +16,10 @@ const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"
/// Open bytes of any readable format as a new document (templates open untitled); swatch and
/// graphic style library files open in the library panel, Libraries panel files are added to it,
/// flattener, PDF, print and perspective grid presets files are imported, and a script written for
/// the reference app (`.jsx`, `.jsxinc`, `.jsxbin`) shows its check (Check Script…). →
/// `document.open`'s result for a document opened now (its `warnings` say what didn't come in as it
/// was), else null.
/// flattener, PDF, print and perspective grid presets files are imported, a shortcut set another
/// app saved (`.kys`) opens Import Shortcut Set, and a script written for the reference app
/// (`.jsx`, `.jsxinc`, `.jsxbin`) shows its check (Check Script…). → `document.open`'s result for
/// a document opened now (its `warnings` say what didn't come in as it was), else null.
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value, String> {
let opens = opens_as(name);
// A WebAssembly plug-in is installed.
@@ -29,6 +29,9 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or(name)));
return Ok(Value::Null);
}
if opens == Some(OpensAs::ShortcutSet) {
return dialogs::shortcut_import::open(app, name, bytes).map(|_| Value::Null);
}
if opens == Some(OpensAs::Script) {
let p = match path {
Some(path) => json!({ "path": path }),
@@ -66,12 +69,14 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
open_document(app, name, bytes, path, &Value::Null)
}
/// The kinds of file File → Open installs, imports, checks or shows in the library panel or the
/// Libraries panel ([`opens_as`]).
/// The kinds of file File → Open installs, imports, checks, shows in the library panel or the
/// Libraries panel, or reviews in Import Shortcut Set ([`opens_as`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum OpensAs {
/// A WebAssembly plug-in: installed.
Plugin,
/// A shortcut set another app saved (`.kys`): reviewed in Import Shortcut Set.
ShortcutSet,
/// A script written for the reference app: checked without running it (Check Script…).
Script,
/// Flattener, PDF, print or perspective grid presets: imported with command `import`, and
@@ -85,9 +90,10 @@ pub enum OpensAs {
StyleLibrary,
}
/// The kind of file named `name` (by its extension) that File → Open installs, imports, checks or
/// shows in the library panel or the Libraries panel ([`open_bytes`]), else `None`. A drop opens
/// these files the same way, wherever they are dropped ([`VectorcraftApp::drop_target`]).
/// The kind of file named `name` (by its extension) that File → Open installs, imports, checks,
/// shows in the library panel or the Libraries panel, or reviews in Import Shortcut Set
/// ([`open_bytes`]), else `None`. A drop opens these files the same way, wherever they are dropped
/// ([`VectorcraftApp::drop_target`]).
pub fn opens_as(name: &str) -> Option<OpensAs> {
let ext = fileio::extension(name);
let presets = [
@@ -98,6 +104,8 @@ pub fn opens_as(name: &str) -> Option<OpensAs> {
];
if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) {
Some(OpensAs::Plugin)
} else if vectorcraft_engine::cmd::shortcutset::EXTS.contains(&ext.as_str()) {
Some(OpensAs::ShortcutSet)
} else if vectorcraft_engine::cmd::scripts::EXTS.contains(&ext.as_str()) {
Some(OpensAs::Script)
} else if let Some(&(_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) {
@@ -214,6 +222,12 @@ pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result<Value, String>
/// [`open_path`]; with `background`, a document (not a file that installs, imports or asks
/// first) is decoded off the UI thread ([`crate::opening::start`]).
pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) -> Result<Value, String> {
// A shortcut set is read by Import Shortcut Set, up to its reader's cap.
if dialogs::shortcut_import::is_kys(path) {
dialogs::shortcut_import::command(app, &json!({ "path": path }))?;
note_recent(app, path);
return Ok(Value::Null);
}
// A script is read by `script.check`, up to its limit.
if opens_as(path) == Some(OpensAs::Script) {
dialogs::script_check::command(app, &json!({ "path": path }))?;
+2 -2
View File
@@ -1148,8 +1148,8 @@ impl VectorcraftApp {
self.take_dropped_files(ctx);
}
/// Files dropped on the window: documents, libraries, presets and plug-ins opened, pictures
/// and text placed on the canvas ([`Self::drop_target`]).
/// Files dropped on the window: documents, libraries, presets, plug-ins and shortcut sets
/// opened, pictures and text placed on the canvas ([`Self::drop_target`]).
#[cfg(not(target_arch = "wasm32"))]
fn take_dropped_files(&mut self, ctx: &egui::Context) {
let (dropped, shift) = ctx.input(|i| (i.raw.dropped_files.clone(), i.modifiers.shift));
+7
View File
@@ -181,6 +181,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
),
("shortcuts.export", "Export Keyboard Shortcuts…", "", "{path?}"),
("shortcuts.import", "Import Keyboard Shortcuts…", "", "{path? | data?}"),
(
"ui.shortcutImportDialog",
"Import Shortcut Set…",
"",
"{path? | dataBase64?, name?} open Import Shortcut Set for a shortcut set another app saved as text (.kys; without a file, pick one): each entry matched to a tool, command or panel, and its key and modifier codes decoded (dialog `shortcutImport`: name, start: defaults|current, remember, rows: [{name, section, line, key, modifiers, target: entry id | \"\" (no equivalent) | null (not chosen), from, choices, on}], keys and mods: {code: {text, from, votes, names}}); Apply assigns the ready rows over Edit › Keyboard Shortcuts' working set, remembers the matches (importMatches.set) and shows Keyboard Shortcuts, whose OK keeps them. File › Open of a .kys opens it too",
),
("view.outline", "Outline", "Cmd+Y", "{} toggle Outline/Preview"),
("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"),
("view.trimView", "Trim View", "", "{} toggle: hide everything outside the artboards"),
@@ -1420,6 +1426,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
crate::dialogs::find_links::find_in_folder_command(app, s("folder"), p.get("ids").and_then(Value::as_array).cloned())
}
"ui.packageDialog" => crate::dialogs::package::open(app),
"ui.shortcutImportDialog" => crate::dialogs::shortcut_import::command(app, p),
"file.showPackage" => crate::dialogs::package::show_package(app, p),
"docInfo.save" => crate::panels::doc_info::save_report(app, p),
// Slice Options… and Divide Slices…: their dialogs, on the selected slices.
+1
View File
@@ -66,6 +66,7 @@ const DOCUMENT_FREE: &[&str] = &[
"window.graphicStyleLibrary.other",
"shortcuts.import",
"shortcuts.export",
"ui.shortcutImportDialog",
];
impl Entry {
+5 -3
View File
@@ -44,7 +44,7 @@ pub enum DropTarget {
Place(DropAt),
/// Opened as File → Open opens it: no document is open, the drop missed the canvas (the tab
/// bar), it is a document dropped where the platform doesn't say, or it is a library, a presets
/// file or a plug-in.
/// file, a plug-in or a shortcut set.
Open,
}
@@ -302,7 +302,8 @@ impl VectorcraftApp {
/// onto the canvas of the open document (Shift embeds it), else opened. Where the platform
/// doesn't say (`None`: desktop drags carry no position), a document opens as a tab of its
/// own and a picture or text is placed in the middle of the view. A library, a presets file, a
/// plug-in or a script opens wherever it is dropped, as File → Open opens it ([`io::opens_as`]).
/// plug-in, a shortcut set or a script opens wherever it is dropped, as File → Open opens it
/// ([`io::opens_as`]).
pub fn drop_target(&self, name: &str, pos: Option<egui::Pos2>, shift: bool) -> DropTarget {
let (Some(rect), Some(st), Some(v)) = (self.canvas_rect, self.session.active(), self.view()) else { return DropTarget::Open };
let at = match pos {
@@ -391,7 +392,8 @@ pub fn drain(app: &mut VectorcraftApp) {
/// Files dragged over the window: the canvas is outlined where they would be placed. On the web
/// the browser gives a dragged file's type but not its name, so the canvas is outlined for any
/// file dragged over it, including libraries, presets and plug-ins, which open when dropped.
/// file dragged over it, including libraries, presets, plug-ins and shortcut sets, which open when
/// dropped.
pub fn paint_drop_highlight(app: &VectorcraftApp, ctx: &egui::Context, painter: &egui::Painter, rect: egui::Rect) {
let pos = drag_pos(ctx);
let placed = ctx.input(|i| {
+20 -5
View File
@@ -494,9 +494,20 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
let data = if let Some(v) = p.get("data") {
Ok(v.clone())
} else {
let path = s("path").or_else(|| crate::picks::open(app, &crate::FilePick::default()));
let pick = crate::FilePick { filters: vec![("Shortcut sets", crate::dialogs::shortcut_import::IMPORT_EXTS)], ..Default::default() };
let path = s("path").or_else(|| crate::picks::open(app, &pick));
// A `.kys` file is read by Import Shortcut Set, up to its reader's cap.
if let Some(path) = path.as_deref().filter(|p| crate::dialogs::shortcut_import::is_kys(p)) {
return Some(crate::dialogs::shortcut_import::command(app, &json!({ "path": path })));
}
match (path, app.services.read.as_ref()) {
(Some(path), Some(r)) => r(&path).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())),
(Some(path), Some(r)) => match r(&path) {
// A set another app saved as text opens Import Shortcut Set.
Ok(b) if crate::dialogs::shortcut_import::is_text_set(&path, &b) => {
return Some(crate::dialogs::shortcut_import::open(app, &path, &b));
}
b => b.and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())),
},
(None, _) => Err("cancelled".into()),
(_, None) => Err("no file reader".into()),
}
@@ -518,9 +529,13 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
Some(r)
}
/// While recording, Escape cancels the recording instead of closing the dialog.
/// While recording (here or in Import Shortcut Set), Escape cancels the recording instead of closing
/// the dialog.
pub fn is_recording(app: &VectorcraftApp) -> bool {
app.ui.dialog.as_ref().is_some_and(|d| d.kind == "shortcuts" && !d.str("__recording").is_empty())
app.ui
.dialog
.as_ref()
.is_some_and(|d| (d.kind == "shortcuts" || d.kind == crate::dialogs::shortcut_import::KIND) && !d.str("__recording").is_empty())
}
// ---------- dialog ----------
@@ -748,7 +763,7 @@ fn import_into_dialog(app: &mut VectorcraftApp) {
let (o, s) = (app.ui.shortcut_overrides.clone(), app.ui.shortcut_set.clone());
(app.ui.shortcut_overrides, app.ui.shortcut_set) = saved;
sync(&app.ui);
if let Some(d) = app.ui.dialog.as_mut() {
if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == "shortcuts") {
d.fields.insert("overrides".into(), serde_json::to_value(&o).unwrap_or(json!({})));
d.fields.insert("set".into(), json!(s));
d.fields.insert("__message".into(), json!(tl!("Imported. Press OK to keep the imported set.")));
+28
View File
@@ -77,6 +77,34 @@ fn control_requests_stay_inside_the_roots() {
let _ = std::fs::remove_dir_all(base);
}
/// A shortcut set another app saved (`.kys`) is read only from inside the read root, whichever way a
/// control request names it: `shortcutSet.read`, Import Shortcut Set, Keyboard Shortcuts' Import…
/// and File › Open.
#[test]
fn shortcut_sets_are_read_only_inside_the_read_root() {
let (mut app, tx, base) = confined("kys");
let set = "/Menus {\n /Acme\\ Undo { /Modifiers 0 /Key 48 }\n}\n";
std::fs::write(base.join("in/a.kys"), set).unwrap();
std::fs::write(base.join("outside/b.kys"), set).unwrap();
let execute = |command: &str, path: PathBuf| json!({"command": command, "params": {"path": s(path)}});
let r = request(&mut app, &tx, "engine.execute", execute("shortcutSet.read", base.join("in/a.kys")));
assert_eq!(r["result"]["entries"][0]["name"], "Acme Undo", "{r}");
for (method, params) in [
("engine.execute", execute("shortcutSet.read", base.join("outside/b.kys"))),
("engine.execute", execute("ui.shortcutImportDialog", base.join("outside/b.kys"))),
("engine.execute", execute("shortcuts.import", base.join("outside/b.kys"))),
("app.open", json!({"path": s(base.join("outside/b.kys"))})),
] {
let r = request(&mut app, &tx, method, params.clone());
assert!(r["error"].as_str().is_some_and(|e| e.contains("outside the read root")), "{method} {params}: {r}");
assert!(app.ui.dialog.is_none(), "{method} {params}: no dialog opens");
}
let r = request(&mut app, &tx, "engine.execute", execute("ui.shortcutImportDialog", base.join("in/a.kys")));
assert_eq!(r["ok"], true, "{r}");
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::shortcut_import::KIND));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn frames_with_injected_input_are_confined() {
let (mut app, tx, base) = confined("synthetic");
+47
View File
@@ -300,6 +300,32 @@ fn keyboard_shortcuts_import_and_export_ask_off_the_ui_thread() {
assert_eq!(*written.borrow(), ["/keys/out.json", "/keys/command.json"]);
}
/// Keyboard Shortcuts' Import… opens a shortcut set another app saved as text (`.kys`) in Import
/// Shortcut Set over the dialog when its file dialog answers, and the dialog's working set stays in
/// Keyboard Shortcuts, which Cancel shows again.
#[test]
fn keyboard_shortcuts_import_opens_a_text_set_over_the_dialog() {
let (mut app, shown, _) = app();
let dir = std::env::temp_dir().join(format!("vc-picks-kys-import-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let set = dir.join("Mine.kys");
std::fs::write(&set, "/Menus {\n /Acme\\ Undo { /Modifiers 11 /Key 902 }\n}\n").unwrap();
let path = set.to_string_lossy().into_owned();
app.run("edit.keyboardShortcuts", json!({})).unwrap();
click(&mut app, "Import…", dialogs);
assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)]));
shown.answer(&[&path]);
poll(&mut app);
let d = app.ui.dialog.as_ref().unwrap_or_else(|| panic!("no dialog: {}", app.ui.status));
assert_eq!(d.kind.as_str(), crate::dialogs::shortcut_import::KIND, "{}", app.ui.status);
for field in ["overrides", "set", "__message"] {
assert!(!d.fields.contains_key(field), "Keyboard Shortcuts' `{field}` stays in Keyboard Shortcuts");
}
crate::dialogs::cancel(&mut app);
assert_eq!(dialog_kind(&app), Some("shortcuts"));
let _ = std::fs::remove_dir_all(dir);
}
/// A dialog's folder button answered after the dialog closed fills nothing, and the status says
/// why.
#[test]
@@ -496,6 +522,27 @@ fn an_open_answer_goes_on_after_the_active_document_closes() {
assert_ne!(app.ui.status, CLOSED);
}
/// Import Shortcut Set… acts on no document either: its answer opens the set picked in Import
/// Shortcut Set even when the document active at the time closed meanwhile (Linux).
#[test]
fn an_import_shortcut_set_answer_goes_on_after_the_active_document_closes() {
let (mut app, shown, _) = with_dialogs(false);
let dir = std::env::temp_dir().join(format!("vc-picks-kys-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let set = dir.join("Mine.kys");
std::fs::write(&set, "/Menus {\n /Acme\\ Undo { /Modifiers 11 /Key 902 }\n}\n").unwrap();
let path = set.to_string_lossy().into_owned();
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
assert!(app.run("ui.shortcutImportDialog", json!({})).is_err());
app.run("file.close", json!({})).unwrap();
assert!(app.session.documents().is_empty());
shown.answer(&[&path]);
poll(&mut app);
assert_eq!(dialog_kind(&app), Some(crate::dialogs::shortcut_import::KIND), "{}", app.ui.status);
assert_ne!(app.ui.status, CLOSED);
let _ = std::fs::remove_dir_all(dir);
}
/// Save Changes' Save, answered by its Save dialog, saves and closes the document it asked about,
/// even when an earlier document closed meanwhile (Linux), then asks about the next one.
#[test]
+20
View File
@@ -211,6 +211,26 @@ fn a_dropped_libraries_panel_file_is_added_as_a_new_library() {
assert_eq!((app.session.documents().len(), images(&app, 0)), (1, 0), "neither opened as a document nor placed");
}
/// A shortcut set dropped on the window while a document is open opens Import Shortcut Set, as
/// File → Open opens it: from its path (desktop) and from its bytes dropped on the canvas (web).
#[test]
fn a_dropped_shortcut_set_opens_import_shortcut_set() {
let mut app = app();
let ctx = egui::Context::default();
let rect = laid_out(&mut app, &ctx);
let set = "/Menus {\n /Acme\\ Undo { /Modifiers 11 /Key 902 }\n}\n";
let path = temp_file("dropped.kys", set.as_bytes());
frame(&mut app, &ctx, vec![], &[&path], false);
let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone());
assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::shortcut_import::KIND), "{}", app.ui.status);
app.ui.dialog = None;
let target = app.drop_target("sent.kys", Some(rect.center()), false);
assert_eq!(target, DropTarget::Open);
crate::place::drop_files(&mut app, vec![(target, ("sent.kys".into(), None, set.as_bytes().to_vec()))]);
assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::shortcut_import::KIND), "{}", app.ui.status);
assert_eq!((app.session.documents().len(), images(&app, 0)), (1, 0), "neither opened as a document nor placed");
}
#[test]
fn a_dropped_png_is_placed_in_the_middle_of_the_view() {
let mut app = app();
+1 -1
View File
@@ -107,7 +107,7 @@ the two can still win that race.
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab |
| SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`, `profile` (`svg11`/`tiny12`), `encoding` (`utf8`/`utf16`/`latin1`), `embedFonts`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options |
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`) and plug-ins (`.wasm`) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`), plug-ins (`.wasm`) and shortcut sets (`.kys`, in Import Shortcut Set) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
| `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` |
| `app.export` JPEG options | `{path?, format: jpg, quality?: 0–100, colorModel?: rgb \| cmyk \| gray, method?: baseline \| optimized \| progressive, scans?: 3–5, embedIcc?, imageMap?: none \| client \| server}` | `cmyk` writes ink amounts in the working CMYK space (CMYK colours keep their inks; RGB ones are separated with the colour settings); `embedIcc` (default true) embeds sRGB, the working CMYK profile or a grey profile, generated by the CMS; `imageMap` also writes `<stem>.html` (client-side `<map>`) or `<stem>.map` (NCSA) beside the image for the objects with a URL and an Image Map shape (`attributes.set`), returned in `linked`. The `jpgOptions` dialog has these fields too (`quality` is shown 0–10; a CMYK document defaults to `cmyk`) |
| `app.export` PNG-8 and GIF | `{path?, format: png8 \| gif, colors?: 2–256, reduction?: perceptual \| selective \| adaptive \| web \| blackWhite \| gray, dither?: none \| diffusion \| pattern \| noise, ditherAmount?: 0–100, transparency?, matte?, interlaced?, …raster options}` | a palette of at most `colors` entries (art with that many colours or fewer keeps them exactly); pixels under half opacity become one transparent entry unless `transparency` is false, partly transparent edges are blended over `matte` (`none` keeps their colour). `png8` writes an indexed `.png` (1–8 bits per pixel); Export for Screens rows take both. The `png8Options` / `gifOptions` dialogs have these fields |
+1 -1
View File
@@ -29,7 +29,7 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
- **Open** sets `Services::open_async`, which shows `rfd::AsyncFileDialog`. The bytes arrive in `Services::inbox`, which the app drains every frame.
- **Save / Save As / Export** go through `Services::download`: a Blob, an object URL and a temporary `<a download>`, all created from Rust. There is no save dialog, so the suggested name becomes the download name.
- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox (files to be placed on a canvas go to `Services::place_inbox` with their `place::DropAt`: the document they were dropped on, by uid, and the point; `VectorcraftApp::drop_target` decides from the position the page's drag events give). Desktop drags carry no position (winit 0.30 reports none, and the pointer position egui last had is stale), so the desktop app decides by kind: documents open, pictures and text are placed in the middle of the view. On the web and the desktop, swatch and graphic style libraries, Libraries panel files, presets and plug-ins open wherever they are dropped, as File › Open opens them (`io::opens_as`). A file that arrives after another document became active still lands in its own document, which becomes active again; if that document closed meanwhile, the file is not placed and the status bar says so. (The app's synchronous drop path is compiled out on wasm32.)
- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox (files to be placed on a canvas go to `Services::place_inbox` with their `place::DropAt`: the document they were dropped on, by uid, and the point; `VectorcraftApp::drop_target` decides from the position the page's drag events give). Desktop drags carry no position (winit 0.30 reports none, and the pointer position egui last had is stale), so the desktop app decides by kind: documents open, pictures and text are placed in the middle of the view. On the web and the desktop, swatch and graphic style libraries, Libraries panel files, presets, plug-ins and shortcut sets open wherever they are dropped, as File › Open opens them (`io::opens_as`). A file that arrives after another document became active still lands in its own document, which becomes active again; if that document closed meanwhile, the file is not placed and the status bar says so. (The app's synchronous drop path is compiled out on wasm32.)
- **Data Recovery** keeps its copies in `localStorage` (`vectorcraft-recovery/<area>/<name>`, shared by the site's tabs), so tabs can't lock their areas the way desktop apps lock a folder (`RecoveryStore::lock`). Each tab holds its area with a heartbeat it refreshes every minute and, where the browser has Web Locks (secure pages: `https://` or localhost), with a Web Lock named after the area (`apps/vectorcraft-web/src/locks.rs`, through `js_sys::Reflect` since web-sys has them only as an unstable API). The browser releases the lock only when the tab is gone, not while its timers are paused in the background, which a heartbeat can't tell apart (#367). `navigator.locks.query()` is asynchronous, so the shell keeps a snapshot refreshed every 10 seconds (taken once before the first frame); a snapshot older than a minute counts every area as held. An area is offered as a crash's leftovers only when no tab holds its lock and its heartbeat is older than three intervals. Without Web Locks a paused tab can still be taken for gone; it repairs that when it resumes: its next heartbeat (or recovery save) writes again the copies missing from its area, and a heartbeat newer than the one the other tab wrote taking the area over makes the area running again for that tab.
- **Losing the graphics (#369):** the browser can drop the page's WebGPU device or WebGL context (a GPU reset, a driver update, too many tabs on the GPU; or a script calling `device.destroy()` / `WEBGL_lose_context.loseContext()`). The app goes on without it, so the shell watches for it: wgpu's device-lost callback for WebGPU, the canvas's `webglcontextlost` event for WebGL2. Both report to a `graphics::GraphicsLoss`, and the next frame of `WebShell::logic` handles it:
1. `VectorcraftApp::graphics_lost` writes Data Recovery copies of the modified documents at once (to browser storage, also when Data Recovery is turned off).
+29 -1
View File
@@ -65,7 +65,7 @@ the suite) keep the agent to the folders you give it:
- Every file a command reads must lie inside the **read root**: `open_file`, `file.place`, relinking and
updating links, the images an SVG links to, swatch library, graphic style library and colour-profile loads, plug-ins,
`file.newFromTemplate`, preset imports, the folders `text.findFontFiles` and `links.findFiles` search, the
`file.newFromTemplate`, preset imports, shortcut sets (`shortcutSet.read`), the folders `text.findFontFiles` and `links.findFiles` search, the
scripts `script.check` reads and the files they include.
- Every file a command writes must lie inside the **write root**: `save_file`, `export`, the save and
export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`,
@@ -3440,3 +3440,31 @@ Envelope Options.
{"name":"run_command","arguments":{"command":"object.envelope.editContents","params":{"editing":true}}}
{"name":"run_command","arguments":{"command":"object.mesh.movePoint","params":{"id":12,"index":4,"x":220,"y":140,"handle":0}}}
```
## Shortcut sets from other apps
`shortcutSet.read {path? | data? | dataBase64?, name?}` reads a shortcut set another app saved as text (a `.kys` file
that starts with `/Menus {`) without changing the document or the preferences. Each block that sets `/Key` is an
entry, listed with its `name`, its `section` (the names of the blocks around it), its codes as the file writes them
(`key`, `modifiers`, `context`, `represent`) and its `line`. `codes` counts the entries that use each key and modifier
code, and `unread` lists the tokens the reader skipped, with their lines. The reader stops at 4 MiB, 16 open blocks and
20,000 entries, cuts names at 1,024 bytes, and sets `truncated` when it reaches one of these limits. An XML shortcut
file fails with "This shortcut file is in a form VectorCraft can't read."
The import matches record what the names and codes in such files stand for in VectorCraft. They are kept with the
preferences, and resetting the preferences keeps them. `importMatches.set {matches: [{kind, name, target, label?}]}`
records them: kind `name` maps an entry name to a command id, `tool:<id>` or `panel:<id>`, and kinds `key` and
`modifiers` map a code, written in decimal, to a key or to modifiers as Edit › Keyboard Shortcuts writes them (`K`,
`Cmd+Shift`). A `target` of `""` records that there is no equivalent (for kind `modifiers`, no modifiers), and `null`
removes the match. `importMatches.list {kind?, query?}` lists them and `importMatches.reset {kind?}` removes them.
`shortcutSet.read` then adds the saved `target` to each entry, a `chord` to each entry whose codes are both known, and
the saved `key` or `mods` to each code.
In the desktop app, Edit › Keyboard Shortcuts › Import… and File › Open show such a file in Import Shortcut Set
(`ui.shortcutImportDialog {path? | dataBase64?, name?}` over the control channel). The dialog proposes targets from
the entry names and decodes the codes that VectorCraft's default shortcuts agree on.
```json
{"name":"run_command","arguments":{"command":"shortcutSet.read","params":{"path":"/Users/me/Studio.kys"}}}
{"name":"run_command","arguments":{"command":"importMatches.set","params":{"matches":[{"kind":"name","name":"Group","target":"object.group"},{"kind":"key","name":"901","target":"G"}]}}}
```
+1 -1
View File
@@ -62,7 +62,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) | 🟡 | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; broader pass remains) | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 |
| | **Total to full parity** (one agent; from [target-app-parity.md](target-app-parity.md)) | | **~360–590** |
+1
View File
@@ -430,6 +430,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box.
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected).
- Shortcut sets other apps save as text (`.kys`) open in Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts › Import… in the desktop app, or from a drop on the window. It matches each entry to a tool, command or panel by name and decodes the file's key and modifier codes where VectorCraft's default shortcuts agree on them; the user answers the rest, and Apply fills the Keyboard Shortcuts dialog's working set. The answers are kept with the preferences for the next import (`shortcutSet.read`, `importMatches.*`).
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
- **Tools:**
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain.