diff --git a/Cargo.lock b/Cargo.lock index e204e808d..89dfe74fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index f5d45788b..72f6444d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 914ade779..fc3f0dea4 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -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(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0854dcaab..3ccedb7a9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/namematch.rs b/crates/engine/src/namematch.rs deleted file mode 100644 index 497544578..000000000 --- a/crates/engine/src/namematch.rs +++ /dev/null @@ -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, -} - -/// 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), -} - -/// 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 { - 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 { - 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 = 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, - 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, - id: String, - labels: Vec, -} - -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, - /// Candidates by their joined id words and their labels' letters and digits. - exact: HashMap>, - /// Candidates by a word of their id or labels. - by_word: HashMap>, - /// Candidates by a character pair of their labels. - by_pair: HashMap<(char, char), Vec>, -} - -impl Matcher { - pub fn new(candidates: &[Candidate]) -> Self { - let prepared: Vec = candidates.iter().map(Prepared::of).collect(); - let (mut exact, mut by_word, mut by_pair) = (HashMap::new(), HashMap::new(), HashMap::new()); - fn add(map: &mut HashMap>, 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 { - 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"]); - } -} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index ba650c3b6..e480a95c4 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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 } diff --git a/crates/ui-egui/src/dialogs/shortcut_import.rs b/crates/ui-egui/src/dialogs/shortcut_import.rs index 4845a7102..a66184a99 100644 --- a/crates/ui-egui/src/dialogs/shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/shortcut_import.rs @@ -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 { 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 { let review = Review::of(d); let statuses = review.statuses(); let back = super::parent(d); - let mut ov: BTreeMap = 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 { 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 { 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)], start: Start) -> usize { + let mut entries: Vec = vec![]; + // Per entry: its row's line and target. + let mut rows: Vec<(usize, &str)> = vec![]; + let mut remembered: BTreeMap = 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 = + 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 = 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)]) -> Result<(), String> { let mut matches = vec![]; diff --git a/crates/ui-egui/src/dialogs/tests_shortcut_import.rs b/crates/ui-egui/src/dialogs/tests_shortcut_import.rs index 002c04b88..6673285b5 100644 --- a/crates/ui-egui/src/dialogs/tests_shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/tests_shortcut_import.rs @@ -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> = 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"}))); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index b61613e80..0df3d081a 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -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}, променени на диска diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f64f49519..2ed9143f5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e33f3735e..8c914902c 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4ff3f922f..908f128e4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 566078a29..982a2c299 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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} σε μία πλευρά: ανιχνεύεται, αλλά πιο αργά. Μια προρύθμιση χαμηλότερης πιστότητας ή ένα μικρότερο αντίγραφο της εικόνας ανιχνεύεται γρηγορότερα. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6f9649671..93932fd5e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 0e277db8e..400287e86 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 76e7626f6..73a573ded 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index eb7dfa404..defc81b1f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a6bfdb0d5..f499e7fdc 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9c4ddf9e2..aa612d4f8 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d1f9fc62e..45b15999c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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} を超えています。トレースはできますが、時間がかかります。忠実度の低いプリセットや画像の小さいコピーを使うと速くなります。 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 62ab638f3..540c27d7f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -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} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1e3bf00e7..ab04e0197 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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}을 넘습니다. 추적은 되지만 더 느립니다. 충실도가 낮은 사전 설정이나 이미지의 작은 사본을 사용하면 더 빠릅니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 9eb141055..0147b9339 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 1941bc050..977976c90 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 19f0f442d..275681b77 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e465f2a32..3fbe152df 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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} по одной стороне: трассировка выполняется, но медленнее. Стиль с меньшей точностью или уменьшенная копия изображения трассируются быстрее. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 03ad0ddd7..7ccb3a6b5 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ed0514462..775dbe747 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 40ea0ac69..4e49ccb0d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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} з одного боку: трасування виконується, але повільніше. Стиль із нижчою точністю або менша копія зображення трасуються швидше. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 858d3f049..20ce3ae08 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f2175fd33..43cadf81b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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}:会进行描摹,但速度较慢。使用保真度较低的预设或图像的较小副本会更快。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d5b3857e6..3d07d982f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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}:仍會描圖,但速度較慢。使用精確度較低的預設集或較小的影像拷貝,描圖會較快。 diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs new file mode 100644 index 000000000..03d08be1d --- /dev/null +++ b/crates/ui-egui/src/keymap.rs @@ -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:`, or +//! `panel:` 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:` 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 { + 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 { + 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 { + 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 = 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> = 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(cells: &'static [OnceLock; 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; 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 { + 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 { + 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 { + 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(overrides: &Overrides, s: S) -> Result { + 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 { + Ok(from_value(Value::deserialize(d)?)) + } + + pub fn to_value(overrides: &Overrides) -> Value { + struct Stored<'a>(&'a Overrides); + impl Serialize for Stored<'_> { + fn serialize(&self, s: S) -> Result { + 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 = 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 = serde_json::from_value(saved["shortcut_overrides"].clone()).unwrap(); + let want: BTreeMap = [ + ("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 = 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::::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}"); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8136f57bd..93ae627b5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7191f77f1..7e3f810bb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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"); diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 28ec31706..5d68d1d8e 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -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:`; 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:` or `panel:`; 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); +/// 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>, +} + +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>); + +/// The mirror: the keymap in effect. +pub(crate) fn current() -> Arc { + static DEFAULTS: [OnceLock>; 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, key: &str) -> Option { - 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 { + 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 { - 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, chord: &str, except: &str) -> Vec { - let Some(n) = normalize(chord) else { return vec![] }; +pub fn conflicts_with(overrides: &Overrides, chord: &str, except: &str) -> Vec { + 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) -> Vec<(String, Vec)> { +pub fn all_conflicts(overrides: &Overrides) -> Vec<(String, Vec)> { + conflicts_in(&keymap_with(overrides)) +} + +/// [`all_conflicts`] of a keymap. +pub fn conflicts_in(km: &Keymap) -> Vec<(String, Vec)> { let mut by: BTreeMap> = 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, key: &str, chord: Option<&str>, force: bool) -> Result, 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, 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, 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 = 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 = 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, 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 = 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, 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> { - PRESETS.contains(&set_name(name)).then(BTreeMap::new) +pub fn preset(name: &str) -> Option { + PRESETS.contains(&set_name(name)).then(Overrides::new) } /// Export format: `{"format": "vectorcraft-shortcuts", "set": name, "overrides": {...}}`. -pub fn export_json(set: &str, overrides: &BTreeMap) -> 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> { +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), 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 { - 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 { 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 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>, + 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 { + 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::>(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, 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")); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 5b353f9f1..217b53c65 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -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 { 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 { - 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 = [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 { + 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] diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e3de3b0e2..3d67b5f15 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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:` → chord ("" = none). - #[serde(default)] - pub shortcut_overrides: std::collections::BTreeMap, + /// Keyboard shortcut overrides: command id, `tool:` or `panel:` → 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 = 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 } } diff --git a/crates/ui-egui/src/tests_nativemenu.rs b/crates/ui-egui/src/tests_nativemenu.rs index 6e75912a2..6f0bb7f48 100644 --- a/crates/ui-egui/src/tests_nativemenu.rs +++ b/crates/ui-egui/src/tests_nativemenu.rs @@ -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(); diff --git a/crates/ui-egui/src/tests_paintchips.rs b/crates/ui-egui/src/tests_paintchips.rs index 8845000e9..ca8e8f259 100644 --- a/crates/ui-egui/src/tests_paintchips.rs +++ b/crates/ui-egui/src/tests_paintchips.rs @@ -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); diff --git a/crates/ui-egui/src/tests_pastechords.rs b/crates/ui-egui/src/tests_pastechords.rs index 74a4e3b0c..55cab98e7 100644 --- a/crates/ui-egui/src/tests_pastechords.rs +++ b/crates/ui-egui/src/tests_pastechords.rs @@ -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 diff --git a/crates/ui-egui/src/tests_picks.rs b/crates/ui-egui/src/tests_picks.rs index 47fca62ad..ece5aa444 100644 --- a/crates/ui-egui/src/tests_picks.rs +++ b/crates/ui-egui/src/tests_picks.rs @@ -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()))); diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 05ee9ffb5..3117a7af1 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -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;