mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
Shortcuts: use the shared craft-keymap crate for the catalogue, overrides, dispatch and .kys import (#3119)
* Shortcuts: the keymap, overrides and dispatch come from craft-keymap
The editor's catalogue (commands, UI commands, panels, tools; one tool
group per tool; show/hide commands marked as toggles) builds a
craft-keymap Keymap. UiState::shortcut_overrides is a craft_keymap
Overrides stored through keymap::stored, which writes the same map of
entry key -> one string as before, with panel: keys and VectorCraft's
spellings, so earlier versions read the file; a value that is not text
drops that entry instead of every preference.
Key presses run through craft_keymap's Dispatcher with accept filters:
keys that type text go to the one-key text path, and while the Type
tool edits only command-key chords run. Held keys repeat. Conflicts
and assignment use the crate's conflicts and set_bindings. Extra Shift
no longer falls back to the chord without it.
* Shortcuts: .kys import matches and applies through craft-keymap
The review proposes targets with craft_keymap's namematch, and Apply
hands the ready rows to craft_keymap::plan as one set, each row's
target passed as the remembered answer for its entry. The later row
takes a chord, and commands outside the set lose the chords the set
gives. Rows with one name in one block keep their own targets.
The engine's own namematch module is removed.
* Shortcuts: Cmd+, beside other hits, web Mac platform, cached dialog view
- Pin craft-keymap at 362eb59f.
- Cmd+, opens Settings on a Mac after the frame's other shortcuts run, with the
Mac read from egui's OS; drop settings_chord, which dispatch no longer used.
- The web build follows the browser's OS, so Ctrl stays Ctrl on a Mac.
- The Keyboard Shortcuts dialog keeps its keymap, row texts and conflict count
between frames while the working set is unchanged.
- Every default shortcut text becomes its entry's binding (test).
- craft-keymap counts as a UI crate for the layer check.
- Translate "too many changed shortcuts"; the parse error reuses the existing
"can't parse shortcut `{c}`" message.
* Shortcuts: the chords the operating system takes can't be assigned
The keymap reserves craft_keymap::reserved::system for its platform:
window switching and closing, and the virtual consoles off the Mac.
The Keyboard Shortcuts dialog and shortcuts.set refuse them with
"<chord> is reserved by the system", and a saved override on one keeps
the entry's default. No default shortcut uses a reserved chord.
* Shortcuts: craft-keymap at craft-libs 2eca9b3a
craft-keymap comes from storytold/craft-libs (craft-libs #164 and #186), at the rev craft-pdf
already uses. The web build keeps the browser's full platform, so Windows browsers get Windows'
reserved chords, and each platform caches its own default keymap.
This commit is contained in:
Generated
+11
@@ -873,6 +873,16 @@ name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-keymap"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
@@ -5037,6 +5047,7 @@ name = "vectorcraft-ui-egui"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"craft-keymap",
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
|
||||
@@ -15,6 +15,7 @@ craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
|
||||
# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
|
||||
# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] }
|
||||
|
||||
@@ -1149,6 +1149,38 @@ mod tests {
|
||||
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
|
||||
}
|
||||
|
||||
/// Shortcut overrides an earlier version saved still load: its spellings, `panel:` keys, an
|
||||
/// override that can't fire (#487), an entry for a command this version lacks, and an old set
|
||||
/// name.
|
||||
#[test]
|
||||
fn shortcut_overrides_from_an_earlier_version_load() {
|
||||
use serde_json::json;
|
||||
use vectorcraft_ui_egui::{keymap, shortcut_editor};
|
||||
let mut raw = serde_json::to_value(vectorcraft_ui_egui::UiState::default()).unwrap();
|
||||
raw["shortcut_overrides"] = json!({
|
||||
"tool:pen": "Shift+9",
|
||||
"edit.preferences": "",
|
||||
"panel:layers": "Cmd+Alt+Shift+F7",
|
||||
"tool:groupSelection": "Shift+ShiftLeft",
|
||||
"test.unknown": "Cmd+Alt+Shift+F9",
|
||||
"view.grid": "Cmd+Period",
|
||||
});
|
||||
raw["shortcut_set"] = json!(shortcut_editor::LEGACY_PRESETS[0].0);
|
||||
let ui = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap().sanitized();
|
||||
assert_eq!(ui.shortcut_set, shortcut_editor::LEGACY_PRESETS[0].1);
|
||||
let km = keymap::keymap_with(&ui.shortcut_overrides);
|
||||
let text = |key: &str| keymap::effective_with(&km, key);
|
||||
assert_eq!(text("tool:pen").as_deref(), Some("Shift+9"));
|
||||
assert_eq!(text("edit.preferences"), None);
|
||||
assert_eq!(text("panel:layers").as_deref(), Some("Cmd+Alt+Shift+F7"));
|
||||
assert_eq!(text("tool:groupSelection").as_deref(), shortcut_editor::default_of("tool:groupSelection"), "back on its default");
|
||||
assert_eq!(text("view.grid").as_deref(), Some("Cmd+Period"));
|
||||
let period = keymap::vc_chord("Cmd+.").unwrap();
|
||||
assert_eq!(keymap::lookup(&km, &period).map(|c| c.id), Some("view.grid".to_string()));
|
||||
assert_eq!(text("test.unknown").as_deref(), Some("Cmd+Alt+Shift+F9"), "kept");
|
||||
assert!(km.problems().iter().any(|p| format!("{p:?}") == r#"UnknownCommand { key: "test.unknown" }"#), "reported");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_files_unminimizes_and_focuses_the_window() {
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
@@ -12,7 +12,6 @@ pub mod guard;
|
||||
pub mod inspect;
|
||||
pub mod journal;
|
||||
pub mod link_watch;
|
||||
pub mod namematch;
|
||||
pub mod steps;
|
||||
pub mod store;
|
||||
mod tooling;
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
//! Matching a name another app's file writes (a shortcut set's entry name) against VectorCraft's
|
||||
//! commands, tools and panels, by their ids and labels only. A match is proposed when one candidate
|
||||
//! scores well and clearly ahead of the next; otherwise the best few are offered to choose from.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A command, tool or panel a name can stand for.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Candidate {
|
||||
/// `select.all`, `tool:rectangle` or `panel:layers`.
|
||||
pub id: String,
|
||||
/// Its labels: the English one, and the one in the interface language.
|
||||
pub labels: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a name stands for, as far as [`propose`] can tell.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Proposal {
|
||||
/// The candidate at this index of the list, with its score (0 to 1).
|
||||
Match(usize, f64),
|
||||
/// The best candidates, best first, when none stands out.
|
||||
Choice(Vec<usize>),
|
||||
}
|
||||
|
||||
/// The lowest score proposed.
|
||||
const THRESHOLD: f64 = 0.6;
|
||||
/// How far the best score must lead the next one to be proposed.
|
||||
const MARGIN: f64 = 0.1;
|
||||
/// The most candidates a [`Proposal::Choice`] lists.
|
||||
const CHOICES: usize = 5;
|
||||
/// The lowest score a [`Proposal::Choice`] lists.
|
||||
const CHOICE_FLOOR: f64 = 0.3;
|
||||
|
||||
/// `s` with only its letters and digits, lowercased.
|
||||
fn folded(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
/// The words of `s`, lowercased: runs of letters and digits, split where a lowercase letter or a digit
|
||||
/// meets an uppercase one (`selectAll`: `select`, `all`).
|
||||
fn words(s: &str) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
let mut word = String::new();
|
||||
let mut prev_lower = false;
|
||||
for c in s.chars() {
|
||||
if !c.is_alphanumeric() {
|
||||
if !word.is_empty() {
|
||||
out.push(std::mem::take(&mut word));
|
||||
}
|
||||
prev_lower = false;
|
||||
continue;
|
||||
}
|
||||
if c.is_uppercase() && prev_lower && !word.is_empty() {
|
||||
out.push(std::mem::take(&mut word));
|
||||
}
|
||||
prev_lower = c.is_lowercase() || c.is_ascii_digit();
|
||||
word.extend(c.to_lowercase());
|
||||
}
|
||||
if !word.is_empty() {
|
||||
out.push(word);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The words of a candidate's id: its segments after any `tool:` or `panel:`, split as [`words`].
|
||||
fn id_words(id: &str) -> Vec<String> {
|
||||
let id = id.strip_prefix("tool:").or_else(|| id.strip_prefix("panel:")).unwrap_or(id);
|
||||
id.split('.').flat_map(words).collect()
|
||||
}
|
||||
|
||||
/// How many words `a` and `b` share, each word counted once, as a Dice coefficient (0 to 1).
|
||||
fn word_overlap(a: &[String], b: &[String]) -> f64 {
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut rest: Vec<&String> = b.iter().collect();
|
||||
let mut shared = 0usize;
|
||||
for w in a {
|
||||
if let Some(i) = rest.iter().position(|x| *x == w) {
|
||||
rest.swap_remove(i);
|
||||
shared += 1;
|
||||
}
|
||||
}
|
||||
(2 * shared) as f64 / (a.len() + b.len()) as f64
|
||||
}
|
||||
|
||||
/// The pairs of neighboring characters in `s`.
|
||||
fn pairs(s: &str) -> Vec<(char, char)> {
|
||||
let c: Vec<char> = s.chars().collect();
|
||||
c.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect()
|
||||
}
|
||||
|
||||
/// How many character pairs `a` and `b` (each sorted) share, as a Dice coefficient (0 to 1): for
|
||||
/// names written without spaces. Linear in their lengths, so long names in a file stay quick.
|
||||
fn pair_similarity(a: &[(char, char)], b: &[(char, char)]) -> f64 {
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let (mut i, mut j, mut shared) = (0usize, 0usize, 0usize);
|
||||
while let (Some(p), Some(q)) = (a.get(i), b.get(j)) {
|
||||
match p.cmp(q) {
|
||||
std::cmp::Ordering::Less => i += 1,
|
||||
std::cmp::Ordering::Greater => j += 1,
|
||||
std::cmp::Ordering::Equal => {
|
||||
shared += 1;
|
||||
i += 1;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(2 * shared) as f64 / (a.len() + b.len()) as f64
|
||||
}
|
||||
|
||||
/// A name or label as the scores compare it: its letters and digits (lowercased), its words and the
|
||||
/// character pairs of the former (sorted).
|
||||
struct Text {
|
||||
folded: String,
|
||||
words: Vec<String>,
|
||||
pairs: Vec<(char, char)>,
|
||||
}
|
||||
|
||||
impl Text {
|
||||
fn of(s: &str) -> Self {
|
||||
let folded = folded(s);
|
||||
let mut pairs = pairs(&folded);
|
||||
pairs.sort_unstable();
|
||||
Self { pairs, words: words(s), folded }
|
||||
}
|
||||
}
|
||||
|
||||
/// A candidate as the scores compare it.
|
||||
struct Prepared {
|
||||
/// Its id's words, and those joined.
|
||||
id_words: Vec<String>,
|
||||
id: String,
|
||||
labels: Vec<Text>,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
fn of(c: &Candidate) -> Self {
|
||||
let id_words = id_words(&c.id);
|
||||
Self { id: id_words.concat(), id_words, labels: c.labels.iter().map(|l| Text::of(l)).collect() }
|
||||
}
|
||||
|
||||
/// See [`score`].
|
||||
fn score(&self, name: &Text) -> f64 {
|
||||
if name.folded.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
if name.folded == self.id || self.labels.iter().any(|l| l.folded == name.folded) {
|
||||
return 1.0;
|
||||
}
|
||||
let by_words = self.labels.iter().map(|l| word_overlap(&name.words, &l.words)).fold(word_overlap(&name.words, &self.id_words), f64::max);
|
||||
let by_pairs = self.labels.iter().map(|l| pair_similarity(&name.pairs, &l.pairs)).fold(0.0, f64::max);
|
||||
by_words.max(by_pairs * 0.9)
|
||||
}
|
||||
}
|
||||
|
||||
/// How well `name` fits `candidate` (0 to 1): 1 when it equals a label or the id, its letters and
|
||||
/// digits compared without case; else the best of word overlap and character-pair similarity, the
|
||||
/// latter counted a little lower.
|
||||
pub fn score(name: &str, candidate: &Candidate) -> f64 {
|
||||
Prepared::of(candidate).score(&Text::of(name))
|
||||
}
|
||||
|
||||
/// The candidate `name` stands for, when one scores at least 0.6 and 0.1 ahead of every other; else
|
||||
/// the best five that score at least 0.3, to choose from.
|
||||
pub fn propose(name: &str, candidates: &[Candidate]) -> Proposal {
|
||||
Matcher::new(candidates).propose(name)
|
||||
}
|
||||
|
||||
/// [`propose`] for many names against the same candidates: each candidate's words and character
|
||||
/// pairs are worked out once, and a name is scored only against the candidates that share a word or
|
||||
/// a character pair with it (the others score 0).
|
||||
pub struct Matcher {
|
||||
prepared: Vec<Prepared>,
|
||||
/// Candidates by their joined id words and their labels' letters and digits.
|
||||
exact: HashMap<String, Vec<usize>>,
|
||||
/// Candidates by a word of their id or labels.
|
||||
by_word: HashMap<String, Vec<usize>>,
|
||||
/// Candidates by a character pair of their labels.
|
||||
by_pair: HashMap<(char, char), Vec<usize>>,
|
||||
}
|
||||
|
||||
impl Matcher {
|
||||
pub fn new(candidates: &[Candidate]) -> Self {
|
||||
let prepared: Vec<Prepared> = candidates.iter().map(Prepared::of).collect();
|
||||
let (mut exact, mut by_word, mut by_pair) = (HashMap::new(), HashMap::new(), HashMap::new());
|
||||
fn add<K: std::hash::Hash + Eq>(map: &mut HashMap<K, Vec<usize>>, key: K, i: usize) {
|
||||
let list = map.entry(key).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
for (i, p) in prepared.iter().enumerate() {
|
||||
add(&mut exact, p.id.clone(), i);
|
||||
for l in &p.labels {
|
||||
add(&mut exact, l.folded.clone(), i);
|
||||
}
|
||||
for w in p.id_words.iter().chain(p.labels.iter().flat_map(|l| &l.words)) {
|
||||
add(&mut by_word, w.clone(), i);
|
||||
}
|
||||
for pair in p.labels.iter().flat_map(|l| &l.pairs) {
|
||||
add(&mut by_pair, *pair, i);
|
||||
}
|
||||
}
|
||||
Self { prepared, exact, by_word, by_pair }
|
||||
}
|
||||
|
||||
/// See [`propose`].
|
||||
pub fn propose(&self, name: &str) -> Proposal {
|
||||
let text = Text::of(name);
|
||||
let mut seen = vec![false; self.prepared.len()];
|
||||
let lists = self
|
||||
.exact
|
||||
.get(&text.folded)
|
||||
.into_iter()
|
||||
.chain(text.words.iter().filter_map(|w| self.by_word.get(w)))
|
||||
// Each pair looked up once (they are sorted).
|
||||
.chain(text.pairs.chunk_by(|a, b| a == b).filter_map(|c| self.by_pair.get(c.first()?)));
|
||||
let mut scored: Vec<(usize, f64)> = vec![];
|
||||
for &i in lists.flatten() {
|
||||
if let Some(s) = seen.get_mut(i)
|
||||
&& !*s
|
||||
{
|
||||
*s = true;
|
||||
let score = self.prepared.get(i).map_or(0.0, |p| p.score(&text));
|
||||
if score >= CHOICE_FLOOR {
|
||||
scored.push((i, score));
|
||||
}
|
||||
}
|
||||
}
|
||||
scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
match (scored.first(), scored.get(1)) {
|
||||
(Some(&(i, best)), next) if best >= THRESHOLD && next.is_none_or(|n| best - n.1 >= MARGIN) => Proposal::Match(i, best),
|
||||
_ => Proposal::Choice(scored.iter().take(CHOICES).map(|(i, _)| *i).collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn candidates(list: &[(&str, &str)]) -> Vec<Candidate> {
|
||||
list.iter().map(|(id, label)| Candidate { id: id.to_string(), labels: vec![label.to_string()] }).collect()
|
||||
}
|
||||
|
||||
fn proposed<'a>(name: &str, list: &'a [Candidate]) -> Option<&'a str> {
|
||||
match propose(name, list) {
|
||||
Proposal::Match(i, _) => list.get(i).map(|c| c.id.as_str()),
|
||||
Proposal::Choice(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_words_still_find_the_tool() {
|
||||
let tools = candidates(&[
|
||||
("tool:rectangle", "Rectangle Tool"),
|
||||
("tool:roundedRectangle", "Rounded Rectangle Tool"),
|
||||
("tool:rectangularGrid", "Rectangular Grid Tool"),
|
||||
("tool:ellipse", "Ellipse Tool"),
|
||||
]);
|
||||
assert_eq!(proposed("Acme Rectangle Tool", &tools), Some("tool:rectangle"));
|
||||
assert_eq!(proposed("Rounded Rectangle", &tools), Some("tool:roundedRectangle"));
|
||||
assert_eq!(proposed("ellipse tool", &tools), Some("tool:ellipse"), "case aside, the label itself");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letters_only_names_match_ids() {
|
||||
let commands = candidates(&[("acme.copyAll", "Everything"), ("acme.copyAllOnPage", "Everything on This Page"), ("acme.clear", "Clear Out")]);
|
||||
assert_eq!(proposed("acmecopyall", &commands), Some("acme.copyAll"));
|
||||
assert_eq!(proposed("Acme Copy All On Page", &commands), Some("acme.copyAllOnPage"));
|
||||
assert_eq!(proposed("clear out", &commands), Some("acme.clear"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ties_need_a_choice() {
|
||||
let tools = candidates(&[("tool:rectangle", "Rectangle Tool"), ("tool:ellipse", "Ellipse Tool"), ("tool:star", "Star Tool")]);
|
||||
assert_eq!(propose("Tool", &tools), Proposal::Choice(vec![0, 1, 2]));
|
||||
assert_eq!(propose("Nothing Alike", &tools), Proposal::Choice(vec![]));
|
||||
assert_eq!(propose("", &tools), Proposal::Choice(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_without_spaces_match_by_character_pairs() {
|
||||
let list = vec![
|
||||
Candidate { id: "tool:rectangle".into(), labels: vec!["Rectangle Tool".into(), "長方形ツール".into()] },
|
||||
Candidate { id: "tool:ellipse".into(), labels: vec!["Ellipse Tool".into(), "楕円形ツール".into()] },
|
||||
];
|
||||
assert_eq!(proposed("長方形ツール", &list), Some("tool:rectangle"));
|
||||
assert_eq!(proposed("楕円形 ツール", &list), Some("tool:ellipse"), "spaces and punctuation aside");
|
||||
assert_eq!(proposed("楕円形ツール2", &list), Some("tool:ellipse"), "most character pairs shared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_index_finds_what_scoring_every_candidate_finds() {
|
||||
let list = candidates(&[
|
||||
("tool:rectangle", "Rectangle Tool"),
|
||||
("tool:roundedRectangle", "Rounded Rectangle Tool"),
|
||||
("tool:ellipse", "Ellipse Tool"),
|
||||
("select.all", "All"),
|
||||
("select.none", "Deselect"),
|
||||
("edit.undo", "Undo"),
|
||||
("foo.bar", "Zzz"),
|
||||
("panel:layers", "Layers"),
|
||||
]);
|
||||
let matcher = Matcher::new(&list);
|
||||
for name in ["Acme Rectangle Tool", "rounded", "acmecopyall", "foobar", "zz", "Lay", "Undo Redo", "ツール", "", "e"] {
|
||||
let mut scored: Vec<(usize, f64)> =
|
||||
list.iter().enumerate().map(|(i, c)| (i, score(name, c))).filter(|(_, s)| *s >= CHOICE_FLOOR).collect();
|
||||
scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
let every = match (scored.first(), scored.get(1)) {
|
||||
(Some(&(i, best)), next) if best >= THRESHOLD && next.is_none_or(|n| best - n.1 >= MARGIN) => Proposal::Match(i, best),
|
||||
_ => Proposal::Choice(scored.iter().take(CHOICES).map(|(i, _)| *i).collect()),
|
||||
};
|
||||
assert_eq!(matcher.propose(name), every, "{name}");
|
||||
}
|
||||
assert_eq!(proposed("foobar", &list), Some("foo.bar"), "the id alone, sharing no word or pair with the label");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_split_camel_case_and_separators() {
|
||||
assert_eq!(words("selectAll_onArtboard 2x"), ["select", "all", "on", "artboard", "2x"]);
|
||||
assert_eq!(words("Acme\u{a0}Lasso-Tool"), ["acme", "lasso", "tool"]);
|
||||
assert_eq!(id_words("tool:roundedRectangle"), ["rounded", "rectangle"]);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ interface-fonts = []
|
||||
|
||||
[dependencies]
|
||||
craft-ui = { workspace = true }
|
||||
craft-keymap = { workspace = true, features = ["egui"] }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
//! 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
|
||||
//! ([`craft_keymap::import::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.
|
||||
//! section. Apply hands the rows that are ready, in the file's order, to [`craft_keymap::plan`], each
|
||||
//! row's target given as the user's answer for it: when two rows want one shortcut, the later row
|
||||
//! takes it, and the tools, commands and panels outside the set lose the shortcuts the set gives.
|
||||
//! 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`,
|
||||
@@ -20,9 +21,11 @@
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use craft_keymap::import::namematch::{Candidate, Matcher, Proposal};
|
||||
use craft_keymap::import::{MAX_IMPORT_TEXT, MAX_PATH_SEGMENTS};
|
||||
use craft_keymap::{CommandRef, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, NoneDefined, Start, Trigger, plan};
|
||||
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;
|
||||
@@ -481,7 +484,7 @@ pub fn command(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
open_read(app, json!({ "path": path }))
|
||||
}
|
||||
|
||||
/// Apply: the ready rows assigned in the file's order over VectorCraft's defaults (or the current
|
||||
/// Apply: the ready rows applied as one set ([`apply_ready`]) 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}`
|
||||
@@ -489,19 +492,14 @@ 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(),
|
||||
let current = d.str("start") == "current";
|
||||
let ov = match (current, &back) {
|
||||
(true, Some(b)) => b.fields.get("overrides").map(|v| crate::keymap::stored::from_value(v.clone())).unwrap_or_default(),
|
||||
(true, None) => app.ui.shortcut_overrides.clone(),
|
||||
(false, _) => BTreeMap::new(),
|
||||
(false, _) => craft_keymap::Overrides::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 mut km = crate::keymap::keymap_with(&ov);
|
||||
let applied = apply_ready(&mut km, &review, &statuses, if current { Start::Current } else { Start::Defaults });
|
||||
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();
|
||||
@@ -510,7 +508,7 @@ pub fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
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("overrides".into(), crate::keymap::stored::to_value(km.overrides()));
|
||||
k.fields.insert("set".into(), json!(set));
|
||||
k.fields.insert("__message".into(), json!(tl!("Imported. Press OK to keep the imported set.")));
|
||||
}
|
||||
@@ -520,6 +518,59 @@ pub fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
Ok(json!({ "applied": applied, "rows": review.rows.len() }))
|
||||
}
|
||||
|
||||
/// Text cut to what [`craft_keymap::plan`] reads, so an entry's [`ForeignEntry::remember_key`] is
|
||||
/// the one `plan` looks up.
|
||||
fn within_cap(s: &str) -> String {
|
||||
s.chars().take(MAX_IMPORT_TEXT).collect()
|
||||
}
|
||||
|
||||
/// The ready rows applied to `km` as one set through [`craft_keymap::plan`]. Each row's target is
|
||||
/// passed as the user's answer for its entry, so the review's matching decides; a binding the plan
|
||||
/// gives any other command is left out. → the number of rows applied.
|
||||
fn apply_ready(km: &mut Keymap, review: &Review, statuses: &[(Status, Option<String>)], start: Start) -> usize {
|
||||
let mut entries: Vec<ForeignEntry> = vec![];
|
||||
// Per entry: its row's line and target.
|
||||
let mut rows: Vec<(usize, &str)> = vec![];
|
||||
let mut remembered: BTreeMap<String, CommandRef> = BTreeMap::new();
|
||||
for (r, (status, chord)) in review.rows.iter().zip(statuses) {
|
||||
let (Status::Ready, Some(target), Some(text)) = (*status, r.target.as_deref(), chord.as_deref()) else { continue };
|
||||
let Some(chord) = crate::keymap::vc_chord(text) else { continue };
|
||||
// Room for the name and a `#line` segment within the segments `plan` reads.
|
||||
let mut path: Vec<String> =
|
||||
r.section.split(" › ").filter(|s| !s.is_empty()).take(MAX_PATH_SEGMENTS.saturating_sub(2)).map(within_cap).collect();
|
||||
path.push(within_cap(&r.name));
|
||||
let key = ForeignKey { context: None, text: text.to_string(), trigger: Ok(Trigger::single(chord)), notes: vec![] };
|
||||
let mut entry = ForeignEntry::new(None, path.clone()).with_key(key).at_line(r.line);
|
||||
if target.starts_with("tool:") {
|
||||
entry = entry.tool();
|
||||
}
|
||||
let command = crate::keymap::command_of(target);
|
||||
// Rows with one name in one section can stand for different items: each keeps its own answer.
|
||||
let mut n = 0usize;
|
||||
while remembered.get(&entry.remember_key()).is_some_and(|c| *c != command) {
|
||||
n += 1;
|
||||
entry.path = path.clone();
|
||||
entry.path.push(if n == 1 { format!("#{}", r.line) } else { format!("#{}-{n}", r.line) });
|
||||
}
|
||||
remembered.insert(entry.remember_key(), command);
|
||||
entries.push(entry);
|
||||
rows.push((r.line, target));
|
||||
}
|
||||
let options = ImportOptions { remembered, start, none_defined: NoneDefined::Keep, suggest: false, ..ImportOptions::default() };
|
||||
let mut planned = plan(&ForeignSet::new(entries), km, &options);
|
||||
for p in &mut planned.commands {
|
||||
let key = crate::keymap::entry_key(&p.command);
|
||||
p.bindings.retain(|b| rows.get(b.order).is_some_and(|(_, t)| *t == key));
|
||||
}
|
||||
let report = planned.apply(km);
|
||||
let planned_rows: HashSet<usize> = planned.commands.iter().flat_map(|p| p.bindings.iter().map(|b| b.order)).collect();
|
||||
let left_out = |line: usize, target: &str| {
|
||||
report.skipped.iter().any(|s| s.line == Some(line) && crate::keymap::entry_key(&s.command) == target)
|
||||
|| report.failed.iter().any(|f| crate::keymap::entry_key(&f.command) == target)
|
||||
};
|
||||
rows.iter().enumerate().filter(|(i, (line, target))| planned_rows.contains(i) && !left_out(*line, target)).count()
|
||||
}
|
||||
|
||||
/// 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![];
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use craft_keymap::import::namematch::Candidate;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
@@ -360,7 +361,7 @@ fn keyboard_shortcuts_import_reads_both_kinds_of_set() {
|
||||
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(),
|
||||
serde_json::to_vec(&shortcut_editor::export_json("Mine", &crate::keymap::overrides_from(&[("tool:pen", "Shift+9")]))).unwrap(),
|
||||
),
|
||||
("/sets/Text.json", b"\xEF\xBB\xBF /Menus { /Acme\\ Undo { /Key 1 } }".to_vec()),
|
||||
]);
|
||||
@@ -465,3 +466,113 @@ fn a_large_set_keeps_its_buttons_on_a_small_window() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies `rows` (name, section, target, shortcut) through the review, every code answered, starting
|
||||
/// from `start` → Apply's result and the working set Keyboard Shortcuts shows.
|
||||
fn apply_rows(app: &mut VectorcraftApp, start: &str, rows: &[(&str, &str, &str, &str)]) -> (Value, Value) {
|
||||
super::shortcut_import::open(app, "Set.kys", SET.as_bytes()).unwrap();
|
||||
let mut r = Review::default();
|
||||
for (i, (name, section, target, chord)) in rows.iter().enumerate() {
|
||||
let code = 1000 + i as i64;
|
||||
let (mods, key) = chord.rsplit_once('+').unwrap_or(("", chord));
|
||||
r.keys.insert(code, Code { text: Some(key.into()), from: "you".into(), ..Default::default() });
|
||||
r.mods.insert(code, Code { text: Some(mods.into()), from: "you".into(), ..Default::default() });
|
||||
let target = Some(target.to_string());
|
||||
r.rows.push(Row {
|
||||
name: name.to_string(),
|
||||
section: section.to_string(),
|
||||
line: i + 2,
|
||||
key: Some(code),
|
||||
modifiers: Some(code),
|
||||
target,
|
||||
from: "you".into(),
|
||||
on: true,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
assert!(r.statuses().iter().all(|s| s.0 == Status::Ready), "{:?}", r.statuses());
|
||||
set(app, "rows", serde_json::to_value(&r.rows).unwrap());
|
||||
set(app, "keys", serde_json::to_value(&r.keys).unwrap());
|
||||
set(app, "mods", serde_json::to_value(&r.mods).unwrap());
|
||||
set(app, "start", json!(start));
|
||||
set(app, "remember", json!(false));
|
||||
let out = confirm(app).unwrap();
|
||||
(out, dialog(app).fields["overrides"].clone())
|
||||
}
|
||||
|
||||
/// Cmd and Ctrl are one key off the Mac: the review lets both rows through, and the later one takes
|
||||
/// the shortcut.
|
||||
#[test]
|
||||
fn a_later_row_takes_a_shortcut_that_reads_the_same() {
|
||||
let mut app = app();
|
||||
let rows = [("Acme Copy", "Menus", "edit.copy", "Cmd+Alt+Shift+F10"), ("Acme Undo", "Menus", "edit.undo", "Ctrl+Alt+Shift+F10")];
|
||||
let (out, ov) = apply_rows(&mut app, "defaults", &rows);
|
||||
assert_eq!(ov["edit.undo"], "Ctrl+Alt+Shift+F10");
|
||||
if craft_keymap::Platform::current() == craft_keymap::Platform::Mac {
|
||||
assert_eq!((out["applied"].clone(), ov["edit.copy"].clone()), (json!(2), json!("Cmd+Alt+Shift+F10")));
|
||||
} else {
|
||||
assert_eq!(out["applied"], 1);
|
||||
assert!(ov.get("edit.copy").is_none(), "Copy keeps its default: {ov}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A shortcut the set gives is taken from a default outside the set and from the working set's own
|
||||
/// change, while the working set's other changes stay with Start: Current.
|
||||
#[test]
|
||||
fn the_set_takes_its_shortcuts_from_outside_it() {
|
||||
let mut app = app();
|
||||
assert_eq!(shortcut_editor::default_of("path.join"), Some("Cmd+J"));
|
||||
let rows = [("Acme Copy", "Menus › Edit", "edit.copy", "Cmd+J"), ("Acme Undo", "Menus › Edit", "edit.undo", "Cmd+Alt+Shift+9")];
|
||||
let (out, ov) = apply_rows(&mut app, "defaults", &rows);
|
||||
assert_eq!(out["applied"], 2);
|
||||
assert_eq!(ov, json!({"edit.copy": "Cmd+J", "edit.undo": "Cmd+Alt+Shift+9", "path.join": ""}));
|
||||
// From the current shortcuts: the parent dialog's working set.
|
||||
app.ui.dialog = None;
|
||||
shortcut_editor::open(&mut app);
|
||||
set(&mut app, "overrides", json!({"edit.preferences": "Cmd+Alt+Shift+9", "edit.redo": "Cmd+Alt+Shift+F11"}));
|
||||
let (_, ov) = apply_rows(&mut app, "current", &rows);
|
||||
assert_eq!(
|
||||
ov,
|
||||
json!({"edit.copy": "Cmd+J", "edit.undo": "Cmd+Alt+Shift+9", "edit.preferences": "", "edit.redo": "Cmd+Alt+Shift+F11", "path.join": ""})
|
||||
);
|
||||
// From the defaults: the working set's changes are dropped.
|
||||
set(&mut app, "overrides", json!({"edit.redo": "Cmd+Alt+Shift+F11"}));
|
||||
let (_, ov) = apply_rows(&mut app, "defaults", &rows);
|
||||
assert_eq!(ov, json!({"edit.copy": "Cmd+J", "edit.undo": "Cmd+Alt+Shift+9", "path.join": ""}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_rows_go_to_tools_and_command_rows_to_commands() {
|
||||
let mut app = app();
|
||||
let rows = [("Pen", "Tools", "tool:pen", "Shift+9"), ("Pen Tool", "Menus", "edit.copy", "Cmd+Alt+Shift+F12")];
|
||||
let (out, ov) = apply_rows(&mut app, "defaults", &rows);
|
||||
assert_eq!(out["applied"], 2);
|
||||
assert_eq!(ov, json!({"tool:pen": "Shift+9", "edit.copy": "Cmd+Alt+Shift+F12"}));
|
||||
}
|
||||
|
||||
/// Two entries with one name in one block can be answered with different items.
|
||||
#[test]
|
||||
fn rows_with_one_name_keep_their_own_targets() {
|
||||
let mut app = app();
|
||||
let rows = [("Acme Twin", "Menus", "edit.copy", "Cmd+Alt+Shift+F11"), ("acme twin…", "Menus", "edit.undo", "Cmd+Alt+Shift+F12")];
|
||||
let (out, ov) = apply_rows(&mut app, "defaults", &rows);
|
||||
assert_eq!(out["applied"], 2);
|
||||
assert_eq!(ov, json!({"edit.copy": "Cmd+Alt+Shift+F11", "edit.undo": "Cmd+Alt+Shift+F12"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_names_match_and_apply() {
|
||||
let label = "Acme Long Label ".repeat(14);
|
||||
assert!(label.chars().count() > 200);
|
||||
let read = json!({"entries": [{"name": format!("{label}and more words after it"), "section": ["Menus"], "line": 2, "key": 901}]});
|
||||
let r = Review::build(
|
||||
&read,
|
||||
&[Candidate { id: "edit.copy".into(), labels: vec![label] }, Candidate { id: "edit.undo".into(), labels: vec!["Undo".into()] }],
|
||||
);
|
||||
assert_eq!(r.rows[0].target.as_deref(), Some("edit.copy"), "the first 200 characters decide");
|
||||
// Past the longest text the plan reads.
|
||||
let mut app = app();
|
||||
let name = "Acme Copy ".repeat(150);
|
||||
let (out, ov) = apply_rows(&mut app, "defaults", &[(&name, "Menus", "edit.copy", "Cmd+Alt+Shift+F11")]);
|
||||
assert_eq!((out["applied"].clone(), ov), (json!(1), json!({"edit.copy": "Cmd+Alt+Shift+F11"})));
|
||||
}
|
||||
|
||||
@@ -4257,6 +4257,7 @@ axis Both И двете
|
||||
@msg {_1} font families available наличности на шрифтови семейства: {_1}
|
||||
@msg {_1} is a live effect (Effect › Plug-ins) {_1} е динамичен ефект (Ефект › Добавки)
|
||||
@msg {_1} is already used by {_2} {_1} вече се използва от {_2}
|
||||
@msg {_1} is reserved by the system {_1} е запазено от системата
|
||||
@msg {_1} linked file(s) can't be read here: their previews show свързаните файлове {_1} не могат да бъдат прочетени тук: показват се техните прегледи
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions свързаните изображения {_1} са променени на диска: „Обновяване на връзките“ показва новите версии
|
||||
@msg Updated {_1} linked file(s) changed on disk Обновени са свързани файлове {_1}, променени на диска
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4665,6 +4665,7 @@ imageTrace Outline Obrys
|
||||
@msg {_1} is an exporter (File › Export › Export As…) {_1} je exportní zásuvný modul (Soubor › Export › Exportovat jako…)
|
||||
@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` má {size} bajtů (limit {MAX_DOCUMENT_DATA})
|
||||
@msg {_1} is already used by {_2} {_1} už používá {_2}
|
||||
@msg {_1} is reserved by the system Zkratku {_1} si vyhrazuje systém
|
||||
@msg {_1} linked file(s) can't be read here: their previews show propojené soubory ({_1}) zde nelze přečíst: zobrazují se jejich náhledy
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions propojené obrázky ({_1}) se na disku změnily: nové verze zobrazí Aktualizovat propojení
|
||||
@msg {_1}: not saved, so not closed {_1}: neuloženo, proto nezavřeno
|
||||
@@ -5389,6 +5390,7 @@ mark-style Japanese Japonské
|
||||
@msg Persistent libraries not found: {files} Trvalé knihovny nebyly nalezeny: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` není panel knihovny (viz window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open panel knihovny `{given}` není otevřený
|
||||
@msg too many changed shortcuts příliš mnoho změněných klávesových zkratek
|
||||
@msg library panel `{given}` isn't missing its library knihovna panelu knihovny `{given}` nechybí
|
||||
@msg too many library panels were opened in this session v této relaci bylo otevřeno příliš mnoho panelů knihoven
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz má {_1} × {_2} pixelů, na jedné straně více než {LARGE_TRACE_SIDE}: vektorizuje se, ale pomaleji. Rychleji se vektorizuje s předvolbou nižší věrnosti nebo menší kopie obrazu.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4354,6 +4354,7 @@ imageTrace Outline Kontur
|
||||
@msg {_1} font families available {_1} skrifttypefamilier tilgængelige
|
||||
@msg {_1} is a live effect (Effect › Plug-ins) {_1} er en live-effekt (Effekt › Plug-ins)
|
||||
@msg {_1} is already used by {_2} {_1} bruges allerede af {_2}
|
||||
@msg {_1} is reserved by the system {_1} er reserveret af systemet
|
||||
@msg {_1} linked file(s) can't be read here: their previews show {_1} linket(e) fil(er) kan ikke læses her: deres forhåndsvisninger vises
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} linket(e) billede(r) er ændret på disken: Opdatér links viser de nye versioner
|
||||
@msg {_1}: not saved, so not closed {_1}: ikke gemt, derfor ikke lukket
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4288,6 +4288,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg {_1} is an exporter (File › Export › Export As…) {_1} ist ein Export-Plug-in (Datei › Exportieren › Exportieren als…)
|
||||
@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` ist {size} Byte groß (Grenze {MAX_DOCUMENT_DATA})
|
||||
@msg {_1} is already used by {_2} {_1} wird bereits von {_2} verwendet
|
||||
@msg {_1} is reserved by the system {_1} ist vom System reserviert
|
||||
@msg {_1} linked file(s) can't be read here: their previews show {_1} verknüpfte Datei(en) können hier nicht gelesen werden: Ihre Vorschauen werden angezeigt
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} verknüpfte(s) Bild(er) wurde(n) auf dem Datenträger geändert: „Verknüpfungen aktualisieren“ zeigt die neuen Versionen
|
||||
@msg {_1}: not saved, so not closed {_1}: nicht gespeichert, daher nicht geschlossen
|
||||
@@ -4968,6 +4969,7 @@ effect Soft Weich
|
||||
@msg Persistent libraries not found: {files} Dauerhafte Bibliotheken nicht gefunden: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` ist kein Bibliotheksbedienfeld (siehe window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open Bibliotheksbedienfeld `{given}` ist nicht geöffnet
|
||||
@msg too many changed shortcuts zu viele geänderte Tastaturbefehle
|
||||
@msg library panel `{given}` isn't missing its library dem Bibliotheksbedienfeld `{given}` fehlt seine Bibliothek nicht
|
||||
@msg too many library panels were opened in this session in dieser Sitzung wurden zu viele Bibliotheksbedienfelder geöffnet
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Das Bild hat {_1} × {_2} Pixel, mehr als {LARGE_TRACE_SIDE} auf einer Seite: Es wird nachgezeichnet, aber langsamer. Eine Vorgabe mit geringerer Detailtreue oder eine kleinere Kopie des Bildes geht schneller.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4336,6 +4336,7 @@ theme Appearance Εμφάνιση
|
||||
@msg {_1} is an exporter (File › Export › Export As…) το {_1} είναι πρόσθετο εξαγωγής (Αρχείο › Εξαγωγή › Εξαγωγή ως…)
|
||||
@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) το `documentData` έχει {size} byte (όριο {MAX_DOCUMENT_DATA})
|
||||
@msg {_1} is already used by {_2} το {_1} χρησιμοποιείται ήδη από το {_2}
|
||||
@msg {_1} is reserved by the system Το {_1} είναι δεσμευμένο από το σύστημα
|
||||
@msg {_1} linked file(s) can't be read here: their previews show {_1} συνδεδεμένα αρχεία δεν μπορούν να διαβαστούν εδώ: εμφανίζονται οι προεπισκοπήσεις τους
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} συνδεδεμένες εικόνες άλλαξαν στον δίσκο: η Ενημέρωση συνδέσμων εμφανίζει τις νέες εκδόσεις
|
||||
@msg Updated {_1} linked file(s) changed on disk Ενημερώθηκαν {_1} συνδεδεμένα αρχεία που άλλαξαν στον δίσκο
|
||||
@@ -5341,6 +5342,7 @@ effect Points Σημεία
|
||||
@msg Persistent libraries not found: {files} Δεν βρέθηκαν μόνιμες βιβλιοθήκες: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) Το `{given}` δεν είναι πίνακας βιβλιοθήκης (δείτε window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open ο πίνακας βιβλιοθήκης `{given}` δεν είναι ανοιχτός
|
||||
@msg too many changed shortcuts πάρα πολλές τροποποιημένες συντομεύσεις
|
||||
@msg library panel `{given}` isn't missing its library η βιβλιοθήκη του πίνακα βιβλιοθήκης `{given}` δεν λείπει
|
||||
@msg too many library panels were opened in this session άνοιξαν πάρα πολλοί πίνακες βιβλιοθηκών σε αυτή τη συνεδρία
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Η εικόνα είναι {_1} × {_2} εικονοστοιχεία, πάνω από {LARGE_TRACE_SIDE} σε μία πλευρά: ανιχνεύεται, αλλά πιο αργά. Μια προρύθμιση χαμηλότερης πιστότητας ή ένα μικρότερο αντίγραφο της εικόνας ανιχνεύεται γρηγορότερα.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2069 and column 19.
|
@@ -4363,6 +4363,7 @@ axis Both Ambos
|
||||
@msg {_1} is an exporter (File › Export › Export As…) {_1} es un plugin de exportación (Archivo › Exportar › Exportar como…)
|
||||
@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` ocupa {size} bytes (límite {MAX_DOCUMENT_DATA})
|
||||
@msg {_1} is already used by {_2} {_1} ya lo usa {_2}
|
||||
@msg {_1} is reserved by the system {_1} está reservado por el sistema
|
||||
@msg {_1} linked file(s) can't be read here: their previews show aquí no se pueden leer {_1} archivo(s) vinculado(s): se muestran sus previsualizaciones
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} imagen(es) vinculada(s) cambiaron en el disco: Actualizar vínculos muestra las versiones nuevas
|
||||
@msg Updated {_1} linked file(s) changed on disk Se actualizaron {_1} archivo(s) vinculado(s) que cambiaron en el disco
|
||||
@@ -5331,6 +5332,7 @@ mark-style Japanese Japonesas
|
||||
@msg Persistent libraries not found: {files} No se encontraron bibliotecas persistentes: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` no es un panel de biblioteca (consulte window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open el panel de biblioteca `{given}` no está abierto
|
||||
@msg too many changed shortcuts demasiados atajos modificados
|
||||
@msg library panel `{given}` isn't missing its library la biblioteca del panel de biblioteca `{given}` no falta
|
||||
@msg too many library panels were opened in this session se abrieron demasiados paneles de biblioteca en esta sesión
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. La imagen tiene {_1} × {_2} píxeles, más de {LARGE_TRACE_SIDE} en un lado: se calcará, pero más despacio. Un ajuste preestablecido de menor fidelidad o una copia más pequeña de la imagen se calcan más rápido.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4572,6 +4572,7 @@ imageTrace Outline Ääriviiva
|
||||
@msg {_1} is an exporter (File › Export › Export As…) {_1} on vientilaajennus (Tiedosto › Vie › Vie muodossa…)
|
||||
@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` on {size} tavua (raja {MAX_DOCUMENT_DATA})
|
||||
@msg {_1} is already used by {_2} {_1} on jo käytössä kohteella {_2}
|
||||
@msg {_1} is reserved by the system {_1} on järjestelmän varaama
|
||||
@msg {_1} linked file(s) can't be read here: their previews show {_1} linkitettyä tiedostoa ei voi lukea täällä: niiden esikatselut näytetään
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} linkitettyä kuvaa muuttui levyllä: Päivitä linkit näyttää uudet versiot
|
||||
@msg {_1}: not saved, so not closed {_1}: ei tallennettu, joten ei suljettu
|
||||
@@ -5353,6 +5354,7 @@ mark-style Japanese Japanilainen
|
||||
@msg Persistent libraries not found: {files} Pysyviä kirjastoja ei löytynyt: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` ei ole kirjastopaneeli (katso window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open kirjastopaneeli `{given}` ei ole auki
|
||||
@msg too many changed shortcuts liian monta muutettua pikanäppäintä
|
||||
@msg library panel `{given}` isn't missing its library kirjastopaneelin `{given}` kirjasto ei puutu
|
||||
@msg too many library panels were opened in this session tässä istunnossa on avattu liian monta kirjastopaneelia
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Kuva on {_1} × {_2} kuvapistettä, yhdeltä sivultaan yli {LARGE_TRACE_SIDE}: se jäljitetään, mutta hitaammin. Pienemmän tarkkuuden esiasetus tai kuvan pienempi kopio jäljittyy nopeammin.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4360,6 +4360,7 @@ axis Both Les deux
|
||||
@msg {_1} is an exporter (File › Export › Export As…) {_1} est un module externe d’exportation (Fichier › Exporter › Exporter sous…)
|
||||
@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` fait {size} octets (limite {MAX_DOCUMENT_DATA})
|
||||
@msg {_1} is already used by {_2} {_1} est déjà utilisé par {_2}
|
||||
@msg {_1} is reserved by the system {_1} est réservé par le système
|
||||
@msg {_1} linked file(s) can't be read here: their previews show {_1} fichier(s) lié(s) illisible(s) ici : leurs aperçus sont affichés
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} image(s) liée(s) modifiée(s) sur le disque : Mettre à jour les liens affiche les nouvelles versions
|
||||
@msg Updated {_1} linked file(s) changed on disk {_1} fichier(s) lié(s) modifié(s) sur le disque mis à jour
|
||||
@@ -5388,6 +5389,7 @@ theme Appearance Apparence
|
||||
@msg Persistent libraries not found: {files} Bibliothèques persistantes introuvables : {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` n’est pas un panneau de bibliothèque (voir window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open le panneau de bibliothèque `{given}` n’est pas ouvert
|
||||
@msg too many changed shortcuts trop de raccourcis modifiés
|
||||
@msg library panel `{given}` isn't missing its library la bibliothèque du panneau de bibliothèque `{given}` ne manque pas
|
||||
@msg too many library panels were opened in this session trop de panneaux de bibliothèque ont été ouverts dans cette session
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’image fait {_1} × {_2} pixels, plus de {LARGE_TRACE_SIDE} sur un côté : elle est vectorisée, mais plus lentement. Un paramètre prédéfini moins fidèle ou une copie plus petite de l’image se vectorise plus vite.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5125,6 +5125,7 @@ imageTrace Outline Körvonal
|
||||
@msg {_1} is an exporter (File › Export › Export As…) exportáló bővítmény: {_1} (Fájl › Exportálás › Exportálás másként…)
|
||||
@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) a `documentData` mérete {size} bájt (korlát: {MAX_DOCUMENT_DATA})
|
||||
@msg {_1} is already used by {_2} már foglalt: {_1} (használja: {_2})
|
||||
@msg {_1} is reserved by the system A(z) {_1} a rendszer számára foglalt
|
||||
@msg {_1} linked file(s) can't be read here: their previews show Itt nem olvasható csatolt fájlok: {_1}; az előnézetük látszik
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions A lemezen módosult csatolt képek: {_1}; a Csatolások frissítése az új változatokat mutatja
|
||||
@msg {_1}: not saved, so not closed {_1}: nincs mentve, ezért nem záródott be
|
||||
@@ -5375,6 +5376,7 @@ imageTrace Outline Körvonal
|
||||
@msg Persistent libraries not found: {files} Nem találhatók állandó könyvtárak: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) A(z) `{given}` nem könyvtárpanel (lásd: window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open a(z) `{given}` könyvtárpanel nincs megnyitva
|
||||
@msg too many changed shortcuts túl sok módosított billentyűparancs
|
||||
@msg library panel `{given}` isn't missing its library a(z) `{given}` könyvtárpanel könyvtára nem hiányzik
|
||||
@msg too many library panels were opened in this session ebben a munkamenetben túl sok könyvtárpanel nyílt meg
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A kép {_1} × {_2} képpont, egyik oldalán több mint {LARGE_TRACE_SIDE}: a vektorizálás megtörténik, de lassabban. Kisebb hűségű előbeállítással vagy a kép kisebb másolatával gyorsabb.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4354,6 +4354,7 @@ imageTrace Outline Kontur
|
||||
@msg {_1} font families available {_1} keluarga font tersedia
|
||||
@msg {_1} is a live effect (Effect › Plug-ins) {_1} adalah efek langsung (Efek › Plug-in)
|
||||
@msg {_1} is already used by {_2} {_1} sudah digunakan oleh {_2}
|
||||
@msg {_1} is reserved by the system {_1} dicadangkan oleh sistem
|
||||
@msg {_1} linked file(s) can't be read here: their previews show {_1} berkas tertaut tidak dapat dibaca di sini: yang ditampilkan adalah pratinjaunya
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} gambar tertaut di disk telah berubah: Perbarui Tautan menampilkan versi barunya
|
||||
@msg {_1}: not saved, so not closed {_1}: tidak disimpan, sehingga tidak ditutup
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3840 and column 19.
|
@@ -4403,6 +4403,7 @@ axis Both Entrambi
|
||||
@msg {_1} is an exporter (File › Export › Export As…) {_1} è un plug-in di esportazione (File › Esporta › Esporta come…)
|
||||
@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` occupa {size} byte (limite {MAX_DOCUMENT_DATA})
|
||||
@msg {_1} is already used by {_2} {_1} è già usato da {_2}
|
||||
@msg {_1} is reserved by the system {_1} è riservato dal sistema
|
||||
@msg {_1} linked file(s) can't be read here: their previews show file collegati non leggibili qui: {_1}; vengono mostrate le loro anteprime
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions immagini collegate modificate su disco: {_1}; Aggiorna collegamenti mostra le nuove versioni
|
||||
@msg Updated {_1} linked file(s) changed on disk File collegati modificati su disco aggiornati: {_1}
|
||||
@@ -5002,6 +5003,7 @@ lighting Light Luce
|
||||
@msg Persistent libraries not found: {files} Librerie persistenti non trovate: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` non è un pannello libreria (vedi window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open il pannello libreria `{given}` non è aperto
|
||||
@msg too many changed shortcuts troppe scelte rapide modificate
|
||||
@msg library panel `{given}` isn't missing its library la libreria del pannello libreria `{given}` non è mancante
|
||||
@msg too many library panels were opened in this session in questa sessione sono stati aperti troppi pannelli libreria
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’immagine è di {_1} × {_2} pixel, più di {LARGE_TRACE_SIDE} su un lato: viene vettorializzata, ma più lentamente. Un predefinito a fedeltà più bassa o una copia più piccola dell’immagine è più veloce.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4529,6 +4529,7 @@ swatch Bright Violet ビビッドバイオレット
|
||||
@msg unknown preset (one of {_1}) 不明なプリセットです ({_1} のいずれかを指定してください)
|
||||
@msg unknown tool or command `{key}` `{key}` は不明なツールまたはコマンドです
|
||||
@msg {_1} is already used by {_2} {_1} はすでに {_2} で使用されています
|
||||
@msg {_1} is reserved by the system {_1} はシステムで予約されています
|
||||
@msg Couldn't copy to the system clipboard: {e} システムのクリップボードにコピーできませんでした: {e}
|
||||
@msg Couldn't paste from the clipboard: {e} クリップボードから貼り付けられませんでした: {e}
|
||||
@msg nothing selected 何も選択されていません
|
||||
@@ -5042,6 +5043,7 @@ lighting Light ライト
|
||||
@msg Persistent libraries not found: {files} 永続ライブラリが見つかりません: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` はライブラリパネルではありません (window.swatchLibrary を参照)
|
||||
@msg library panel `{given}` isn't open ライブラリパネル `{given}` は開いていません
|
||||
@msg too many changed shortcuts 変更したショートカットが多すぎます
|
||||
@msg library panel `{given}` isn't missing its library ライブラリパネル `{given}` のライブラリは欠落していません
|
||||
@msg too many library panels were opened in this session このセッションで開いたライブラリパネルが多すぎます
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 画像は {_1} × {_2} ピクセルで、一辺が {LARGE_TRACE_SIDE} を超えています。トレースはできますが、時間がかかります。忠実度の低いプリセットや画像の小さいコピーを使うと速くなります。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4121,6 +4121,7 @@ font-technology Variable ცვლადი
|
||||
@msg {_1} is an exporter (File › Export › Export As…) {_1} ექსპორტის პლაგინია (ფაილი › ექსპორტირება › ექსპორტირება ფორმატში…)
|
||||
@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` {size} ბაიტია (ლიმიტი {MAX_DOCUMENT_DATA})
|
||||
@msg {_1} is already used by {_2} {_1} უკვე გამოიყენება აქ: {_2}
|
||||
@msg {_1} is reserved by the system {_1} დაჯავშნილია სისტემის მიერ
|
||||
@msg {_1} linked file(s) can't be read here: their previews show მიბმული ფაილების წაკითხვა აქ ვერ ხერხდება ({_1}): ჩანს მათი გადახედვა
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions მიბმული გამოსახულებები შეიცვალა დისკზე ({_1}): ბმულების განახლება აჩვენებს ახალ ვერსიებს
|
||||
@msg {_1}: not saved, so not closed {_1}: არ შენახულა, ამიტომ არ დაიხურა
|
||||
@@ -5263,6 +5264,7 @@ imageTrace Outline კონტური
|
||||
@msg the image has no usable size or position გამოსახულებას არ აქვს გამოსადეგი ზომა ან პოზიცია
|
||||
@msg the image's pixels can't be read გამოსახულების პიქსელების წაკითხვა ვერ ხერხდება
|
||||
@msg the spine has no segments ხერხემალს სეგმენტები არ აქვს
|
||||
@msg too many changed shortcuts ზედმეტად ბევრი შეცვლილი მალსახმობი
|
||||
@msg too many library panels were opened in this session ამ სესიაში ზედმეტად ბევრი ბიბლიოთეკის პანელი გაიხსნა
|
||||
@msg variables are locked ცვლადები ჩაკეტილია
|
||||
@msg variables that aren't text were left out: {kinds} ცვლადები, რომლებიც ტექსტი არ არის, გამოტოვებულია: {kinds}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4461,6 +4461,7 @@ theme Appearance 외관
|
||||
@msg unknown preset (one of {_1}) 알 수 없는 프리셋({_1} 중 하나)
|
||||
@msg unknown tool or command `{key}` 알 수 없는 도구 또는 명령 `{key}`
|
||||
@msg {_1} is already used by {_2} {_1}은(는) 이미 {_2}에서 사용 중입니다
|
||||
@msg {_1} is reserved by the system {_1}은(는) 시스템에서 예약되어 있습니다
|
||||
@msg Couldn't copy to the system clipboard: {e} 시스템 클립보드로 복사할 수 없습니다: {e}
|
||||
@msg Couldn't paste from the clipboard: {e} 클립보드에서 붙여넣을 수 없습니다: {e}
|
||||
@msg nothing selected 선택된 항목이 없습니다
|
||||
@@ -4965,6 +4966,7 @@ lighting Light 광원
|
||||
@msg Persistent libraries not found: {files} 지속 라이브러리를 찾을 수 없습니다: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}`은(는) 라이브러리 패널이 아닙니다(window.swatchLibrary 참조)
|
||||
@msg library panel `{given}` isn't open 라이브러리 패널 `{given}`이(가) 열려 있지 않습니다
|
||||
@msg too many changed shortcuts 변경된 단축키가 너무 많습니다
|
||||
@msg library panel `{given}` isn't missing its library 라이브러리 패널 `{given}`의 라이브러리가 누락되지 않았습니다
|
||||
@msg too many library panels were opened in this session 이 세션에서 라이브러리 패널을 너무 많이 열었습니다
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 이미지가 {_1} × {_2}픽셀로 한 변이 {LARGE_TRACE_SIDE}을 넘습니다. 추적은 되지만 더 느립니다. 충실도가 낮은 사전 설정이나 이미지의 작은 사본을 사용하면 더 빠릅니다.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4366,6 +4366,7 @@ imageTrace Outline Contour
|
||||
@msg {_1} font families available {_1} lettertypefamilies beschikbaar
|
||||
@msg {_1} is a live effect (Effect › Plug-ins) {_1} is een live-effect (Effect › Plug-ins)
|
||||
@msg {_1} is already used by {_2} {_1} wordt al gebruikt door {_2}
|
||||
@msg {_1} is reserved by the system {_1} is gereserveerd door het systeem
|
||||
@msg {_1} linked file(s) can't be read here: their previews show {_1} gekoppelde bestand(en) kunnen hier niet worden gelezen: hun voorbeelden worden weergegeven
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} gekoppelde afbeelding(en) zijn gewijzigd op de schijf: Koppelingen bijwerken toont de nieuwe versies
|
||||
@msg {_1}: not saved, so not closed {_1}: niet opgeslagen, dus niet gesloten
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4490,6 +4490,7 @@ imageTrace Outline Kontur
|
||||
@msg {_1} is an exporter (File › Export › Export As…) {_1} to wtyczka eksportu (Plik › Eksportuj › Eksportuj jako…)
|
||||
@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` ma {size} bajtów (limit {MAX_DOCUMENT_DATA})
|
||||
@msg {_1} is already used by {_2} {_1} jest już używany przez {_2}
|
||||
@msg {_1} is reserved by the system Skrót {_1} jest zarezerwowany przez system
|
||||
@msg {_1} linked file(s) can't be read here: their previews show połączonych plików nie można tu odczytać ({_1}): wyświetlane są ich podglądy
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions połączone obrazy zmienione na dysku: {_1} — Aktualizuj łącza pokaże nowe wersje
|
||||
@msg {_1}: not saved, so not closed {_1}: nie zapisano, więc nie zamknięto
|
||||
@@ -5241,6 +5242,7 @@ mark-style Japanese Japońskie
|
||||
@msg Persistent libraries not found: {files} Nie znaleziono trwałych bibliotek: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` nie jest panelem biblioteki (zobacz window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open panel biblioteki `{given}` nie jest otwarty
|
||||
@msg too many changed shortcuts zbyt wiele zmienionych skrótów
|
||||
@msg library panel `{given}` isn't missing its library panelowi biblioteki `{given}` nie brakuje biblioteki
|
||||
@msg too many library panels were opened in this session w tej sesji otwarto zbyt wiele paneli bibliotek
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz ma {_1} × {_2} pikseli, ponad {LARGE_TRACE_SIDE} na jednym boku: zostanie obrysowany, ale wolniej. Ustawienie wstępne o niższej wierności lub mniejsza kopia obrazu obrysuje się szybciej.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3936,6 +3936,7 @@ axis Both Ambos
|
||||
@msg {_1} is an exporter (File › Export › Export As…) {_1} é um plug-in de exportação (Arquivo › Exportar › Exportar Como…)
|
||||
@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` tem {size} bytes (limite {MAX_DOCUMENT_DATA})
|
||||
@msg {_1} is already used by {_2} {_1} já é usado por {_2}
|
||||
@msg {_1} is reserved by the system {_1} é reservado pelo sistema
|
||||
@msg {_1} linked file(s) can't be read here: their previews show {_1} arquivo(s) vinculado(s) não podem ser lidos aqui: suas visualizações são exibidas
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} imagem(ns) vinculada(s) mudou(aram) no disco: Atualizar links mostra as novas versões
|
||||
@msg {_1}: not saved, so not closed {_1}: não salvo, portanto não fechado
|
||||
@@ -5306,6 +5307,7 @@ mark-style Japanese Japonesas
|
||||
@msg Persistent libraries not found: {files} Bibliotecas persistentes não encontradas: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` não é um painel de biblioteca (consulte window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open o painel de biblioteca `{given}` não está aberto
|
||||
@msg too many changed shortcuts atalhos alterados demais
|
||||
@msg library panel `{given}` isn't missing its library a biblioteca do painel de biblioteca `{given}` não está faltando
|
||||
@msg too many library panels were opened in this session foram abertos painéis de biblioteca demais nesta sessão
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A imagem tem {_1} × {_2} pixels, mais de {LARGE_TRACE_SIDE} em um lado: ela é traçada, mas mais devagar. Uma predefinição de menor fidelidade ou uma cópia menor da imagem traça mais rápido.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4399,6 +4399,7 @@ axis Both Оба
|
||||
@msg {_1} is an exporter (File › Export › Export As…) {_1} — модуль экспорта (Файл › Экспорт › Экспортировать как…)
|
||||
@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` занимает {size} байт (предел {MAX_DOCUMENT_DATA})
|
||||
@msg {_1} is already used by {_2} {_1} уже используется {_2}
|
||||
@msg {_1} is reserved by the system Сочетание {_1} зарезервировано системой
|
||||
@msg {_1} linked file(s) can't be read here: their previews show здесь невозможно прочитать связанных файлов: {_1}: отображаются их предварительные просмотры
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions связанных изображений ({_1}) изменились на диске: «Обновить связи» покажет новые версии
|
||||
@msg Updated {_1} linked file(s) changed on disk Обновлено связанных файлов, измененных на диске: {_1}
|
||||
@@ -4998,6 +4999,7 @@ lighting Light Свет
|
||||
@msg Persistent libraries not found: {files} Постоянные библиотеки не найдены: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` — не панель библиотеки (см. window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open панель библиотеки `{given}` не открыта
|
||||
@msg too many changed shortcuts слишком много изменённых сочетаний
|
||||
@msg library panel `{given}` isn't missing its library библиотека панели библиотеки `{given}` не отсутствует
|
||||
@msg too many library panels were opened in this session в этом сеансе открыто слишком много панелей библиотек
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Изображение {_1} × {_2} пикселей, больше {LARGE_TRACE_SIDE} по одной стороне: трассировка выполняется, но медленнее. Стиль с меньшей точностью или уменьшенная копия изображения трассируются быстрее.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4272,6 +4272,7 @@ axis Both Båda
|
||||
@msg {_1} is an exporter (File › Export › Export As…) {_1} är ett exportinsticksprogram (Fil › Exportera › Exportera som…)
|
||||
@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` är {size} byte (gräns {MAX_DOCUMENT_DATA})
|
||||
@msg {_1} is already used by {_2} {_1} används redan av {_2}
|
||||
@msg {_1} is reserved by the system {_1} är reserverat av systemet
|
||||
@msg {_1} linked file(s) can't be read here: their previews show {_1} länkade fil(er) kan inte läsas här: deras förhandsgranskningar visas
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} länkade bild(er) har ändrats på disken: Uppdatera länkar visar de nya versionerna
|
||||
@msg Updated {_1} linked file(s) changed on disk Uppdaterade {_1} länkade fil(er) som ändrats på disken
|
||||
@@ -5384,6 +5385,7 @@ swatch Yellow Green Gulgrön
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` är inte en bibliotekspanel (se window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open bibliotekspanelen `{given}` är inte öppen
|
||||
Locate Library… Hitta bibliotek…
|
||||
@msg too many changed shortcuts för många ändrade kortkommandon
|
||||
@msg library panel `{given}` isn't missing its library bibliotekspanelen `{given}` saknar inte sitt bibliotek
|
||||
@msg Persistent libraries not found: {files} Beständiga bibliotek hittades inte: {files}
|
||||
@msg too many library panels were opened in this session för många bibliotekspaneler har öppnats under den här sessionen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -3768,6 +3768,8 @@ axis Both Her İkisi
|
||||
@msg bad colour `{hex}` (#rrggbb) kötü renk `{hex}` (#rrggbb)
|
||||
@msg bad page tree kötü sayfa ağacı
|
||||
@msg bad shortcut kötü kısayol
|
||||
@msg {_1} is reserved by the system {_1} sistem tarafından ayrılmıştır
|
||||
@msg too many changed shortcuts değiştirilen kısayol sayısı çok fazla
|
||||
@msg bad stream length Kötü akış uzunluğu
|
||||
@msg bad trailer kötü römork
|
||||
@msg balance must be between 0 and 100 bakiye 0 ile 100 arasında olmalıdır
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -4387,6 +4387,7 @@ axis Both Обидві
|
||||
@msg {_1} is an exporter (File › Export › Export As…) {_1} - модуль експорту (Файл › Експорт › Експортувати як…)
|
||||
@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` займає {size} байт (межа {MAX_DOCUMENT_DATA})
|
||||
@msg {_1} is already used by {_2} {_1} вже використовується {_2}
|
||||
@msg {_1} is reserved by the system Сполучення {_1} зарезервоване системою
|
||||
@msg {_1} linked file(s) can't be read here: their previews show тут неможливо прочитати пов’язані файли: {_1}: відображаються їх попередні перегляди
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions пов’язаних зображень ({_1}) змінилися на диску: «Оновити зв’язки» покаже нові версії
|
||||
@msg Updated {_1} linked file(s) changed on disk Оновлено пов’язаних файлів, змінених на диску: {_1}
|
||||
@@ -4982,6 +4983,7 @@ lighting Light Світло
|
||||
@msg Persistent libraries not found: {files} Постійні бібліотеки не знайдено: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` — не панель бібліотеки (див. window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open панель бібліотеки `{given}` не відкрита
|
||||
@msg too many changed shortcuts забагато змінених поєднань
|
||||
@msg library panel `{given}` isn't missing its library бібліотека панелі бібліотеки `{given}` не відсутня
|
||||
@msg too many library panels were opened in this session у цьому сеансі відкрито забагато панелей бібліотек
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Зображення {_1} × {_2} пікселів, понад {LARGE_TRACE_SIDE} з одного боку: трасування виконується, але повільніше. Стиль із нижчою точністю або менша копія зображення трасуються швидше.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 749 and column 40.
|
@@ -4783,6 +4783,7 @@ theme Appearance Giao diện
|
||||
@msg {_1} is an exporter (File › Export › Export As…) {_1} là plug-in xuất (Tệp › Xuất › Xuất thành…)
|
||||
@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` có {size} byte (giới hạn {MAX_DOCUMENT_DATA})
|
||||
@msg {_1} is already used by {_2} {_1} đã được dùng bởi {_2}
|
||||
@msg {_1} is reserved by the system {_1} được hệ thống dành riêng
|
||||
@msg {_1} linked file(s) can't be read here: their previews show {_1} tệp liên kết không thể đọc ở đây: bản xem trước được hiển thị
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} ảnh liên kết đã thay đổi trên đĩa: Cập nhật liên kết hiển thị phiên bản mới
|
||||
@msg {_1}: not saved, so not closed {_1}: chưa lưu, nên không đóng
|
||||
@@ -4982,6 +4983,7 @@ theme Appearance Giao diện
|
||||
@msg Persistent libraries not found: {files} Không tìm thấy thư viện lưu giữ: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` không phải là bảng thư viện (xem window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open bảng thư viện `{given}` chưa được mở
|
||||
@msg too many changed shortcuts quá nhiều phím tắt đã thay đổi
|
||||
@msg library panel `{given}` isn't missing its library thư viện của bảng thư viện `{given}` không bị thiếu
|
||||
@msg too many library panels were opened in this session đã mở quá nhiều bảng thư viện trong phiên này
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Ảnh có kích thước {_1} × {_2} điểm ảnh, hơn {LARGE_TRACE_SIDE} ở một cạnh: ảnh vẫn được dò, nhưng chậm hơn. Một thiết lập sẵn có độ trung thực thấp hơn hoặc một bản sao nhỏ hơn của ảnh sẽ dò nhanh hơn.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4054 and column 10.
|
@@ -4250,6 +4250,7 @@ imageTrace Outline 轮廓
|
||||
@msg unknown preset (one of {_1}) 未知预设 (应为 {_1} 之一)
|
||||
@msg unknown tool or command `{key}` 未知工具或命令 `{key}`
|
||||
@msg {_1} is already used by {_2} {_1} 已被 {_2} 使用
|
||||
@msg {_1} is reserved by the system {_1} 已被系统保留
|
||||
@msg Couldn't copy to the system clipboard: {e} 无法复制到系统剪贴板:{e}
|
||||
@msg Couldn't paste from the clipboard: {e} 无法从剪贴板粘贴:{e}
|
||||
@msg nothing selected 未选择任何对象
|
||||
@@ -5221,6 +5222,7 @@ lighting Light 光源
|
||||
@msg Persistent libraries not found: {files} 找不到持久库:{files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` 不是库面板 (请参阅 window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open 库面板 `{given}` 未打开
|
||||
@msg too many changed shortcuts 已更改的快捷键过多
|
||||
@msg library panel `{given}` isn't missing its library 库面板 `{given}` 的库未缺失
|
||||
@msg too many library panels were opened in this session 本次会话中打开的库面板过多
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 图像为 {_1} × {_2} 像素,某一边超过 {LARGE_TRACE_SIDE}:会进行描摹,但速度较慢。使用保真度较低的预设或图像的较小副本会更快。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2230,6 +2230,7 @@ file-filter Templates 範本
|
||||
@msg unknown preset (one of {_1}) 未知的預設集 (應為 {_1} 其中之一)
|
||||
@msg unknown tool or command `{key}` 未知的工具或指令 `{key}`
|
||||
@msg {_1} is already used by {_2} {_1} 已由 {_2} 使用
|
||||
@msg {_1} is reserved by the system {_1} 已被系統保留
|
||||
@msg Couldn't copy to the system clipboard: {e} 無法複製到系統剪貼簿:{e}
|
||||
@msg Couldn't paste from the clipboard: {e} 無法從剪貼簿貼上:{e}
|
||||
@msg nothing selected 未選取任何物件
|
||||
@@ -2370,6 +2371,7 @@ file-filter Templates 範本
|
||||
@msg Persistent libraries not found: {files} 找不到持久資料庫:{files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` 不是資料庫面板 (請參閱 window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open 資料庫面板 `{given}` 未開啟
|
||||
@msg too many changed shortcuts 已變更的快速鍵過多
|
||||
@msg library panel `{given}` isn't missing its library 資料庫面板 `{given}` 的資料庫未遺失
|
||||
@msg too many library panels were opened in this session 本次工作階段開啟的資料庫面板過多
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 影像為 {_1} × {_2} 像素,有一邊超過 {LARGE_TRACE_SIDE}:仍會描圖,但速度較慢。使用精確度較低的預設集或較小的影像拷貝,描圖會較快。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -0,0 +1,451 @@
|
||||
//! VectorCraft's keymap on [`craft_keymap`]: the catalogue of everything a shortcut can run, the
|
||||
//! bridge between VectorCraft's chord spellings and the crate's, and the stored form of the
|
||||
//! user's overrides.
|
||||
//!
|
||||
//! Entry keys are VectorCraft's everywhere outside this module: a command id, `tool:<id>`, or
|
||||
//! `panel:<id>` for the Window menu item showing a panel (`window.panel {panel}`). Chord texts
|
||||
//! outside it are VectorCraft's too (`Cmd+Shift+]`, `Comma`, `Period`, `~` for the backtick key,
|
||||
//! `=` for Plus with modifiers). Inside an [`Overrides`] keys and texts are the crate's.
|
||||
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
use craft_keymap::{Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Lookup, Mods, Overrides, Platform, Scope, Scopes};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::shortcut_editor::{self, Entry};
|
||||
|
||||
/// Menu commands whose label flips between Show and Hide (see `menus::dynamic_label`).
|
||||
pub const TOGGLES: &[&str] = &[
|
||||
"view.edges",
|
||||
"view.template",
|
||||
"view.cornerWidget",
|
||||
"view.textThreads",
|
||||
"type.hiddenCharacters",
|
||||
"view.gradientAnnotator",
|
||||
"view.artboards",
|
||||
"view.rulers",
|
||||
"view.boundingBox",
|
||||
"view.transparencyGrid",
|
||||
"view.guides",
|
||||
"view.grid",
|
||||
"view.slices.hide",
|
||||
"view.printTiling",
|
||||
"perspective.grid.show",
|
||||
"perspective.grid.rulers",
|
||||
];
|
||||
|
||||
// ---------- entry keys ----------
|
||||
|
||||
/// The command an entry key runs.
|
||||
pub fn command_of(key: &str) -> CommandRef {
|
||||
match key.strip_prefix("panel:") {
|
||||
Some(panel) => CommandRef::with_params("window.panel", json!({ "panel": panel })),
|
||||
None => CommandRef::new(key),
|
||||
}
|
||||
}
|
||||
|
||||
/// The entry key of a command: `panel:<id>` for `window.panel {panel}`.
|
||||
pub fn entry_key(command: &CommandRef) -> String {
|
||||
match (command.id.as_str(), command.params.get("panel").and_then(Value::as_str)) {
|
||||
("window.panel", Some(panel)) => format!("panel:{panel}"),
|
||||
_ => command.key(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`Overrides`] key of an entry key.
|
||||
pub fn stored_key(key: &str) -> String {
|
||||
if key.starts_with("panel:") { command_of(key).key() } else { key.to_string() }
|
||||
}
|
||||
|
||||
/// The entry key of an [`Overrides`] key.
|
||||
pub fn entry_of_stored(key: &str) -> String {
|
||||
CommandRef::parse_key(key).filter(|c| c.id == "window.panel").map_or_else(|| key.to_string(), |c| entry_key(&c))
|
||||
}
|
||||
|
||||
// ---------- chord texts ----------
|
||||
|
||||
/// Reads VectorCraft chord text. `+` and `=` are one key: Plus alone, Equals with modifiers.
|
||||
pub fn vc_chord(text: &str) -> Option<Chord> {
|
||||
let sc = crate::shortcuts::parse(text.trim())?;
|
||||
let key = Key::from_egui(sc.logical_key)?;
|
||||
let m = sc.modifiers;
|
||||
let mods = Mods { cmd: m.command || m.mac_cmd, ctrl: m.ctrl, alt: m.alt, shift: m.shift };
|
||||
let key = match key {
|
||||
Key::Plus | Key::Equals if mods.is_none() => Key::Plus,
|
||||
Key::Plus | Key::Equals => Key::Equals,
|
||||
k => k,
|
||||
};
|
||||
Some(Chord::new(mods, key))
|
||||
}
|
||||
|
||||
/// VectorCraft's text of a chord, as `shortcut_editor::format` writes it.
|
||||
pub fn vc_format(chord: &Chord) -> String {
|
||||
let m = chord.mods;
|
||||
let mods = egui::Modifiers { alt: m.alt, ctrl: m.ctrl, shift: m.shift, mac_cmd: false, command: m.cmd };
|
||||
shortcut_editor::format(&egui::KeyboardShortcut::new(mods, chord.key.to_egui()))
|
||||
}
|
||||
|
||||
/// VectorCraft's text of a binding; a scoped or double binding (hand-written) keeps the crate's.
|
||||
pub fn binding_text(b: &Binding) -> String {
|
||||
if b.scope.is_none() && !b.trigger.double { vc_format(&b.trigger.chord) } else { b.to_string() }
|
||||
}
|
||||
|
||||
/// VectorCraft text → the crate's. Text both read as one chord stays as written (`Comma`,
|
||||
/// `Ctrl+Cmd+H`), so it is saved back as the user wrote it; text neither reads is kept for
|
||||
/// `Keymap::problems`.
|
||||
pub fn vc_to_crate(text: &str) -> String {
|
||||
if text.trim().is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
match vc_chord(text) {
|
||||
Some(c) if Binding::parse(text).is_ok_and(|b| b == Binding::new(c)) => text.to_string(),
|
||||
Some(c) => c.to_string(),
|
||||
None => text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The crate's text → VectorCraft's. The crate's canonical text is rewritten; other text is
|
||||
/// what [`vc_to_crate`] kept as written, or text that does not parse.
|
||||
pub fn crate_to_vc(text: &str) -> String {
|
||||
match Binding::parse(text) {
|
||||
Ok(b) if b.to_string() == text => binding_text(&b),
|
||||
_ => text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A default shortcut of the catalogue.
|
||||
fn vc_binding(text: &str) -> Option<Binding> {
|
||||
vc_chord(text).map(Binding::new)
|
||||
}
|
||||
|
||||
// ---------- catalogue ----------
|
||||
|
||||
/// Every listed entry as the crate sees it, in dispatch priority: commands (engine, then UI),
|
||||
/// panels, tools. Each tool is its own group: tools never cycle.
|
||||
fn catalogue() -> Vec<Bindable> {
|
||||
let rank = |e: &Entry| {
|
||||
if e.is_tool {
|
||||
2
|
||||
} else if e.key.starts_with("panel:") {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
let mut entries: Vec<&Entry> = shortcut_editor::entries().iter().collect();
|
||||
entries.sort_by_key(|e| rank(e));
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let mut path: Vec<String> = e.group.split(" › ").filter(|s| !s.is_empty()).map(str::to_string).collect();
|
||||
path.push(e.label.clone());
|
||||
let mut b = Bindable::new(command_of(&e.key), e.label.clone(), Scope::app()).with_path(path);
|
||||
if let Some(tool) = e.key.strip_prefix("tool:") {
|
||||
b = b.with_kind(Kind::Tool { group: Some(tool.to_string()) });
|
||||
}
|
||||
if TOGGLES.contains(&e.key.as_str()) {
|
||||
b = b.toggle();
|
||||
}
|
||||
match shortcut_editor::default_of(&e.key).and_then(vc_binding) {
|
||||
Some(d) => b.with_default(d),
|
||||
None => b,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// On the web, the browser's operating system once egui reports it ([`note_os`]).
|
||||
static WEB_PLATFORM: RwLock<Option<Platform>> = RwLock::new(None);
|
||||
|
||||
/// The platform shortcuts follow: the build's, and on the web the browser's operating system, so
|
||||
/// Cmd shortcuts work in a browser on a Mac and Windows' reserved chords stay free on Windows.
|
||||
pub fn platform() -> Platform {
|
||||
let web = if cfg!(target_arch = "wasm32") { WEB_PLATFORM.read().ok().and_then(|p| *p) } else { None };
|
||||
web.unwrap_or_else(Platform::current)
|
||||
}
|
||||
|
||||
/// Records the operating system egui reports, which the web build reads from the browser.
|
||||
pub fn note_os(ctx: &egui::Context) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
let p = craft_keymap::egui::platform(ctx);
|
||||
if let Ok(mut web) = WEB_PLATFORM.write()
|
||||
&& *web != Some(p)
|
||||
{
|
||||
*web = Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value for `p` in `cells`, made on first use. Each platform has its own cell, because the
|
||||
/// web build learns the browser's platform only after the first frame.
|
||||
pub(crate) fn per_platform<T>(cells: &'static [OnceLock<T>; 3], p: Platform, make: impl FnOnce() -> T) -> &'static T {
|
||||
let [mac, windows, other] = cells;
|
||||
let cell = match p {
|
||||
Platform::Mac => mac,
|
||||
Platform::Windows => windows,
|
||||
_ => other,
|
||||
};
|
||||
cell.get_or_init(make)
|
||||
}
|
||||
|
||||
/// The keymap with the default shortcuts for [`platform`], built once per platform.
|
||||
pub fn template() -> &'static Keymap {
|
||||
static TEMPLATES: [OnceLock<Keymap>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()];
|
||||
let p = platform();
|
||||
per_platform(&TEMPLATES, p, || keymap_for(p))
|
||||
}
|
||||
|
||||
/// The default keymap of `p`. The chords the operating system takes can't be assigned.
|
||||
fn keymap_for(p: Platform) -> Keymap {
|
||||
Keymap::new(catalogue(), Scopes::new(), p).with_reserved(craft_keymap::reserved::system(p))
|
||||
}
|
||||
|
||||
/// The keymap with `overrides` applied.
|
||||
pub fn keymap_with(overrides: &Overrides) -> Keymap {
|
||||
template().clone().with_overrides(overrides.clone())
|
||||
}
|
||||
|
||||
/// The command a chord runs, the first for tools sharing it.
|
||||
pub fn lookup(km: &Keymap, chord: &Chord) -> Option<CommandRef> {
|
||||
match km.lookup(chord, false, &craft_keymap::Active::NONE) {
|
||||
Lookup::None => None,
|
||||
Lookup::Run(c) => Some(c),
|
||||
Lookup::Cycle(v) => v.into_iter().next(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective shortcut text of an entry key under `km`: the default's own text while the entry
|
||||
/// has its defaults. An entry the catalogue lacks reads its stored text.
|
||||
pub fn effective_with(km: &Keymap, key: &str) -> Option<String> {
|
||||
let command = command_of(key);
|
||||
if km.bindable(&command).is_none() {
|
||||
return match km.overrides().get(&stored_key(key)) {
|
||||
Some(texts) => texts.first().filter(|t| !t.trim().is_empty()).map(|t| crate_to_vc(t)),
|
||||
None => shortcut_editor::default_of(key).map(str::to_string),
|
||||
};
|
||||
}
|
||||
let now = km.bindings(&command);
|
||||
if now == km.default_bindings(&command) {
|
||||
return shortcut_editor::default_of(key).map(str::to_string);
|
||||
}
|
||||
let first = now.first()?;
|
||||
// The text as stored, when it is this binding's: the user's spelling.
|
||||
let stored = km.overrides().get(&stored_key(key)).and_then(|t| t.first()).filter(|t| Binding::parse(t).is_ok_and(|b| b == *first));
|
||||
Some(stored.map_or_else(|| binding_text(first), |t| crate_to_vc(t)))
|
||||
}
|
||||
|
||||
// ---------- overrides ----------
|
||||
|
||||
/// Sets an entry's shortcut in VectorCraft's terms: `""` for none.
|
||||
pub fn set_override(overrides: &mut Overrides, key: &str, text: &str) {
|
||||
let texts = if text.trim().is_empty() { vec![] } else { vec![vc_to_crate(text)] };
|
||||
// Only an over-long key or text is refused; such an entry could not be stored anyway.
|
||||
let _ = overrides.insert(stored_key(key), texts);
|
||||
}
|
||||
|
||||
/// Overrides holding `(entry key, shortcut)` pairs in VectorCraft's terms.
|
||||
pub fn overrides_from(pairs: &[(&str, &str)]) -> Overrides {
|
||||
let mut out = Overrides::new();
|
||||
for (k, t) in pairs {
|
||||
set_override(&mut out, k, t);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An entry's stored shortcut in VectorCraft's terms (`""` for none), when it has an override.
|
||||
pub fn override_text(overrides: &Overrides, key: &str) -> Option<String> {
|
||||
overrides.get(&stored_key(key)).map(|t| t.first().map(|t| crate_to_vc(t)).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn has_override(overrides: &Overrides, key: &str) -> bool {
|
||||
overrides.get(&stored_key(key)).is_some()
|
||||
}
|
||||
|
||||
/// Serde for [`Overrides`] in VectorCraft's own form, which every earlier version reads: a map
|
||||
/// of entry key → one shortcut text (`""` for none), with `panel:` keys and VectorCraft's
|
||||
/// spellings. Loading never fails: what cannot be used is dropped or kept for
|
||||
/// `Keymap::problems`, and only an entry's first text is kept.
|
||||
pub mod stored {
|
||||
use craft_keymap::Overrides;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::{crate_to_vc, entry_of_stored, stored_key, vc_to_crate};
|
||||
|
||||
pub fn serialize<S: Serializer>(overrides: &Overrides, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.collect_map(overrides.iter().map(|(k, texts)| (entry_of_stored(k), texts.first().map(|t| crate_to_vc(t)).unwrap_or_default())))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Overrides, D::Error> {
|
||||
Ok(from_value(Value::deserialize(d)?))
|
||||
}
|
||||
|
||||
pub fn to_value(overrides: &Overrides) -> Value {
|
||||
struct Stored<'a>(&'a Overrides);
|
||||
impl Serialize for Stored<'_> {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
serialize(self.0, s)
|
||||
}
|
||||
}
|
||||
serde_json::to_value(Stored(overrides)).unwrap_or_else(|_| Value::Object(Map::new()))
|
||||
}
|
||||
|
||||
pub fn from_value(v: Value) -> Overrides {
|
||||
let entries: Vec<(String, Value)> = match v {
|
||||
Value::Object(m) => m.into_iter().collect(),
|
||||
// The crate's list form, `[{command, keys}]`, as a newer version might write it.
|
||||
Value::Array(items) => items
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
let command = item.get("command").and_then(Value::as_str)?.to_string();
|
||||
Some((command, item.get("keys").cloned().unwrap_or(Value::Array(vec![]))))
|
||||
})
|
||||
.collect(),
|
||||
other => return Overrides::deserialize(other).unwrap_or_default(),
|
||||
};
|
||||
let map: Map<String, Value> = entries.into_iter().map(|(k, v)| (stored_key(&k), first_text(v))).collect();
|
||||
Overrides::deserialize(Value::Object(map)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// One text in the crate's spelling; values that are not text are left for the crate to
|
||||
/// report.
|
||||
fn first_text(v: Value) -> Value {
|
||||
match v {
|
||||
Value::String(s) => Value::String(vc_to_crate(&s)),
|
||||
Value::Array(items) => match items.into_iter().next() {
|
||||
None => Value::Array(vec![]),
|
||||
Some(Value::String(s)) => Value::String(vc_to_crate(&s)),
|
||||
Some(other) => Value::Array(vec![other]),
|
||||
},
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::state::UiState;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Earlier versions read the overrides as a map of one string per entry; a file this
|
||||
/// version writes must stay that.
|
||||
#[test]
|
||||
fn older_versions_read_the_saved_overrides() {
|
||||
let mut ui = UiState::default();
|
||||
set_override(&mut ui.shortcut_overrides, "tool:pen", "Shift+9");
|
||||
set_override(&mut ui.shortcut_overrides, "edit.preferences", "");
|
||||
set_override(&mut ui.shortcut_overrides, "panel:layers", "Cmd+Alt+Shift+F7");
|
||||
set_override(&mut ui.shortcut_overrides, "view.grid", "Cmd+Period");
|
||||
// An entry holding two texts (the crate keeps texts it cannot use after the binding).
|
||||
ui.shortcut_overrides.insert("tool:curvature", vec!["Shift+`".into(), "Hyper+K".into()]);
|
||||
let saved = serde_json::to_value(&ui).unwrap();
|
||||
let old: BTreeMap<String, String> = serde_json::from_value(saved["shortcut_overrides"].clone()).unwrap();
|
||||
let want: BTreeMap<String, String> = [
|
||||
("tool:pen", "Shift+9"),
|
||||
("edit.preferences", ""),
|
||||
("panel:layers", "Cmd+Alt+Shift+F7"),
|
||||
("view.grid", "Cmd+Period"),
|
||||
("tool:curvature", "Shift+~"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect();
|
||||
assert_eq!(old, want);
|
||||
let back: UiState = serde_json::from_value(saved).unwrap();
|
||||
assert_eq!(override_text(&back.shortcut_overrides, "panel:layers").as_deref(), Some("Cmd+Alt+Shift+F7"));
|
||||
assert_eq!(override_text(&back.shortcut_overrides, "tool:curvature").as_deref(), Some("Shift+~"));
|
||||
}
|
||||
|
||||
/// A hand-edited value that is not text drops that entry, not every preference.
|
||||
#[test]
|
||||
fn a_bad_override_value_keeps_the_other_preferences() {
|
||||
let mut saved = serde_json::to_value(UiState { toolbar: false, ..Default::default() }).unwrap();
|
||||
saved["shortcut_overrides"] = json!({"x": 5, "tool:pen": "Shift+9"});
|
||||
let ui: UiState = serde_json::from_value(saved).unwrap();
|
||||
assert!(!ui.toolbar);
|
||||
assert!(!has_override(&ui.shortcut_overrides, "x"));
|
||||
assert_eq!(override_text(&ui.shortcut_overrides, "tool:pen").as_deref(), Some("Shift+9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curvature_keeps_shift_tilde_and_plus_is_one_key() {
|
||||
let km = template();
|
||||
let curvature = vc_chord("Shift+~").unwrap();
|
||||
assert_eq!(curvature, Chord::parse("Shift+`").unwrap());
|
||||
assert_eq!(lookup(km, &curvature), Some(CommandRef::new("tool:curvature")));
|
||||
assert_eq!(effective_with(km, "tool:curvature").as_deref(), Some("Shift+~"));
|
||||
// Plus alone is the Add Anchor Point tool's, however it is written; with modifiers the
|
||||
// key is `=`.
|
||||
for t in ["+", "="] {
|
||||
assert_eq!(vc_chord(t), Some(Chord::plain(Key::Plus)), "{t}");
|
||||
}
|
||||
for t in ["Cmd++", "Cmd+="] {
|
||||
assert_eq!(vc_chord(t), Some(Chord::new(Mods::CMD, Key::Equals)), "{t}");
|
||||
}
|
||||
assert_eq!(lookup(km, &Chord::plain(Key::Plus)), Some(CommandRef::new("tool:addAnchor")));
|
||||
assert_eq!(lookup(km, &Chord::new(Mods::CMD, Key::Equals)), Some(CommandRef::new("view.zoomIn")));
|
||||
assert_eq!(vc_format(&Chord::new(Mods::CMD, Key::Comma)), "Cmd+Comma");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_shortcuts_do_not_conflict() {
|
||||
assert_eq!(template().conflicts(), vec![]);
|
||||
assert_eq!(template().problems(), vec![]);
|
||||
assert_eq!(template().catalog().len(), shortcut_editor::entries().len(), "every entry is bindable");
|
||||
// A default the bridge can't read would show in the menus but never run.
|
||||
let unbound: Vec<(&str, &str)> = shortcut_editor::entries()
|
||||
.iter()
|
||||
.filter_map(|e| shortcut_editor::default_of(&e.key).map(|d| (e.key.as_str(), d)))
|
||||
.filter(|(key, d)| vc_binding(d).map(|b| vec![b]) != Some(template().default_bindings(&command_of(key))))
|
||||
.collect();
|
||||
assert_eq!(unbound, vec![], "every default text becomes the entry's binding");
|
||||
}
|
||||
|
||||
/// The chords the operating system takes are refused, and no default uses one.
|
||||
#[test]
|
||||
fn system_shortcuts_are_refused() {
|
||||
for (p, os_chord) in [(Platform::Mac, "Cmd+Tab"), (Platform::Windows, "Alt+F4"), (Platform::Linux, "Ctrl+Alt+F2")] {
|
||||
let mut km = keymap_for(p);
|
||||
assert_eq!((km.conflicts(), km.problems()), (vec![], vec![]), "{p:?}");
|
||||
let reserved: Vec<String> = shortcut_editor::entries()
|
||||
.iter()
|
||||
.filter(|e| km.bindings(&command_of(&e.key)).iter().any(|b| km.is_reserved(&b.trigger.chord)))
|
||||
.map(|e| e.key.clone())
|
||||
.collect();
|
||||
assert_eq!(reserved, Vec::<String>::new(), "{p:?}: defaults on reserved chords");
|
||||
let err = shortcut_editor::assign_in(&mut km, "edit.undo", Some(os_chord), true).unwrap_err();
|
||||
assert!(err.ends_with("is reserved by the system"), "{p:?}: {err}");
|
||||
// A saved override on a reserved chord keeps the default.
|
||||
let mut ov = Overrides::new();
|
||||
let _ = ov.insert(stored_key("edit.undo"), vec![os_chord.to_string()]);
|
||||
let km = keymap_for(p).with_overrides(ov);
|
||||
assert_eq!(km.bindings(&command_of("edit.undo")), km.default_bindings(&command_of("edit.undo")), "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_entries_bridge_to_window_panel() {
|
||||
let c = command_of("panel:layers");
|
||||
assert_eq!((c.id.as_str(), c.params.clone()), ("window.panel", json!({"panel": "layers"})));
|
||||
assert_eq!(entry_key(&c), "panel:layers");
|
||||
assert_eq!(entry_of_stored(&stored_key("panel:layers")), "panel:layers");
|
||||
assert_eq!(stored_key("tool:pen"), "tool:pen");
|
||||
let paths = &template().bindable(&c).unwrap().paths;
|
||||
assert_eq!(paths, &vec![vec!["Window".to_string(), "Layers".to_string()]], "import matches panels by name");
|
||||
}
|
||||
|
||||
/// Each toggle's menu label reads Show or Hide.
|
||||
#[test]
|
||||
fn toggles_show_and_hide() {
|
||||
let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
for id in TOGGLES {
|
||||
let label = crate::menus::dynamic_label(&app, id, "");
|
||||
assert!(label.starts_with("Show ") || label.starts_with("Hide "), "{id}: {label}");
|
||||
let listed = template().bindable(&CommandRef::new(*id));
|
||||
assert!(listed.is_none_or(|b| b.is_toggle), "{id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ pub mod i18n;
|
||||
pub mod icon_data;
|
||||
pub mod icons;
|
||||
pub mod io;
|
||||
pub mod keymap;
|
||||
pub mod menus;
|
||||
pub mod native_menu;
|
||||
pub mod opening;
|
||||
@@ -523,6 +524,8 @@ pub struct VectorcraftApp {
|
||||
font_install_check_at: f64,
|
||||
/// Keyboard pastes of something other than text (see [`shortcuts::PasteChord`]).
|
||||
pub(crate) paste_chord: shortcuts::PasteChord,
|
||||
/// Runs the frame's key presses against the keymap ([`shortcuts::handle`]).
|
||||
pub(crate) dispatcher: craft_keymap::egui::Dispatcher,
|
||||
/// Saves and exports running in the background (Preferences → File Handling).
|
||||
pub background: background::Background,
|
||||
/// Documents being read off the UI thread ([`opening`]).
|
||||
@@ -659,6 +662,8 @@ impl VectorcraftApp {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
font_install_check_at: f64::NEG_INFINITY,
|
||||
paste_chord: Default::default(),
|
||||
// Held keys repeat their command (Cmd+= keeps zooming in).
|
||||
dispatcher: craft_keymap::egui::Dispatcher::new().with_repeat(true),
|
||||
background: Default::default(),
|
||||
opening: Default::default(),
|
||||
plugin_runs: Default::default(),
|
||||
@@ -1408,6 +1413,7 @@ impl VectorcraftApp {
|
||||
// The timer and heartbeat run in an idle window too.
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
|
||||
}
|
||||
crate::keymap::note_os(ctx);
|
||||
shortcut_editor::sync(&self.ui);
|
||||
prefs_dialog::apply_runtime(self, ctx);
|
||||
// The OS title bar (and the taskbar / Alt-Tab entry) follows the active file; with the
|
||||
|
||||
@@ -4777,7 +4777,7 @@ mod tests {
|
||||
let e = ed::entry("view.preciseCursors").expect("listed in the editor");
|
||||
assert_eq!((e.label.as_str(), e.group.as_str(), e.is_tool), ("Precise Cursors", "View", false));
|
||||
assert_eq!(ed::default_of("view.preciseCursors"), None, "no default key");
|
||||
let mut overrides = std::collections::BTreeMap::new();
|
||||
let mut overrides = craft_keymap::Overrides::new();
|
||||
assert_eq!(ed::effective_in(&overrides, "view.preciseCursors"), None);
|
||||
assert_eq!(ed::assign(&mut overrides, "view.preciseCursors", Some("Cmd+Alt+Shift+U"), false), Ok(vec![]));
|
||||
let chord = ed::effective_in(&overrides, "view.preciseCursors").expect("assigned");
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
//! Edit → Keyboard Shortcuts: user overrides of tool and command shortcuts, conflict detection,
|
||||
//! presets, import/export, and the dialog.
|
||||
//!
|
||||
//! Overrides live in [`UiState::shortcut_overrides`] (persisted) keyed by command id or
|
||||
//! `tool:<id>`; an empty string removes a shortcut. A process-wide mirror (synced every frame by
|
||||
//! [`sync`]) lets `menus::shortcut_of`, the egui dispatcher, the native menu and tooltips consult
|
||||
//! them without threading the app state through.
|
||||
//! Overrides live in [`UiState::shortcut_overrides`] (persisted, [`crate::keymap::stored`]) keyed
|
||||
//! by command id, `tool:<id>` or `panel:<id>`; an empty string removes a shortcut. A process-wide
|
||||
//! mirror (synced every frame by [`sync`]) holds the keymap with them, so `menus::shortcut_of`, the
|
||||
//! dispatcher, the native menu and tooltips consult them without threading the app state through.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use craft_keymap::{AssignError, Binding, ConflictKind, Keymap, Overrides, Policy, Trigger};
|
||||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::keymap::{command_of, effective_with, entry_key, entry_of_stored, keymap_with, stored, vc_chord, vc_format};
|
||||
|
||||
use crate::state::{Dialog, UiState};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{VectorcraftApp, menus, widgets};
|
||||
@@ -58,7 +62,39 @@ macro_rules! ui_mirror {
|
||||
}
|
||||
pub(crate) use ui_mirror;
|
||||
|
||||
ui_mirror!(fn store() -> BTreeMap<String, &'static str>);
|
||||
/// The keymap with the user's overrides, and the effective text of every entry they change.
|
||||
pub(crate) struct Mirror {
|
||||
pub keymap: Keymap,
|
||||
text: HashMap<String, Option<&'static str>>,
|
||||
}
|
||||
|
||||
impl Mirror {
|
||||
fn new(overrides: Overrides) -> Mirror {
|
||||
let keymap = crate::keymap::template().clone().with_overrides(overrides);
|
||||
let text = keymap
|
||||
.overrides()
|
||||
.iter()
|
||||
.map(|(k, _)| {
|
||||
let key = entry_of_stored(k);
|
||||
let t = effective_with(&keymap, &key).map(|t| intern(&t));
|
||||
(key, t)
|
||||
})
|
||||
.collect();
|
||||
Mirror { keymap, text }
|
||||
}
|
||||
}
|
||||
|
||||
ui_mirror!(fn store() -> Option<Arc<Mirror>>);
|
||||
|
||||
/// The mirror: the keymap in effect.
|
||||
pub(crate) fn current() -> Arc<Mirror> {
|
||||
static DEFAULTS: [OnceLock<Arc<Mirror>>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()];
|
||||
store()
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|m| m.clone())
|
||||
.unwrap_or_else(|| crate::keymap::per_platform(&DEFAULTS, crate::keymap::platform(), || Arc::new(Mirror::new(Overrides::new()))).clone())
|
||||
}
|
||||
|
||||
/// Leak-once interning for override strings (bounded by the chords a user ever assigns).
|
||||
pub fn intern(s: &str) -> &'static str {
|
||||
@@ -76,21 +112,20 @@ pub fn intern(s: &str) -> &'static str {
|
||||
pub fn sync(ui: &UiState) {
|
||||
let same = store()
|
||||
.read()
|
||||
.map(|m| m.len() == ui.shortcut_overrides.len() && m.iter().all(|(k, v)| ui.shortcut_overrides.get(k).map(String::as_str) == Some(*v)))
|
||||
.map(|m| match &*m {
|
||||
Some(m) => *m.keymap.overrides() == ui.shortcut_overrides && m.keymap.platform() == crate::keymap::platform(),
|
||||
None => ui.shortcut_overrides.is_empty(),
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !same {
|
||||
if let Ok(mut m) = store().write() {
|
||||
*m = ui.shortcut_overrides.iter().map(|(k, v)| (k.clone(), intern(v))).collect();
|
||||
*m = Some(Arc::new(Mirror::new(ui.shortcut_overrides.clone())));
|
||||
}
|
||||
GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
crate::workspaces::sync(ui);
|
||||
}
|
||||
|
||||
fn global_override(key: &str) -> Option<&'static str> {
|
||||
store().read().ok().and_then(|m| m.get(key).copied())
|
||||
}
|
||||
|
||||
/// Registry default shortcut of a command (engine or UI command).
|
||||
pub fn default_command_shortcut(id: &str) -> Option<&'static str> {
|
||||
if let Some(c) = vectorcraft_engine::find_command(id) {
|
||||
@@ -136,20 +171,16 @@ pub fn default_of(key: &str) -> Option<&'static str> {
|
||||
|
||||
/// Effective shortcut of an entry key, honouring the user's overrides.
|
||||
fn effective(key: &str) -> Option<&'static str> {
|
||||
match global_override(key) {
|
||||
Some("") => None,
|
||||
Some(s) => Some(s),
|
||||
let m = current();
|
||||
match m.text.get(key) {
|
||||
Some(t) => *t,
|
||||
None => default_of(key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective shortcut of `key` under an explicit override map: override > default; "" = none.
|
||||
pub fn effective_in(overrides: &BTreeMap<String, String>, key: &str) -> Option<String> {
|
||||
match overrides.get(key) {
|
||||
Some(s) if s.is_empty() => None,
|
||||
Some(s) => Some(s.clone()),
|
||||
None => default_of(key).map(str::to_string),
|
||||
}
|
||||
/// Effective shortcut of `key` under explicit overrides: override > default.
|
||||
pub fn effective_in(overrides: &Overrides, key: &str) -> Option<String> {
|
||||
effective_with(&keymap_with(overrides), key)
|
||||
}
|
||||
|
||||
/// Effective shortcut of a command, honouring the user's overrides.
|
||||
@@ -169,15 +200,18 @@ pub fn panel_shortcut(panel: &str) -> Option<&'static str> {
|
||||
|
||||
/// Tool whose (effective) shortcut is `key` ("V", "Shift+M").
|
||||
pub fn tool_for_key(key: &str) -> Option<&'static str> {
|
||||
let n = normalize(key)?;
|
||||
vectorcraft_tools::catalog::all_tools().find(|t| tool_shortcut(t.id).and_then(normalize).as_deref() == Some(n.as_str())).map(|t| t.id)
|
||||
let c = crate::keymap::lookup(¤t().keymap, &vc_chord(key)?)?;
|
||||
c.id.strip_prefix("tool:").and_then(vectorcraft_tools::tool_info).map(|t| t.id)
|
||||
}
|
||||
|
||||
/// Command whose effective shortcut is exactly `key` (for single-key command shortcuts: X, D, /).
|
||||
/// Command (not a tool or a panel) whose effective shortcut is exactly `key`.
|
||||
pub fn command_for_key(key: &str) -> Option<&'static str> {
|
||||
let n = normalize(key)?;
|
||||
let hit = |id: &'static str| command_shortcut(id).and_then(normalize).as_deref() == Some(n.as_str());
|
||||
vectorcraft_engine::command_specs().iter().map(|c| c.id).chain(menus::UI_COMMANDS.iter().map(|c| c.0)).find(|id| hit(id))
|
||||
let c = crate::keymap::lookup(¤t().keymap, &vc_chord(key)?)?;
|
||||
vectorcraft_engine::command_specs()
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.chain(menus::UI_COMMANDS.iter().map(|c| c.0))
|
||||
.find(|id| *id == c.id && c.params.is_null())
|
||||
}
|
||||
|
||||
// ---------- chords ----------
|
||||
@@ -225,7 +259,7 @@ pub fn format(sc: &KeyboardShortcut) -> String {
|
||||
|
||||
/// Canonical form of a shortcut string (None if it doesn't parse).
|
||||
pub fn normalize(s: &str) -> Option<String> {
|
||||
crate::shortcuts::parse(s).map(|sc| format(&sc))
|
||||
vc_chord(s).map(|c| vc_format(&c))
|
||||
}
|
||||
|
||||
/// A recorded key press → shortcut text (None for keys that can't be shortcuts).
|
||||
@@ -306,92 +340,117 @@ pub fn entry(key: &str) -> Option<&'static Entry> {
|
||||
}
|
||||
|
||||
/// Entries (other than `except`) whose effective shortcut equals `chord`.
|
||||
pub fn conflicts_with(overrides: &BTreeMap<String, String>, chord: &str, except: &str) -> Vec<String> {
|
||||
let Some(n) = normalize(chord) else { return vec![] };
|
||||
pub fn conflicts_with(overrides: &Overrides, chord: &str, except: &str) -> Vec<String> {
|
||||
let Some(c) = vc_chord(chord) else { return vec![] };
|
||||
let km = keymap_with(overrides);
|
||||
let p = km.platform();
|
||||
let want = Trigger::single(c).effective(p);
|
||||
entries()
|
||||
.iter()
|
||||
.filter(|e| e.key != except)
|
||||
.filter(|e| effective_in(overrides, &e.key).and_then(|s| normalize(&s)).as_deref() == Some(n.as_str()))
|
||||
.filter(|e| km.bindings(&command_of(&e.key)).iter().any(|b| b.trigger.effective(p) == want))
|
||||
.map(|e| e.key.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// All chords used by more than one entry: (chord, keys).
|
||||
pub fn all_conflicts(overrides: &BTreeMap<String, String>) -> Vec<(String, Vec<String>)> {
|
||||
pub fn all_conflicts(overrides: &Overrides) -> Vec<(String, Vec<String>)> {
|
||||
conflicts_in(&keymap_with(overrides))
|
||||
}
|
||||
|
||||
/// [`all_conflicts`] of a keymap.
|
||||
pub fn conflicts_in(km: &Keymap) -> Vec<(String, Vec<String>)> {
|
||||
let mut by: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for e in entries() {
|
||||
if let Some(n) = effective_in(overrides, &e.key).and_then(|s| normalize(&s)) {
|
||||
by.entry(n).or_default().push(e.key.clone());
|
||||
for c in km.conflicts().into_iter().filter(|c| c.kind == ConflictKind::Clash) {
|
||||
let keys = by.entry(crate::keymap::binding_text(&Binding::new(c.chord))).or_default();
|
||||
for k in [entry_key(&c.a.0), entry_key(&c.b.0)] {
|
||||
if !keys.contains(&k) {
|
||||
keys.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
by.into_iter().filter(|(_, v)| v.len() > 1).collect()
|
||||
by.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Assign `chord` (None = remove, "default" handled by callers via [`reset_one`]) to `key` in
|
||||
/// `overrides`. Conflicting entries lose the chord when `force`, otherwise it's an error.
|
||||
pub fn assign(overrides: &mut BTreeMap<String, String>, key: &str, chord: Option<&str>, force: bool) -> Result<Vec<String>, String> {
|
||||
if entry(key).is_none() && default_of(key).is_none() {
|
||||
pub fn assign(overrides: &mut Overrides, key: &str, chord: Option<&str>, force: bool) -> Result<Vec<String>, String> {
|
||||
let mut km = keymap_with(overrides);
|
||||
let removed = assign_in(&mut km, key, chord, force)?;
|
||||
*overrides = km.overrides().clone();
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// [`assign`] on a keymap.
|
||||
pub fn assign_in(km: &mut Keymap, key: &str, chord: Option<&str>, force: bool) -> Result<Vec<String>, String> {
|
||||
let command = command_of(key);
|
||||
if km.bindable(&command).is_none() {
|
||||
return Err(format!("unknown tool or command `{key}`"));
|
||||
}
|
||||
let chord = match chord {
|
||||
let c = match chord {
|
||||
None | Some("") => {
|
||||
set_or_clear(overrides, key, "");
|
||||
// Fails only when the overrides hold the most entries they can.
|
||||
if !km.clear(&command) {
|
||||
return Err("too many changed shortcuts".into());
|
||||
}
|
||||
return Ok(vec![]);
|
||||
}
|
||||
Some(c) => normalize(c).ok_or_else(|| format!("can't parse shortcut `{c}`"))?,
|
||||
Some(c) => c,
|
||||
};
|
||||
if key.starts_with("tool:") {
|
||||
let sc = crate::shortcuts::parse(&chord).ok_or("bad shortcut")?;
|
||||
if sc.modifiers.command || sc.modifiers.alt || sc.modifiers.ctrl {
|
||||
return Err("tool shortcuts must be a single key (optionally with Shift)".into());
|
||||
let chord = vc_chord(c).ok_or_else(|| format!("can't parse shortcut `{c}`"))?;
|
||||
if key.starts_with("tool:") && (chord.mods.cmd || chord.mods.ctrl || chord.mods.alt) {
|
||||
return Err("tool shortcuts must be a single key (optionally with Shift)".into());
|
||||
}
|
||||
let policy = if force { Policy::Steal } else { Policy::Refuse };
|
||||
match km.set_bindings(&command, vec![Binding::new(chord)], policy) {
|
||||
Ok(done) => {
|
||||
let mut removed: Vec<String> = vec![];
|
||||
for h in done.taken_from {
|
||||
let k = entry_key(&h.command);
|
||||
if !removed.contains(&k) {
|
||||
removed.push(k);
|
||||
}
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
}
|
||||
let clashes = conflicts_with(overrides, &chord, key);
|
||||
if !clashes.is_empty() && !force {
|
||||
let names: Vec<String> = clashes.iter().map(|k| entry(k).map(|e| e.label.clone()).unwrap_or(k.clone())).collect();
|
||||
return Err(format!("{} is already used by {}", menus::pretty_shortcut(&chord), names.join(", ")));
|
||||
}
|
||||
for c in &clashes {
|
||||
set_or_clear(overrides, c, "");
|
||||
}
|
||||
set_or_clear(overrides, key, &chord);
|
||||
Ok(clashes)
|
||||
}
|
||||
|
||||
/// Store `value` for `key`, dropping the override when it equals the default.
|
||||
fn set_or_clear(overrides: &mut BTreeMap<String, String>, key: &str, value: &str) {
|
||||
let default = default_of(key).and_then(normalize).unwrap_or_default();
|
||||
let v = if value.is_empty() { String::new() } else { normalize(value).unwrap_or(value.to_string()) };
|
||||
if v == default {
|
||||
overrides.remove(key);
|
||||
} else {
|
||||
overrides.insert(key.to_string(), v);
|
||||
Err(AssignError::Taken(holders)) => {
|
||||
let names: Vec<String> = holders
|
||||
.iter()
|
||||
.map(|h| {
|
||||
let k = entry_key(&h.command);
|
||||
entry(&k).map_or(k, |e| e.label.clone())
|
||||
})
|
||||
.collect();
|
||||
Err(format!("{} is already used by {}", menus::pretty_shortcut(&vc_format(&chord)), names.join(", ")))
|
||||
}
|
||||
Err(AssignError::Reserved(_)) => Err(format!("{} is reserved by the system", menus::pretty_shortcut(&vc_format(&chord)))),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_one(overrides: &mut BTreeMap<String, String>, key: &str) {
|
||||
overrides.remove(key);
|
||||
pub fn reset_one(overrides: &mut Overrides, key: &str) {
|
||||
overrides.remove(&crate::keymap::stored_key(key));
|
||||
}
|
||||
|
||||
/// Overrides of a preset (both presets are the registry defaults for now).
|
||||
pub fn preset(name: &str) -> Option<BTreeMap<String, String>> {
|
||||
PRESETS.contains(&set_name(name)).then(BTreeMap::new)
|
||||
pub fn preset(name: &str) -> Option<Overrides> {
|
||||
PRESETS.contains(&set_name(name)).then(Overrides::new)
|
||||
}
|
||||
|
||||
/// Export format: `{"format": "vectorcraft-shortcuts", "set": name, "overrides": {...}}`.
|
||||
pub fn export_json(set: &str, overrides: &BTreeMap<String, String>) -> Value {
|
||||
json!({"format": "vectorcraft-shortcuts", "version": 1, "set": set, "overrides": overrides})
|
||||
pub fn export_json(set: &str, overrides: &Overrides) -> Value {
|
||||
json!({"format": "vectorcraft-shortcuts", "version": 1, "set": set, "overrides": stored::to_value(overrides)})
|
||||
}
|
||||
|
||||
pub fn import_json(v: &Value) -> Result<(String, BTreeMap<String, String>), String> {
|
||||
pub fn import_json(v: &Value) -> Result<(String, Overrides), String> {
|
||||
let o = v.get("overrides").and_then(Value::as_object).ok_or("not a VectorCraft shortcut set (missing `overrides`)")?;
|
||||
let mut out = BTreeMap::new();
|
||||
let mut out = Overrides::new();
|
||||
for (k, v) in o {
|
||||
let s = v.as_str().ok_or_else(|| format!("shortcut for `{k}` must be a string"))?;
|
||||
if !s.is_empty() && normalize(s).is_none() {
|
||||
return Err(format!("can't parse shortcut `{s}` for `{k}`"));
|
||||
}
|
||||
out.insert(k.clone(), normalize(s).unwrap_or_default());
|
||||
crate::keymap::set_override(&mut out, k, &normalize(s).unwrap_or_default());
|
||||
}
|
||||
Ok((v.get("set").and_then(Value::as_str).map_or(CUSTOM, set_name).to_string(), out))
|
||||
}
|
||||
@@ -399,15 +458,15 @@ pub fn import_json(v: &Value) -> Result<(String, BTreeMap<String, String>), Stri
|
||||
// ---------- UI commands ----------
|
||||
|
||||
pub fn open(app: &mut VectorcraftApp) {
|
||||
let ov = serde_json::to_value(&app.ui.shortcut_overrides).unwrap_or(json!({}));
|
||||
let ov = stored::to_value(&app.ui.shortcut_overrides);
|
||||
app.ui.dialog = Some(Dialog::new(
|
||||
"shortcuts",
|
||||
json!({"tab": "tools", "query": "", "set": app.ui.shortcut_set, "overrides": ov, "__recording": "", "__selected": "", "__message": "", "__conflict": ""}),
|
||||
));
|
||||
}
|
||||
|
||||
fn dialog_overrides(d: &Dialog) -> BTreeMap<String, String> {
|
||||
d.fields.get("overrides").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default()
|
||||
fn dialog_overrides(d: &Dialog) -> Overrides {
|
||||
d.fields.get("overrides").map(|v| stored::from_value(v.clone())).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// OK: commit the working set.
|
||||
@@ -446,12 +505,13 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
|
||||
}
|
||||
"shortcuts.list" => {
|
||||
let q = s("query").unwrap_or_default().to_lowercase();
|
||||
let ov = &app.ui.shortcut_overrides;
|
||||
let km = keymap_with(&app.ui.shortcut_overrides);
|
||||
let ov = km.overrides();
|
||||
Ok(Value::Array(
|
||||
entries()
|
||||
.iter()
|
||||
.filter(|e| q.is_empty() || e.label.to_lowercase().contains(&q) || e.key.to_lowercase().contains(&q))
|
||||
.map(|e| json!({"id": e.key, "label": e.label, "group": e.group, "shortcut": effective_in(ov, &e.key), "default": default_of(&e.key), "overridden": ov.contains_key(&e.key)}))
|
||||
.map(|e| json!({"id": e.key, "label": e.label, "group": e.group, "shortcut": effective_with(&km, &e.key), "default": default_of(&e.key), "overridden": crate::keymap::has_override(ov, &e.key)}))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
@@ -525,7 +585,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
|
||||
app.ui.shortcut_set = set;
|
||||
sync(&app.ui);
|
||||
if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == "shortcuts") {
|
||||
d.fields.insert("overrides".into(), serde_json::to_value(&app.ui.shortcut_overrides).unwrap_or(json!({})));
|
||||
d.fields.insert("overrides".into(), stored::to_value(&app.ui.shortcut_overrides));
|
||||
d.fields.insert("set".into(), json!(app.ui.shortcut_set));
|
||||
}
|
||||
json!({"overrides": n})
|
||||
@@ -547,10 +607,45 @@ pub fn is_recording(app: &VectorcraftApp) -> bool {
|
||||
|
||||
// ---------- dialog ----------
|
||||
|
||||
/// The dialog's working set as a keymap, with each entry's shortcut text (in [`entries`] order)
|
||||
/// and the number of conflicts, kept between frames while the set is unchanged.
|
||||
struct DialogView {
|
||||
/// The dialog's `overrides` field this was built from.
|
||||
source: Value,
|
||||
keymap: Keymap,
|
||||
texts: Vec<Option<String>>,
|
||||
conflicts: usize,
|
||||
}
|
||||
|
||||
impl DialogView {
|
||||
fn new(overrides: Overrides) -> DialogView {
|
||||
let keymap = keymap_with(&overrides);
|
||||
let texts = entries().iter().map(|e| effective_with(&keymap, &e.key)).collect();
|
||||
let conflicts = conflicts_in(&keymap).len();
|
||||
DialogView { source: stored::to_value(keymap.overrides()), keymap, texts, conflicts }
|
||||
}
|
||||
|
||||
/// The view of `d`'s working set, from the cache unless the set changed. The dialog's
|
||||
/// `overrides` field is left in its stored form.
|
||||
fn of(ctx: &egui::Context, d: &mut Dialog) -> Arc<DialogView> {
|
||||
let id = egui::Id::new("dialog-shortcuts-view");
|
||||
let source = d.fields.get("overrides").unwrap_or(&Value::Null);
|
||||
if let Some(v) = ctx.data(|m| m.get_temp::<Arc<DialogView>>(id)).filter(|v| v.source == *source) {
|
||||
return v;
|
||||
}
|
||||
let v = Arc::new(DialogView::new(dialog_overrides(d)));
|
||||
d.fields.insert("overrides".into(), v.source.clone());
|
||||
ctx.data_mut(|m| m.insert_temp(id, v.clone()));
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.dialog.clone() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let mut ov = dialog_overrides(&d);
|
||||
// The working set, as a keymap: copied only by an edit this frame.
|
||||
let view = DialogView::of(ctx, &mut d);
|
||||
let mut km = Cow::Borrowed(&view.keymap);
|
||||
let mut ok = false;
|
||||
let mut cancel = false;
|
||||
let recording = d.str("__recording");
|
||||
@@ -568,7 +663,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if key == Key::Escape {
|
||||
d.fields.insert("__recording".into(), json!(""));
|
||||
} else if let Some(chord) = chord_from_event(key, m) {
|
||||
record(&mut d, &mut ov, &recording, &chord);
|
||||
record(&mut d, km.to_mut(), &recording, &chord);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -589,7 +684,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if let Some(&name) = crate::dialogs::mixed_dropdown(ui, "kbset", &cur, &opts, 200.0, |_| true).and_then(|i| opts.get(i))
|
||||
&& let Some(p) = preset(name)
|
||||
{
|
||||
ov = p;
|
||||
km.to_mut().set_overrides(p);
|
||||
d.fields.insert("set".into(), json!(name));
|
||||
d.fields.insert("__message".into(), json!(""));
|
||||
}
|
||||
@@ -601,7 +696,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
d.fields.insert("__export".into(), json!(true));
|
||||
}
|
||||
if ui.button(tl!("Reset to Defaults")).clicked() {
|
||||
ov.clear();
|
||||
km.to_mut().reset_all();
|
||||
d.fields.insert("set".into(), json!(PRESETS[0]));
|
||||
d.fields.insert("__message".into(), json!(tl!("All shortcuts reset to defaults.")));
|
||||
}
|
||||
@@ -637,8 +732,11 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
ui.label(egui::RichText::new(tl!("Shortcut")).color(t.text_dim).size(11.0));
|
||||
});
|
||||
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
for e in entries().iter().filter(|e| e.is_tool == tools) {
|
||||
let sc = effective_in(&ov, &e.key);
|
||||
for (i, e) in entries().iter().enumerate().filter(|(_, e)| e.is_tool == tools) {
|
||||
let sc = match &km {
|
||||
Cow::Borrowed(_) => view.texts.get(i).cloned().flatten(),
|
||||
Cow::Owned(km) => effective_with(km, &e.key),
|
||||
};
|
||||
let matches = e.label.to_lowercase().contains(&q)
|
||||
|| tl!(&e.label).to_lowercase().contains(&q)
|
||||
|| e.group.to_lowercase().contains(&q)
|
||||
@@ -667,7 +765,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
} else {
|
||||
sc.as_deref().map(menus::pretty_shortcut).unwrap_or_else(|| "—".into())
|
||||
};
|
||||
let changed = ov.contains_key(&e.key);
|
||||
let changed = crate::keymap::has_override(km.overrides(), &e.key);
|
||||
let txt = egui::RichText::new(label).color(if changed { t.accent } else { t.text });
|
||||
let b = ui
|
||||
.add_sized([150.0, 20.0], egui::Button::new(txt).selected(rec == e.key))
|
||||
@@ -689,11 +787,13 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let sel = d.str("__selected");
|
||||
ui.add_enabled_ui(!sel.is_empty(), |ui| {
|
||||
if ui.button(tl!("Clear")).on_hover_text(tl!("Remove the shortcut")).clicked() {
|
||||
let _ = assign(&mut ov, &sel, None, true);
|
||||
if let Err(e) = assign_in(km.to_mut(), &sel, None, true) {
|
||||
d.fields.insert("__message".into(), json!(e));
|
||||
}
|
||||
d.fields.insert("set".into(), json!(CUSTOM));
|
||||
}
|
||||
if ui.button(tl!("Use Default")).clicked() {
|
||||
reset_one(&mut ov, &sel);
|
||||
km.to_mut().reset(&command_of(&sel));
|
||||
}
|
||||
});
|
||||
let conflict = d.str("__conflict");
|
||||
@@ -710,7 +810,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if !msg.is_empty() {
|
||||
ui.label(egui::RichText::new(msg).color(t.accent_strong).size(11.5));
|
||||
}
|
||||
let n = all_conflicts(&ov).len();
|
||||
let n = match &km {
|
||||
Cow::Borrowed(_) => view.conflicts,
|
||||
Cow::Owned(km) => conflicts_in(km).len(),
|
||||
};
|
||||
if n > 0 {
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tn(
|
||||
@@ -733,7 +836,9 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
});
|
||||
d.fields.insert("overrides".into(), serde_json::to_value(&ov).unwrap_or(json!({})));
|
||||
if let Cow::Owned(km) = &km {
|
||||
d.fields.insert("overrides".into(), stored::to_value(km.overrides()));
|
||||
}
|
||||
let import = d.fields.remove("__import").is_some();
|
||||
let export = d.fields.remove("__export").is_some();
|
||||
app.ui.dialog = Some(d);
|
||||
@@ -771,7 +876,7 @@ fn import_into_dialog(app: &mut VectorcraftApp) {
|
||||
(app.ui.shortcut_overrides, app.ui.shortcut_set) = saved;
|
||||
sync(&app.ui);
|
||||
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("overrides".into(), stored::to_value(&o));
|
||||
d.fields.insert("set".into(), json!(s));
|
||||
d.fields.insert("__message".into(), json!(tl!("Imported. Press OK to keep the imported set.")));
|
||||
}
|
||||
@@ -798,9 +903,9 @@ fn export_from_dialog(app: &mut VectorcraftApp) {
|
||||
}
|
||||
|
||||
/// Apply a recorded chord to `key` in the working set, warning about (and resolving) conflicts.
|
||||
fn record(d: &mut Dialog, ov: &mut BTreeMap<String, String>, key: &str, chord: &str) {
|
||||
fn record(d: &mut Dialog, km: &mut Keymap, key: &str, chord: &str) {
|
||||
d.fields.insert("__recording".into(), json!(""));
|
||||
match assign(ov, key, Some(chord), true) {
|
||||
match assign_in(km, key, Some(chord), true) {
|
||||
Ok(removed) => {
|
||||
d.fields.insert("set".into(), json!(CUSTOM));
|
||||
if let Some(first) = removed.first() {
|
||||
@@ -876,35 +981,35 @@ mod tests {
|
||||
assert_eq!(press(Key::ShiftLeft, Modifiers::SHIFT).str("__recording"), "tool:groupSelection");
|
||||
let d = press(Key::A, Modifiers::SHIFT);
|
||||
assert_eq!(d.str("__recording"), "");
|
||||
assert_eq!(dialog_overrides(&d).get("tool:groupSelection").map(String::as_str), Some("Shift+A"));
|
||||
assert_eq!(crate::keymap::override_text(&dialog_overrides(&d), "tool:groupSelection").as_deref(), Some("Shift+A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_modifier_only_overrides_give_the_default_back() {
|
||||
let mut ui = UiState::default();
|
||||
ui.shortcut_overrides.insert("tool:groupSelection".into(), "Shift+ShiftLeft".into());
|
||||
ui.shortcut_overrides.insert("edit.preferences".into(), String::new());
|
||||
crate::keymap::set_override(&mut ui.shortcut_overrides, "tool:groupSelection", "Shift+ShiftLeft");
|
||||
crate::keymap::set_override(&mut ui.shortcut_overrides, "edit.preferences", "");
|
||||
let ui = ui.sanitized();
|
||||
assert!(!ui.shortcut_overrides.contains_key("tool:groupSelection"));
|
||||
assert_eq!(ui.shortcut_overrides.get("edit.preferences").map(String::as_str), Some(""));
|
||||
assert!(!crate::keymap::has_override(&ui.shortcut_overrides, "tool:groupSelection"));
|
||||
assert_eq!(crate::keymap::override_text(&ui.shortcut_overrides, "edit.preferences").as_deref(), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_beats_default_and_empty_removes() {
|
||||
let mut ov = BTreeMap::new();
|
||||
let mut ov = Overrides::new();
|
||||
assert_eq!(effective_in(&ov, "edit.preferences").as_deref(), Some("Cmd+K"));
|
||||
ov.insert("edit.preferences".to_string(), "Cmd+Alt+Shift+9".to_string());
|
||||
crate::keymap::set_override(&mut ov, "edit.preferences", "Cmd+Alt+Shift+9");
|
||||
assert_eq!(effective_in(&ov, "edit.preferences").as_deref(), Some("Cmd+Alt+Shift+9"));
|
||||
ov.insert("edit.preferences".to_string(), String::new());
|
||||
crate::keymap::set_override(&mut ov, "edit.preferences", "");
|
||||
assert_eq!(effective_in(&ov, "edit.preferences"), None);
|
||||
assert_eq!(effective_in(&BTreeMap::new(), "tool:pen").as_deref(), Some("P"));
|
||||
assert_eq!(effective_in(&Overrides::new(), "tool:pen").as_deref(), Some("P"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_override_reaches_shortcut_of_and_tools() {
|
||||
let mut ui = UiState::default();
|
||||
ui.shortcut_overrides.insert("test.nonexistent".into(), "Cmd+Alt+Shift+F9".into());
|
||||
ui.shortcut_overrides.insert("tool:measure".into(), "Shift+9".into());
|
||||
crate::keymap::set_override(&mut ui.shortcut_overrides, "test.nonexistent", "Cmd+Alt+Shift+F9");
|
||||
crate::keymap::set_override(&mut ui.shortcut_overrides, "tool:measure", "Shift+9");
|
||||
let g = GENERATION.load(Ordering::Relaxed);
|
||||
sync(&ui);
|
||||
assert!(GENERATION.load(Ordering::Relaxed) > g);
|
||||
@@ -918,7 +1023,7 @@ mod tests {
|
||||
#[test]
|
||||
fn another_tests_frames_leave_this_tests_mirror_alone() {
|
||||
let mut ui = UiState::default();
|
||||
ui.shortcut_overrides.insert("tool:measure".into(), "Shift+8".into());
|
||||
crate::keymap::set_override(&mut ui.shortcut_overrides, "tool:measure", "Shift+8");
|
||||
crate::workspaces::save_as(&mut ui, "Mine").unwrap();
|
||||
sync(&ui);
|
||||
// What each frame of a test running in parallel does with its default state.
|
||||
@@ -929,7 +1034,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn assign_detects_conflicts() {
|
||||
let mut ov = BTreeMap::new();
|
||||
let mut ov = Overrides::new();
|
||||
// ⌘K is Preferences.
|
||||
let err = assign(&mut ov, "file.revert", Some("Cmd+K"), false).unwrap_err();
|
||||
assert!(err.contains("Preferences"), "{err}");
|
||||
@@ -939,7 +1044,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn forced_assign_moves_the_chord() {
|
||||
let mut ov = BTreeMap::new();
|
||||
let mut ov = Overrides::new();
|
||||
let removed = assign(&mut ov, "file.revert", Some("Cmd+K"), true).unwrap();
|
||||
assert_eq!(removed, vec!["edit.preferences".to_string()]);
|
||||
assert_eq!(effective_in(&ov, "file.revert").as_deref(), Some("Cmd+K"));
|
||||
@@ -949,7 +1054,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn assigning_the_default_drops_the_override() {
|
||||
let mut ov = BTreeMap::new();
|
||||
let mut ov = Overrides::new();
|
||||
assign(&mut ov, "tool:pen", Some("Shift+9"), false).unwrap();
|
||||
assert_eq!(ov.len(), 1);
|
||||
assign(&mut ov, "tool:pen", Some("P"), false).unwrap();
|
||||
@@ -958,16 +1063,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_shortcuts_must_be_single_keys() {
|
||||
let mut ov = BTreeMap::new();
|
||||
let mut ov = Overrides::new();
|
||||
assert!(assign(&mut ov, "tool:pen", Some("Cmd+Alt+Shift+F9"), true).is_err());
|
||||
assert!(assign(&mut ov, "tool:bogus", Some("Q"), true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_import_round_trip() {
|
||||
let mut ov = BTreeMap::new();
|
||||
ov.insert("tool:pen".to_string(), "Shift+9".to_string());
|
||||
ov.insert("edit.preferences".to_string(), String::new());
|
||||
let mut ov = Overrides::new();
|
||||
crate::keymap::set_override(&mut ov, "tool:pen", "Shift+9");
|
||||
crate::keymap::set_override(&mut ov, "edit.preferences", "");
|
||||
let v = export_json("Mine", &ov);
|
||||
let (set, back) = import_json(&v).unwrap();
|
||||
assert_eq!(set, "Mine");
|
||||
@@ -979,7 +1084,7 @@ mod tests {
|
||||
#[test]
|
||||
fn presets_are_the_defaults() {
|
||||
for p in PRESETS {
|
||||
assert_eq!(preset(p), Some(BTreeMap::new()));
|
||||
assert_eq!(preset(p), Some(Overrides::new()));
|
||||
}
|
||||
assert!(preset("Nope").is_none());
|
||||
}
|
||||
@@ -997,7 +1102,7 @@ mod tests {
|
||||
fn duplicate_can_be_given_a_shortcut() {
|
||||
// Duplicate has no default chord (#1539): it must still be listed so one can be set.
|
||||
assert_eq!(entry("edit.duplicate").map(|e| e.group.as_str()), Some("Edit"));
|
||||
let mut ov = BTreeMap::new();
|
||||
let mut ov = Overrides::new();
|
||||
assign(&mut ov, "edit.duplicate", Some("Cmd+Alt+Shift+F8"), false).unwrap();
|
||||
assert_eq!(effective_in(&ov, "edit.duplicate").as_deref(), Some("Cmd+Alt+Shift+F8"));
|
||||
}
|
||||
@@ -1016,7 +1121,7 @@ mod tests {
|
||||
{
|
||||
assert_eq!(runs_without_params(doc), bare, "{doc}");
|
||||
}
|
||||
let mut ov = BTreeMap::new();
|
||||
let mut ov = Overrides::new();
|
||||
assert!(assign(&mut ov, "symbol.breakLink", Some("Cmd+Alt+Shift+F9"), true).is_ok());
|
||||
assert_eq!(effective_in(&ov, "symbol.breakLink").as_deref(), Some("Cmd+Alt+Shift+F9"));
|
||||
}
|
||||
@@ -1028,7 +1133,7 @@ mod tests {
|
||||
let e = entry("object.isolate").expect("listed");
|
||||
assert_eq!((e.label.as_str(), e.group.as_str(), e.is_tool), ("Enter Isolation Mode", "Object", false));
|
||||
assert_eq!(default_of("object.isolate"), None);
|
||||
let mut ov = BTreeMap::new();
|
||||
let mut ov = Overrides::new();
|
||||
assign(&mut ov, "object.isolate", Some("Cmd+Alt+Shift+F11"), false).unwrap();
|
||||
assert_eq!(effective_in(&ov, "object.isolate").as_deref(), Some("Cmd+Alt+Shift+F11"));
|
||||
}
|
||||
|
||||
+148
-85
@@ -1,6 +1,8 @@
|
||||
//! Keyboard shortcuts: command shortcuts from the registry, single-key tool shortcuts, arrows,
|
||||
//! and tool keys (Enter/Esc/↑/↓ while drawing).
|
||||
|
||||
use craft_keymap::egui::Hit;
|
||||
use craft_keymap::{Active, CommandRef};
|
||||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::cmd::clipboard::{Flavour, TEXT};
|
||||
@@ -10,7 +12,7 @@ use crate::VectorcraftApp;
|
||||
|
||||
/// Parse "Cmd+Shift+]" into an egui shortcut. `Cmd` is Command on macOS and Ctrl elsewhere.
|
||||
/// `+` and `=` name one chord key ([`Key::Equals`]): `+` is Shift+`=` on many layouts, so a
|
||||
/// `Cmd+=` chord also answers `+` however it is typed (see [`consume`]).
|
||||
/// `Cmd+=` chord also answers `+` however it is typed.
|
||||
pub fn parse(s: &str) -> Option<KeyboardShortcut> {
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
@@ -80,44 +82,14 @@ pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json
|
||||
v.push((sc, "window.panel", json!({ "panel": panel })));
|
||||
}
|
||||
}
|
||||
settings_chord(&mut v, cfg!(target_os = "macos"));
|
||||
// Most specific (most modifiers) first so Cmd+Shift+Z isn't eaten by Cmd+Z.
|
||||
// Most specific (most modifiers) first: `paste_command` takes the first V chord the held keys
|
||||
// match logically, so Cmd+Shift+V isn't taken by Cmd+V.
|
||||
v.sort_by_key(|(sc, ..)| {
|
||||
std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8 + sc.modifiers.ctrl as u8)
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
/// On a Mac, Cmd+, opens Settings as in every Mac app (#663), beside Preferences' own shortcut,
|
||||
/// unless a command or a user's shortcut already took it.
|
||||
fn settings_chord(v: &mut Vec<(KeyboardShortcut, &'static str, serde_json::Value)>, mac: bool) {
|
||||
if let Some(sc) = parse("Cmd+,").filter(|sc| mac && !v.iter().any(|(s, ..)| s == sc)) {
|
||||
v.push((sc, "edit.preferences", json!({})));
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume a press of `sc`. A `=` chord also takes [`Key::Plus`], which is how `+` arrives from
|
||||
/// the numpad and from layouts where it has its own key (Shift+`=` arrives as either; extra Shift
|
||||
/// is ignored).
|
||||
pub(crate) fn consume(i: &mut egui::InputState, sc: &KeyboardShortcut) -> bool {
|
||||
i.consume_shortcut(sc)
|
||||
|| (sc.logical_key == Key::Equals && i.consume_key(sc.modifiers, Key::Plus))
|
||||
|| (sc.modifiers.shift && shifted(sc.logical_key).is_some_and(|k| i.consume_key(sc.modifiers, k)))
|
||||
}
|
||||
|
||||
/// The key a shifted punctuation key arrives as: egui reports the character typed, so Cmd+Shift+[
|
||||
/// comes in as Cmd+Shift+`{` (US-style layouts).
|
||||
fn shifted(k: Key) -> Option<Key> {
|
||||
Some(match k {
|
||||
Key::OpenBracket => Key::OpenCurlyBracket,
|
||||
Key::CloseBracket => Key::CloseCurlyBracket,
|
||||
Key::Slash => Key::Questionmark,
|
||||
Key::Semicolon => Key::Colon,
|
||||
Key::Backslash => Key::Pipe,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Keys that paste with Cmd, or alone. (Shift+Insert is left out: Ctrl+Insert copies.)
|
||||
const PASTE_KEYS: [Key; 2] = [Key::V, Key::Paste];
|
||||
|
||||
@@ -448,11 +420,13 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
// Editing keys with modifiers and the clipboard (Cmd+A is Select All's, below: the text).
|
||||
crate::panels::character::route_type_input(app, ctx);
|
||||
// Enter was already delivered above as ToolKey::Enter (newline).
|
||||
let fire = all_shortcuts().into_iter().filter(|(sc, ..)| sc.modifiers.command).find(|(sc, ..)| ctx.input_mut(|i| consume(i, sc)));
|
||||
if let Some((_, id, p)) = fire {
|
||||
crate::menus::invoke(app, id, p);
|
||||
// Enter was already delivered above as ToolKey::Enter (newline). Only chords with the
|
||||
// command key run commands: the rest type or edit.
|
||||
let hits = dispatch(app, ctx, |hit| hit.trigger.chord.mods.cmd);
|
||||
for c in hits {
|
||||
run_hit(app, ctx, &c);
|
||||
}
|
||||
settings_on_a_mac(app, ctx, mac(ctx));
|
||||
return;
|
||||
}
|
||||
// Clipboard keys arrive as events, not key presses; the chord held says which paste.
|
||||
@@ -606,28 +580,14 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Command shortcuts.
|
||||
let mut fire = None;
|
||||
for (sc, id, p) in all_shortcuts() {
|
||||
// Letter / punctuation keys without Cmd/Alt/Ctrl are handled below as text (tool shortcuts,
|
||||
// X, D, /, `,` and `.`).
|
||||
let plain = !(sc.modifiers.command || sc.modifiers.alt || sc.modifiers.ctrl);
|
||||
if plain && (sc.logical_key.name().len() == 1 || matches!(sc.logical_key, Key::Slash | Key::Comma | Key::Period)) {
|
||||
continue;
|
||||
}
|
||||
if ctx.input_mut(|i| consume(i, &sc)) {
|
||||
fire = Some((id, p));
|
||||
break;
|
||||
}
|
||||
// Command, panel and tool shortcuts. Keys that type a character without Cmd/Alt/Ctrl are
|
||||
// handled below as text (tool shortcuts, X, D, /, `,` and `.`).
|
||||
let hits = dispatch(app, ctx, |hit| !(hit.trigger.chord.is_plain() && types_text(hit.trigger.chord.key)));
|
||||
let ran = !hits.is_empty();
|
||||
for c in hits {
|
||||
run_hit(app, ctx, &c);
|
||||
}
|
||||
if let Some((id, p)) = fire {
|
||||
// With the Artboard tool, Select All's keys after a click on an Artboards panel row select
|
||||
// every artboard instead of the art.
|
||||
if id == "select.all" && crate::panels::artboards::selects_all(app, ctx) {
|
||||
crate::panels::artboards::select_all(app);
|
||||
} else {
|
||||
crate::menus::invoke(app, id, p);
|
||||
}
|
||||
if settings_on_a_mac(app, ctx, mac(ctx)) || ran {
|
||||
return;
|
||||
}
|
||||
// Alt+arrows on selected type objects step their spacing as the Type tool does (Cmd/Ctrl:
|
||||
@@ -680,6 +640,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let keymap = crate::shortcut_editor::current();
|
||||
for (text, m) in events {
|
||||
if m.command || m.alt || m.ctrl {
|
||||
continue;
|
||||
@@ -689,18 +650,86 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
// A shifted punctuation character can also be written with its Shift (the Curvature tool's
|
||||
// Shift+~ arrives as `~`).
|
||||
let shifted = m.shift.then(|| format!("Shift+{text}"));
|
||||
let lookup = |find: fn(&str) -> Option<&'static str>| find(&key).or_else(|| find(&text)).or_else(|| shifted.as_deref().and_then(find));
|
||||
// Single-key command shortcuts (X, Shift+X, D, /, Shift+D, F, Shift+F by default).
|
||||
if let Some(id) = lookup(crate::shortcut_editor::command_for_key) {
|
||||
let _ = app.run(id, json!({}));
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = lookup(crate::shortcut_editor::tool_for_key) {
|
||||
app.select_tool(t);
|
||||
let hits: Vec<CommandRef> = [Some(key), Some(text), shifted]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|t| crate::keymap::vc_chord(&t).and_then(|c| crate::keymap::lookup(&keymap.keymap, &c)))
|
||||
.collect();
|
||||
// Single-key command shortcuts (X, Shift+X, D, /, Shift+D, F, Shift+F by default) before
|
||||
// tools.
|
||||
let Some(hit) = hits.iter().find(|c| !c.id.starts_with("tool:")).or(hits.first()) else { continue };
|
||||
match hit.id.strip_prefix("tool:") {
|
||||
Some(tool) => app.select_tool(tool),
|
||||
None if hit.params.is_null() => {
|
||||
let _ = app.run(&hit.id, json!({}));
|
||||
}
|
||||
None => crate::menus::invoke(app, &hit.id, hit.params.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs this frame's key presses against the keymap in effect; `accept` declines a hit, leaving
|
||||
/// its press (and the text it typed) to what follows.
|
||||
fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, accept: impl FnMut(&Hit) -> bool) -> Vec<CommandRef> {
|
||||
let mirror = crate::shortcut_editor::current();
|
||||
let dispatcher = &mut app.dispatcher;
|
||||
ctx.input_mut(|i| {
|
||||
// Shift+`=` types `+`: as the Plus key it also reaches a `=` chord held with Shift
|
||||
// (Cmd+Shift+= zooms in as Cmd+= does).
|
||||
for e in &mut i.events {
|
||||
if let egui::Event::Key { key, modifiers, .. } = e
|
||||
&& *key == Key::Equals
|
||||
&& modifiers.shift
|
||||
{
|
||||
*key = Key::Plus;
|
||||
}
|
||||
}
|
||||
dispatcher.dispatch_filtered(i, &mirror.keymap, &Active::NONE, accept).into_iter().map(|h| h.command).collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs what a shortcut is bound to: a tool, a panel (`window.panel {panel}`) or a command.
|
||||
fn run_hit(app: &mut VectorcraftApp, ctx: &egui::Context, c: &CommandRef) {
|
||||
if let Some(tool) = c.id.strip_prefix("tool:") {
|
||||
app.select_tool(tool);
|
||||
} else if c.id == "select.all" && crate::panels::artboards::selects_all(app, ctx) {
|
||||
// With the Artboard tool, Select All's keys after a click on an Artboards panel row select
|
||||
// every artboard instead of the art.
|
||||
crate::panels::artboards::select_all(app);
|
||||
} else {
|
||||
let p = if c.params.is_null() { json!({}) } else { c.params.clone() };
|
||||
crate::menus::invoke(app, &c.id, p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys that type a character: letters, digits and punctuation (not Space).
|
||||
fn types_text(key: craft_keymap::Key) -> bool {
|
||||
key.name().chars().count() == 1
|
||||
}
|
||||
|
||||
/// Whether the app runs on a Mac (on the web, in a browser on a Mac).
|
||||
fn mac(ctx: &egui::Context) -> bool {
|
||||
ctx.os() == egui::os::OperatingSystem::Mac
|
||||
}
|
||||
|
||||
/// On a Mac, Cmd+, opens Settings as in every Mac app (#663), beside Preferences' own shortcut,
|
||||
/// when no shortcut took the press this frame. Runs after [`dispatch`]. `true` when it ran.
|
||||
fn settings_on_a_mac(app: &mut VectorcraftApp, ctx: &egui::Context, mac: bool) -> bool {
|
||||
if !mac {
|
||||
return false;
|
||||
}
|
||||
let pressed = ctx.input_mut(|i| {
|
||||
let at = i.events.iter().position(
|
||||
|e| matches!(e, egui::Event::Key { key: Key::Comma, pressed: true, modifiers, .. } if modifiers.matches_exact(Modifiers::COMMAND)),
|
||||
);
|
||||
at.map(|at| i.events.remove(at)).is_some()
|
||||
});
|
||||
if pressed {
|
||||
crate::menus::invoke(app, "edit.preferences", json!({}));
|
||||
}
|
||||
pressed
|
||||
}
|
||||
|
||||
/// The second press of Escape pressed twice comes less than this many seconds after the first.
|
||||
const ESCAPE_PAIR_SECONDS: f64 = 1.0;
|
||||
|
||||
@@ -940,10 +969,6 @@ mod tests {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
assert_eq!(crate::menus::shortcut_of("type.createOutlines"), Some("Cmd+Shift+O"));
|
||||
let open = parse("Cmd+O").unwrap();
|
||||
let outlines = parse("Cmd+Shift+O").unwrap();
|
||||
let pos = |want: egui::KeyboardShortcut, id: &str| super::all_shortcuts().iter().position(|(sc, i, _)| *i == id && *sc == want);
|
||||
assert!(pos(outlines, "type.createOutlines").unwrap() < pos(open, "file.open").unwrap(), "more modifiers win so Open does not eat the chord");
|
||||
let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT;
|
||||
let chord = egui::Event::Key { key: Key::O, physical_key: None, pressed: true, repeat: false, modifiers: cmd_shift };
|
||||
let is_text = |app: &VectorcraftApp, id: u64| {
|
||||
@@ -1337,7 +1362,9 @@ mod tests {
|
||||
/// Binds Show Grid to `chord`, or with `None` leaves every command its default shortcut.
|
||||
fn bind_grid(app: &mut VectorcraftApp, chord: Option<&str>) {
|
||||
app.ui.shortcut_overrides.clear();
|
||||
app.ui.shortcut_overrides.extend(chord.map(|c| ("view.grid".to_string(), c.to_string())));
|
||||
if let Some(c) = chord {
|
||||
app.ui.shortcut_overrides.insert("view.grid", vec![c.to_string()]);
|
||||
}
|
||||
crate::shortcut_editor::sync(&app.ui);
|
||||
}
|
||||
|
||||
@@ -1804,22 +1831,58 @@ mod tests {
|
||||
assert_eq!(letter_of(Key::Escape), None);
|
||||
}
|
||||
|
||||
/// Cmd+, opens Settings on a Mac only, and never takes a chord a command already has.
|
||||
/// Shortcuts reach tools, panels (`window.panel {panel}`) and commands through the keymap,
|
||||
/// and a held Cmd+= keeps zooming in.
|
||||
#[test]
|
||||
fn shortcuts_run_tools_panels_and_commands() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
app.session.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
let press = |key, modifiers, repeat| egui::Event::Key { key, physical_key: None, pressed: true, repeat, modifiers };
|
||||
crate::keymap::set_override(&mut app.ui.shortcut_overrides, "tool:pen", "F2");
|
||||
crate::shortcut_editor::sync(&app.ui);
|
||||
frame(&mut app, vec![press(Key::F2, Modifiers::NONE, false)]);
|
||||
assert_eq!(app.session.tool_id(), "pen", "a tool on a key that types nothing");
|
||||
app.select_tool("selection");
|
||||
frame(&mut app, vec![press(Key::F7, Modifiers::SHIFT, false)]);
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("align"));
|
||||
frame(&mut app, vec![press(Key::A, Modifiers::COMMAND, false)]);
|
||||
assert_eq!(app.session.active().unwrap().selection.objects.len(), 2, "Select All");
|
||||
let zoom = |app: &mut VectorcraftApp| app.view_mut().unwrap().zoom;
|
||||
frame(&mut app, vec![press(Key::Equals, Modifiers::COMMAND, false)]);
|
||||
let once = zoom(&mut app);
|
||||
frame(&mut app, vec![press(Key::Equals, Modifiers::COMMAND, true)]);
|
||||
assert!(zoom(&mut app) > once, "a key repeat zooms again");
|
||||
}
|
||||
|
||||
/// Cmd+, opens Settings on a Mac only, beside the frame's other shortcuts, and never takes a
|
||||
/// chord a shortcut already has.
|
||||
#[test]
|
||||
fn cmd_comma_opens_settings_on_a_mac() {
|
||||
let comma = parse("Cmd+,").unwrap();
|
||||
let mut v = vec![];
|
||||
super::settings_chord(&mut v, false);
|
||||
assert!(v.is_empty(), "not elsewhere");
|
||||
super::settings_chord(&mut v, true);
|
||||
assert_eq!(v.len(), 1);
|
||||
assert_eq!((v[0].0, v[0].1), (comma, "edit.preferences"));
|
||||
let mut taken = vec![(comma, "view.zoomIn", json!({}))];
|
||||
super::settings_chord(&mut taken, true);
|
||||
assert_eq!(taken.len(), 1, "a command that has it keeps it");
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(super::all_shortcuts().iter().any(|(sc, id, _)| *sc == comma && *id == "edit.preferences"));
|
||||
}
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
let on = |app: &mut VectorcraftApp, os, events| {
|
||||
app.ui.dialog = None;
|
||||
app.session.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_os(os);
|
||||
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| handle(app, ui.ctx())).textures_delta.clear();
|
||||
let settings = app.ui.dialog.as_ref().is_some_and(|d| d.kind == "preferences");
|
||||
(settings, app.session.active().unwrap().selection.objects.len())
|
||||
};
|
||||
let (mac, linux) = (egui::os::OperatingSystem::Mac, egui::os::OperatingSystem::Nix);
|
||||
let comma = press(Key::Comma, Modifiers::COMMAND);
|
||||
let select_all = press(Key::A, Modifiers::COMMAND);
|
||||
assert_eq!(on(&mut app, mac, vec![comma.clone()]), (true, 0));
|
||||
assert_eq!(on(&mut app, mac, vec![select_all.clone(), comma.clone()]), (true, 1), "with another shortcut in the frame");
|
||||
assert_eq!(on(&mut app, linux, vec![select_all, comma.clone()]), (false, 1), "not elsewhere");
|
||||
crate::keymap::set_override(&mut app.ui.shortcut_overrides, "select.all", "Cmd+,");
|
||||
crate::shortcut_editor::sync(&app.ui);
|
||||
assert_eq!(on(&mut app, mac, vec![comma]), (false, 1), "a shortcut that has it keeps it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -432,9 +432,10 @@ pub struct UiState {
|
||||
/// Recording in progress: (set index, action name, journal length when recording started).
|
||||
#[serde(skip)]
|
||||
pub recording: Option<(usize, String, usize)>,
|
||||
/// Keyboard shortcut overrides: command id or `tool:<id>` → chord ("" = none).
|
||||
#[serde(default)]
|
||||
pub shortcut_overrides: std::collections::BTreeMap<String, String>,
|
||||
/// Keyboard shortcut overrides: command id, `tool:<id>` or `panel:<id>` → chord ("" = none),
|
||||
/// stored in the form every earlier version reads.
|
||||
#[serde(default, with = "crate::keymap::stored")]
|
||||
pub shortcut_overrides: craft_keymap::Overrides,
|
||||
/// Name of the shortcut set (preset or "Custom").
|
||||
#[serde(default = "default_shortcut_set", deserialize_with = "crate::shortcut_editor::deserialize_set_name")]
|
||||
pub shortcut_set: String,
|
||||
@@ -821,7 +822,15 @@ impl UiState {
|
||||
self.workspace_changes.truncate(64);
|
||||
// Overrides that can't fire (modifier-only chords recorded by older versions, #487) give
|
||||
// the default back.
|
||||
self.shortcut_overrides.retain(|_, c| c.is_empty() || crate::shortcut_editor::normalize(c).is_some());
|
||||
let unusable: Vec<String> = self
|
||||
.shortcut_overrides
|
||||
.iter()
|
||||
.filter(|(_, texts)| !texts.iter().all(|t| craft_keymap::Binding::parse(t).is_ok()))
|
||||
.map(|(k, _)| k.to_string())
|
||||
.collect();
|
||||
for k in unusable {
|
||||
self.shortcut_overrides.remove(&k);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ fn the_window_menu_has_the_system_items() {
|
||||
#[test]
|
||||
fn a_command_keeps_a_system_key_it_is_bound_to() {
|
||||
let mut app = app(true);
|
||||
app.ui.shortcut_overrides.insert("view.grid".into(), "Ctrl+Cmd+H".into());
|
||||
crate::keymap::set_override(&mut app.ui.shortcut_overrides, "view.grid", "Ctrl+Cmd+H");
|
||||
crate::shortcut_editor::sync(&app.ui);
|
||||
let layout = native_menu::mac_layout(&app, &native_menu::from_tree(&app, &menus::menu_tree()), Lang::EN);
|
||||
app.ui.shortcut_overrides.clear();
|
||||
|
||||
@@ -233,7 +233,7 @@ fn the_window_menu_panel_keys() {
|
||||
assert_eq!((shortcut("Align"), shortcut("Layers"), shortcut("Paragraph")), ("Shift+F7".into(), "F7".into(), "Cmd+Alt+T".into()));
|
||||
assert!(shortcut_editor::entry("panel:layers").is_some(), "the dock's tabs can be given keys too");
|
||||
// Changed in the editor, the old key is free.
|
||||
app.ui.shortcut_overrides.insert("panel:align".into(), "Cmd+Alt+Shift+F7".into());
|
||||
crate::keymap::set_override(&mut app.ui.shortcut_overrides, "panel:align", "Cmd+Alt+Shift+F7");
|
||||
shortcut_editor::sync(&app.ui);
|
||||
press(&mut app, egui::Key::F7, shift);
|
||||
assert_eq!(app.ui.open_panel, None);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! The paste chords (egui reports every Cmd+V chord as a paste, not as its key) and the default
|
||||
//! shortcuts (no chord does two things).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use egui::{Event, Key, Modifiers};
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
@@ -71,7 +69,7 @@ fn a_paste_takes_the_command_of_the_v_chord_held() {
|
||||
|
||||
#[test]
|
||||
fn no_two_commands_tools_or_panels_share_a_default_shortcut() {
|
||||
assert_eq!(shortcut_editor::all_conflicts(&BTreeMap::new()), vec![]);
|
||||
assert_eq!(shortcut_editor::all_conflicts(&craft_keymap::Overrides::new()), vec![]);
|
||||
}
|
||||
|
||||
/// egui-winit reports every Cmd+C chord as a Copy, Shift and Alt held too, never as its key: the
|
||||
|
||||
@@ -324,7 +324,7 @@ fn load_variable_library_asks_off_the_ui_thread() {
|
||||
#[test]
|
||||
fn keyboard_shortcuts_import_and_export_ask_off_the_ui_thread() {
|
||||
let (mut app, shown, written) = app();
|
||||
let set = crate::shortcut_editor::export_json("Custom", &BTreeMap::from([("view.grid".to_string(), "Ctrl+Alt+G".to_string())]));
|
||||
let set = crate::shortcut_editor::export_json("Custom", &crate::keymap::overrides_from(&[("view.grid", "Ctrl+Alt+G")]));
|
||||
let (_, want) = crate::shortcut_editor::import_json(&set).unwrap();
|
||||
let bytes = serde_json::to_vec(&set).unwrap();
|
||||
app.services.read = Some(Box::new(move |_: &str| Ok(bytes.clone())));
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
||||
|
||||
/// External crates that constitute a UI toolkit / windowing dependency.
|
||||
/// Entries ending in `*` are prefixes.
|
||||
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "craft-ui", "rfd", "bevy*"];
|
||||
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "craft-ui", "craft-keymap", "rfd", "bevy*"];
|
||||
|
||||
/// First layer allowed to use UI crates.
|
||||
pub const UI_MIN_LAYER: u8 = 6;
|
||||
|
||||
Reference in New Issue
Block a user