From 40ed074caed49dd1bf65d6655a4100d18196ca65 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 20:27:02 +0700 Subject: [PATCH] Actions: New Action asks for the action's name, set, function key and color Squashed from #5270 onto main. --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/dialogs.rs | 2 + crates/ui-egui/src/dialogs/action_new.rs | 120 ++ .../ui-egui/src/dialogs/action_set_options.rs | 34 +- crates/ui-egui/src/i18n/bg.tsv | 12 + crates/ui-egui/src/i18n/bs.tsv | 12 + crates/ui-egui/src/i18n/ca.tsv | 12 + crates/ui-egui/src/i18n/cs.tsv | 12 + crates/ui-egui/src/i18n/da.tsv | 12 + crates/ui-egui/src/i18n/de.tsv | 12 + crates/ui-egui/src/i18n/el.tsv | 12 + crates/ui-egui/src/i18n/es.tsv | 12 + crates/ui-egui/src/i18n/fi.tsv | 12 + crates/ui-egui/src/i18n/fr.tsv | 12 + crates/ui-egui/src/i18n/hu.tsv | 12 + crates/ui-egui/src/i18n/id.tsv | 12 + crates/ui-egui/src/i18n/it.tsv | 12 + crates/ui-egui/src/i18n/ja.tsv | 12 + crates/ui-egui/src/i18n/ka.tsv | 12 + crates/ui-egui/src/i18n/ko.tsv | 12 + crates/ui-egui/src/i18n/nl.tsv | 12 + crates/ui-egui/src/i18n/pl.tsv | 12 + crates/ui-egui/src/i18n/pt-br.tsv | 12 + crates/ui-egui/src/i18n/ru.tsv | 12 + crates/ui-egui/src/i18n/sv.tsv | 12 + crates/ui-egui/src/i18n/tr.tsv | 12 + crates/ui-egui/src/i18n/uk.tsv | 12 + crates/ui-egui/src/i18n/vi.tsv | 12 + crates/ui-egui/src/i18n/zh-hans.tsv | 12 + crates/ui-egui/src/i18n/zh-hant.tsv | 12 + crates/ui-egui/src/menus/run.rs | 4 + crates/ui-egui/src/menus/ui_commands.rs | 26 +- crates/ui-egui/src/panels/actions.rs | 1031 ++++++++++++++++- crates/ui-egui/src/panels/actions/files.rs | 25 +- crates/ui-egui/src/panels/pathfinder.rs | 4 +- crates/ui-egui/src/shortcut_editor.rs | 29 + crates/ui-egui/src/shortcuts.rs | 11 +- crates/ui-egui/src/state.rs | 6 + docs/mcp.md | 29 +- docs/target-app-parity.md | 3 +- docs/ui-parity.md | 5 +- 41 files changed, 1558 insertions(+), 84 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/action_new.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 63bb4de7b..7cbe32d1e 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -350,6 +350,7 @@ impl VectorcraftApp { } crate::keymap::note_os(ctx); shortcut_editor::sync(&self.ui); + crate::panels::actions::sync_function_keys(&self.ui); prefs_dialog::apply_runtime(self, ctx); // The OS title bar (and the taskbar / Alt-Tab entry) follows the active file; with the // system title bar this is where the document name lives, as the in-app mark is hidden. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 805388dd4..d68217e52 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8,6 +8,7 @@ mod about; pub mod action_batch; +pub mod action_new; pub mod action_set_options; pub mod artboard_options; pub mod blend_options; @@ -361,6 +362,7 @@ registry! { Package: [package::KIND] => package::SPEC, ActionBatch: [action_batch::KIND] => action_batch::SPEC, ActionSetOptions: [action_set_options::KIND] => action_set_options::SPEC, + ActionNew: [action_new::KIND] => action_new::SPEC, SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC, DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC, EpsOptions: [eps_options::KIND] => eps_options::SPEC, diff --git a/crates/ui-egui/src/dialogs/action_new.rs b/crates/ui-egui/src/dialogs/action_new.rs new file mode 100644 index 000000000..dab6013e1 --- /dev/null +++ b/crates/ui-egui/src/dialogs/action_new.rs @@ -0,0 +1,120 @@ +//! Actions panel › Create New Action (and ≡ › New Action…): the name, set, function key and color +//! of a new action. Record adds the action and starts recording into it (`ui.action.new`). +//! +//! Fields: `set` (the index of the set the action goes into), `name`, `key` (0 for none, 1 to 15 +//! for F1 to F15), `cmd` (Ctrl, Command on macOS), `shift`, `color` (a value of +//! [`crate::panels::actions::COLORS`], `""` for none). An invalid set or function key is an error: +//! Record then adds nothing and the dialog stays open. + +use serde_json::{Value, json}; + +use super::DialogSpec; +use crate::panels::actions::{COLORS, FUNCTION_KEYS, FunctionKey}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of New Action. +pub const KIND: &str = "actionNew"; + +/// The width of the label column. +const LABEL_W: f32 = 90.0; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("New Action").into(), + body, + confirm, + ok: Some("Record"), + ok_enabled: Some(|_, d| !d.str("name").trim().is_empty()), + min_width: 340.0, + ..DialogSpec::FORM +}; + +/// Whether New Action can open: there is a set to put the action in and nothing is recording. +pub fn can_open(app: &VectorcraftApp) -> bool { + !app.ui.action_sets.is_empty() && app.ui.recording.is_none() +} + +/// Opens New Action for the current set, else the first set, named "Action N" with the first +/// number from the set's action count plus one that no action of the set uses, with no function +/// key and no color. +pub fn open(app: &mut VectorcraftApp) -> Result<(), String> { + if app.ui.recording.is_some() { + return Err("an action is already recording".into()); + } + let len = app.ui.action_sets.len(); + let set = app.ui.action_set.filter(|&s| s < len).unwrap_or(0); + let Some(s) = app.ui.action_sets.get(set) else { return Err("no such action set".into()) }; + let taken = |n: usize| s.actions.iter().any(|a| a.name == format!("Action {n}")); + let n = (s.actions.len() + 1..).find(|&n| !taken(n)).unwrap_or(1); + let fields = json!({"set": set, "name": format!("Action {n}"), "key": 0, "cmd": false, "shift": false, "color": ""}); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(()) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + widgets::label_row(ui, "Name:", LABEL_W, |ui| { + super::form::text(ui, d, "name", 200.0); + }); + let names: Vec<&str> = app.ui.action_sets.iter().map(|s| s.name.as_str()).collect(); + let set = d.fields.get("set").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + let shown = set.and_then(|s| names.get(s)).copied().unwrap_or_default(); + widgets::label_row(ui, "Action Set:", LABEL_W, |ui| { + if let Some(i) = widgets::dropdown_names(ui, "action-new-set", shown, &names, 200.0) { + d.fields.insert("set".into(), json!(i)); + } + }); + key_and_color(ui, d); + false +} + +/// The Function Key and Color rows of New Action and Action Options show errors for invalid keys. +pub(super) fn key_and_color(ui: &mut egui::Ui, d: &mut Dialog) { + let key = d.fields.get("key").and_then(Value::as_u64).unwrap_or(0); + let none = tl!("None"); + let keys: Vec = std::iter::once(none.to_string()).chain((1..=FUNCTION_KEYS).map(|n| format!("F{n}"))).collect(); + let labels: Vec<&str> = keys.iter().map(String::as_str).collect(); + let shown = usize::try_from(key).ok().and_then(|k| labels.get(k)).copied().unwrap_or(none); + let modifier = if crate::keymap::platform() == craft_keymap::Platform::Mac { "⌘" } else { "Ctrl" }; + widgets::label_row(ui, "Function Key:", LABEL_W, |ui| { + if let Some(i) = widgets::dropdown_names(ui, "action-key", shown, &labels, 70.0) { + d.fields.insert("key".into(), json!(i)); + } + super::form::check(ui, d, "cmd", modifier); + super::form::check(ui, d, "shift", "Shift"); + }); + let picked = FunctionKey::from_params(&json!({"key": key, "cmd": d.bool("cmd"), "shift": d.bool("shift")})); + if let Ok(Some(k)) = picked + && let Some(message) = crate::panels::actions::key_error(k) + { + widgets::label_row(ui, "", LABEL_W, |ui| { + ui.label(crate::i18n::msg(&message)); + }); + } + let colors: Vec<(&str, &str)> = std::iter::once(("", "None")).chain(COLORS).collect(); + super::form::choice(ui, d, "color", "Color:", (LABEL_W, 120.0), &colors); +} + +/// The function key and color params the dialog's fields give, as `ui.action.new` and +/// `ui.action.options` take them. +pub(super) fn key_and_color_params(d: &Dialog) -> Value { + json!({ + "key": d.fields.get("key").and_then(Value::as_u64).unwrap_or(0), + "cmd": d.bool("cmd"), + "shift": d.bool("shift"), + "color": d.str("color"), + }) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let name = d.str("name").trim().to_string(); + // Record is disabled without a name; `ui.dialog.confirm` keeps the dialog open the same way. + if name.is_empty() { + return Ok(Value::Null); + } + let mut p = key_and_color_params(d); + p["set"] = d.fields.get("set").cloned().unwrap_or(Value::Null); + p["name"] = json!(name); + app.run(crate::panels::actions::NEW_ACTION, p)?; + app.ui.dialog = None; + Ok(Value::Null) +} diff --git a/crates/ui-egui/src/dialogs/action_set_options.rs b/crates/ui-egui/src/dialogs/action_set_options.rs index 568ea41c0..f4c2ecded 100644 --- a/crates/ui-egui/src/dialogs/action_set_options.rs +++ b/crates/ui-egui/src/dialogs/action_set_options.rs @@ -2,10 +2,12 @@ //! the name of a new action set, of the current one, or of the selected action. //! //! Fields: `set` (the index of the set renamed, or of the action's set; none for a new set), -//! `action` (the index of the action renamed, in `set`; none for a set) and `name`. OK adds the set -//! and makes it the current one, renames the set, or renames the action (`ui.action.rename`). A -//! `set` or `action` field that is not the index of a set or action is an error, OK then changes -//! no set or action, and the dialog stays open. +//! `action` (the index of the action renamed, in `set`; none for a set) and `name`. Action Options +//! also holds the action's function key and color, as New Action does (`key`, `cmd`, `shift`, +//! `color`, see [`super::action_new`]). OK adds the set and makes it the current +//! one, renames the set, or changes the action's name, function key and color +//! (`ui.action.options`). An invalid `set`, `action` or function key is an error. OK then changes no set or +//! action, and the dialog stays open. use serde_json::{Value, json}; @@ -43,17 +45,26 @@ pub fn open(app: &mut VectorcraftApp, set: usize) { } } -/// Open Action Options for the action at `action` in set `set`, if there is one. +/// Open Action Options for the action at `action` in set `set`, if there is one, with its name, +/// function key and color. pub fn open_action(app: &mut VectorcraftApp, set: usize, action: usize) { if let Some(a) = app.ui.action_sets.get(set).and_then(|s| s.actions.get(action)) { - app.ui.dialog = Some(Dialog::new(KIND, json!({"set": set, "action": action, "name": a.name}))); + let (key, cmd, shift) = a.key.map_or((0, false, false), |k| (k.key, k.cmd, k.shift)); + let color = a.color.clone().unwrap_or_default(); + let fields = json!({"set": set, "action": action, "name": a.name, "key": key, "cmd": cmd, "shift": shift, "color": color}); + app.ui.dialog = Some(Dialog::new(KIND, fields)); } } -fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { - widgets::label_row(ui, "Name:", 50.0, |ui| { +fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + // Action Options lines its rows up with the Function Key and Color rows. + let action = d.fields.contains_key("action") && d.fields.contains_key("key"); + widgets::label_row(ui, "Name:", if action { 90.0 } else { 50.0 }, |ui| { super::form::text(ui, d, "name", 200.0); }); + if action { + super::action_new::key_and_color(ui, d); + } false } @@ -65,7 +76,12 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } // The fields the dialog holds choose what OK does, as they choose its heading. if d.fields.contains_key("action") { - app.run(crate::panels::actions::RENAME_ACTION, json!({"set": d.fields.get("set"), "action": d.fields.get("action"), "name": name}))?; + // A dialog without the key fields (made by `ui.dialog.set`) keeps the key and color. + let mut p = if d.fields.contains_key("key") { super::action_new::key_and_color_params(d) } else { json!({}) }; + p["set"] = d.fields.get("set").cloned().unwrap_or(Value::Null); + p["action"] = d.fields.get("action").cloned().unwrap_or(Value::Null); + p["name"] = json!(name); + app.run(crate::panels::actions::ACTION_OPTIONS, p)?; } else if let Some(set) = d.fields.get("set") { let s = set.as_u64().and_then(|i| usize::try_from(i).ok()).and_then(|i| app.ui.action_sets.get_mut(i)).ok_or("no such action set")?; s.name = name; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 691f33f88..39cc049f3 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5476,6 +5476,11 @@ theme Appearance Външен вид Export Type: Тип на експортиране: Movie Clip Филмов клип @msg name is empty името е празно +@msg an action is already recording вече се записва действие +@msg no such function key няма такъв функционален клавиш +@msg no such color няма такъв цвят +@msg {_1} cannot be used on Windows {_1} не може да се използва в Windows +@msg more than one action is named “{_1}” повече от едно действие се казва „{_1}“ @msg no symbol named `{n}` няма символ с име `{n}` Smudge Stick… Размазваща пръчка… Smudge Stick Размазваща пръчка @@ -5622,6 +5627,13 @@ axis Y Axis Y ос Action Options… Опции на действието… Action Options Опции на действието Rename Action Преименуване на действие + New Action… Ново действие… + New Action Ново действие + Create New Action Създаване на ново действие + Play Action Изпълнение на действие + Action Set: Набор от действия: + Function Key: Функционален клавиш: + Record Запис Create New Set Създаване на нов набор Stop Global Edit Край на глобалното редактиране Global Edit Options Опции за глобално редактиране diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 25dde07c6..61fcd183f 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5480,6 +5480,11 @@ mark-style Japanese Japanski Export Type: Vrsta izvoza: Movie Clip Filmski klip @msg name is empty naziv je prazan +@msg an action is already recording akcija se već snima +@msg no such function key nema takve funkcijske tipke +@msg no such color nema takve boje +@msg {_1} cannot be used on Windows {_1} se ne može koristiti na Windowsu +@msg more than one action is named “{_1}” više od jedne akcije ima naziv „{_1}” @msg no symbol named `{n}` nema simbola pod nazivom `{n}` Smudge Stick… Štapić za razmazivanje… Smudge Stick Štapić za razmazivanje @@ -5645,6 +5650,13 @@ axis Y Axis Y osa Action Options… Opcije akcije… Action Options Opcije akcije Rename Action Preimenuj akciju + New Action… Nova akcija… + New Action Nova akcija + Create New Action Napravi novu akciju + Play Action Reprodukuj akciju + Action Set: Skup akcija: + Function Key: Funkcijska tipka: + Record Snimi Create New Set Napravi novi skup Stop Global Edit Zaustavi globalno uređivanje Global Edit Options Opcije globalnog uređivanja diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index d44d0594a..18b9e6b3c 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3774,6 +3774,13 @@ digits Malayalam Malaiàlam Action Options… Opcions de l’acció… Action Options Opcions de l’acció Rename Action Canvia el nom de l’acció + New Action… Acció nova… + New Action Acció nova + Create New Action Crea una acció nova + Play Action Reprodueix l’acció + Action Set: Conjunt d’accions: + Function Key: Tecla de funció: + Record Enregistra Set Options… Defineix les opcions… Set Preference Defineix la preferència Set Slice Rectangle Defineix el rectangle del tall @@ -5160,6 +5167,11 @@ digits Malayalam Malaiàlam @msg move requires an existing destination panel per moure cal un panell de destinació existent @msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode Els fitxers PSD multicanal no es poden llegir: deseu-los en mode RGB, CMYK o escala de grisos @msg name is empty el nom és buit +@msg an action is already recording ja s’està enregistrant una acció +@msg no such function key no existeix aquesta tecla de funció +@msg no such color no existeix aquest color +@msg {_1} cannot be used on Windows {_1} no es pot fer servir al Windows +@msg more than one action is named “{_1}” més d’una acció es diu “{_1}” @msg name the file poseu un nom al fitxer @msg New Window isn't available yet: a document opens in one window Finestra nova encara no està disponible: un document s’obre en una sola finestra @msg no active document no hi ha cap document actiu diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3051468cd..d05064235 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5567,6 +5567,11 @@ mark-style Japanese Japonské Export Type: Typ exportu: Movie Clip Filmový klip @msg name is empty název je prázdný +@msg an action is already recording akce se už zaznamenává +@msg no such function key taková funkční klávesa neexistuje +@msg no such color taková barva neexistuje +@msg {_1} cannot be used on Windows Zkratku {_1} nelze ve Windows použít +@msg more than one action is named “{_1}” název „{_1}“ má více než jedna akce @msg no symbol named `{n}` žádný symbol s názvem `{n}` Smudge Stick… Rozmazávací tyčinka… Smudge Stick Rozmazávací tyčinka @@ -5737,6 +5742,13 @@ axis Y Axis osa Y Action Options… Možnosti akce… Action Options Možnosti akce Rename Action Přejmenovat akci + New Action… Nová akce… + New Action Nová akce + Create New Action Vytvořit novou akci + Play Action Přehrát akci + Action Set: Sada akcí: + Function Key: Funkční klávesa: + Record Zaznamenat Create New Set Vytvořit novou sadu Stop Global Edit Ukončit globální úpravy Global Edit Options Možnosti globálních úprav diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index aecb2a8c8..bb2b7b471 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5360,6 +5360,11 @@ swatch Bright Violet Kraftig violet Export Type: Eksporttype: Movie Clip Filmklip @msg name is empty navnet er tomt +@msg an action is already recording en handling optages allerede +@msg no such function key ingen sådan funktionstast +@msg no such color ingen sådan farve +@msg {_1} cannot be used on Windows {_1} kan ikke bruges i Windows +@msg more than one action is named “{_1}” mere end én handling hedder »{_1}« @msg no symbol named `{n}` intet symbol med navnet `{n}` Remove Color Profile… Fjern farveprofil… Color wheel Farvehjul @@ -5639,6 +5644,13 @@ axis Y Axis Y-akse Action Options… Indstillinger for handling… Action Options Indstillinger for handling Rename Action Omdøb handling + New Action… Ny handling… + New Action Ny handling + Create New Action Opret ny handling + Play Action Afspil handling + Action Set: Handlingssæt: + Function Key: Funktionstast: + Record Optag Create New Set Opret nyt sæt Stop Global Edit Stop global redigering Global Edit Options Indstillinger for global redigering diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6fe42bdfa..5c57b5895 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5145,6 +5145,11 @@ effect Soft Weich Export Type: Exporttyp: Movie Clip Movieclip @msg name is empty Name ist leer +@msg an action is already recording eine Aktion wird bereits aufgezeichnet +@msg no such function key keine solche Funktionstaste +@msg no such color keine solche Farbe +@msg {_1} cannot be used on Windows {_1} kann unter Windows nicht verwendet werden +@msg more than one action is named “{_1}” mehr als eine Aktion heißt „{_1}“ @msg no symbol named `{n}` kein Symbol namens `{n}` Smudge Stick… Wischstift… Smudge Stick Wischstift @@ -5319,6 +5324,13 @@ axis Y Axis Y-Achse Action Options… Aktionsoptionen… Action Options Aktionsoptionen Rename Action Aktion umbenennen + New Action… Neue Aktion… + New Action Neue Aktion + Create New Action Neue Aktion erstellen + Play Action Aktion abspielen + Action Set: Aktionssatz: + Function Key: Funktionstaste: + Record Aufzeichnen Create New Set Neuen Satz erstellen Stop Global Edit Globale Bearbeitung beenden Global Edit Options Optionen für globale Bearbeitung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a0ca7ab17..2a17c0bab 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5518,6 +5518,11 @@ effect Points Σημεία Export Type: Τύπος εξαγωγής: Movie Clip Κλιπ ταινίας @msg name is empty το όνομα είναι κενό +@msg an action is already recording μια ενέργεια καταγράφεται ήδη +@msg no such function key δεν υπάρχει τέτοιο πλήκτρο λειτουργίας +@msg no such color δεν υπάρχει τέτοιο χρώμα +@msg {_1} cannot be used on Windows Το {_1} δεν μπορεί να χρησιμοποιηθεί στα Windows +@msg more than one action is named “{_1}” περισσότερες από μία ενέργειες ονομάζονται «{_1}» @msg no symbol named `{n}` κανένα σύμβολο με όνομα `{n}` Smudge Stick… Μουτζούρα… Smudge Stick Μουτζούρα @@ -5688,6 +5693,13 @@ axis Y Axis Άξονας Y Action Options… Επιλογές ενέργειας… Action Options Επιλογές ενέργειας Rename Action Μετονομασία ενέργειας + New Action… Νέα ενέργεια… + New Action Νέα ενέργεια + Create New Action Δημιουργία νέας ενέργειας + Play Action Αναπαραγωγή ενέργειας + Action Set: Σύνολο ενεργειών: + Function Key: Πλήκτρο λειτουργίας: + Record Καταγραφή Create New Set Δημιουργία νέου συνόλου Stop Global Edit Τερματισμός καθολικής επεξεργασίας Global Edit Options Επιλογές καθολικής επεξεργασίας diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9907c409d..3ca5ebf1f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5508,6 +5508,11 @@ mark-style Japanese Japonesas Export Type: Tipo de exportación: Movie Clip Clip de película @msg name is empty el nombre está vacío +@msg an action is already recording ya se está grabando una acción +@msg no such function key no existe esa tecla de función +@msg no such color no existe ese color +@msg {_1} cannot be used on Windows {_1} no se puede usar en Windows +@msg more than one action is named “{_1}” más de una acción se llama “{_1}” @msg no symbol named `{n}` no hay ningún símbolo llamado `{n}` Smudge Stick… Difuminar con barra… Smudge Stick Difuminar con barra @@ -5678,6 +5683,13 @@ axis Y Axis Eje Y Action Options… Opciones de acción… Action Options Opciones de acción Rename Action Cambiar nombre de acción + New Action… Nueva acción… + New Action Nueva acción + Create New Action Crear nueva acción + Play Action Reproducir acción + Action Set: Conjunto de acciones: + Function Key: Tecla de función: + Record Grabar Create New Set Crear nuevo conjunto Stop Global Edit Detener edición global Global Edit Options Opciones de edición global diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 176498542..78d810c7a 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5530,6 +5530,11 @@ mark-style Japanese Japanilainen Export Type: Vientityyppi: Movie Clip Elokuvaleike @msg name is empty nimi on tyhjä +@msg an action is already recording toimintoa nauhoitetaan jo +@msg no such function key toimintonäppäintä ei ole +@msg no such color väriä ei ole +@msg {_1} cannot be used on Windows {_1} ei ole käytettävissä Windowsissa +@msg more than one action is named “{_1}” useamman kuin yhden toiminnon nimi on ”{_1}” @msg no symbol named `{n}` symbolia nimeltä `{n}` ei ole Smudge Stick… Sumennuskynä… Smudge Stick Sumennuskynä @@ -5767,6 +5772,13 @@ axis Y Axis Y-akseli Action Options… Toiminnon asetukset… Action Options Toiminnon asetukset Rename Action Nimeä toiminto uudelleen + New Action… Uusi toiminto… + New Action Uusi toiminto + Create New Action Luo uusi toiminto + Play Action Toista toiminto + Action Set: Toimintosarja: + Function Key: Toimintonäppäin: + Record Nauhoita Create New Set Luo uusi sarja Stop Global Edit Lopeta yleismuokkaus Global Edit Options Yleismuokkauksen asetukset diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3d4eb8583..d5972a384 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5562,6 +5562,11 @@ theme Appearance Apparence Export Type: Type d'exportation : Movie Clip Clip @msg name is empty le nom est vide +@msg an action is already recording un script est déjà en cours d’enregistrement +@msg no such function key touche de fonction inexistante +@msg no such color couleur inexistante +@msg {_1} cannot be used on Windows {_1} ne peut pas être utilisé sous Windows +@msg more than one action is named “{_1}” plusieurs scripts s’appellent « {_1} » @msg no symbol named `{n}` aucun symbole nommé `{n}` Smudge Stick… Estompe… Smudge Stick Estompe @@ -5732,6 +5737,13 @@ axis Y Axis Axe Y Action Options… Options de script… Action Options Options de script Rename Action Renommer le script + New Action… Nouveau script… + New Action Nouveau script + Create New Action Créer un script + Play Action Exécuter le script + Action Set: Jeu de scripts : + Function Key: Touche de fonction : + Record Enregistrer Create New Set Créer un jeu Stop Global Edit Arrêter la modification globale Global Edit Options Options de modification globale diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7dfdf687c..c9a798077 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5550,6 +5550,11 @@ imageTrace Outline Körvonal Export Type: Exportálás típusa: Movie Clip Mozgóklip @msg name is empty a név üres +@msg an action is already recording már folyik egy művelet felvétele +@msg no such function key nincs ilyen funkcióbillentyű +@msg no such color nincs ilyen szín +@msg {_1} cannot be used on Windows A(z) {_1} nem használható Windows rendszeren +@msg more than one action is named “{_1}” több művelet neve is „{_1}” @msg no symbol named `{n}` nincs `{n}` nevű szimbólum Smudge Stick… Elmosó rúd… Smudge Stick Elmosó rúd @@ -5720,6 +5725,13 @@ axis Y Axis Y tengely Action Options… Művelet beállításai… Action Options Művelet beállításai Rename Action Művelet átnevezése + New Action… Új művelet… + New Action Új művelet + Create New Action Új művelet létrehozása + Play Action Művelet lejátszása + Action Set: Műveletkészlet: + Function Key: Funkcióbillentyű: + Record Felvétel Create New Set Új készlet létrehozása Stop Global Edit Globális szerkesztés befejezése Global Edit Options Globális szerkesztés beállításai diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index b2aa56920..1b315d849 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5360,6 +5360,11 @@ swatch Bright Violet Violet cerah Export Type: Jenis ekspor: Movie Clip Klip film @msg name is empty nama kosong +@msg an action is already recording sebuah tindakan sedang direkam +@msg no such function key tidak ada tombol fungsi seperti itu +@msg no such color tidak ada warna seperti itu +@msg {_1} cannot be used on Windows {_1} tidak dapat digunakan di Windows +@msg more than one action is named “{_1}” lebih dari satu tindakan bernama “{_1}” @msg no symbol named `{n}` tidak ada simbol bernama `{n}` Remove Color Profile… Hapus profil warna… Color wheel Roda warna @@ -5639,6 +5644,13 @@ axis Y Axis Sumbu Y Action Options… Opsi tindakan… Action Options Opsi tindakan Rename Action Ganti nama tindakan + New Action… Tindakan baru… + New Action Tindakan baru + Create New Action Buat tindakan baru + Play Action Putar tindakan + Action Set: Set tindakan: + Function Key: Tombol fungsi: + Record Rekam Create New Set Buat set baru Stop Global Edit Hentikan edit global Global Edit Options Opsi edit global diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 50bf09162..0b07d6769 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5180,6 +5180,11 @@ lighting Light Luce Export Type: Tipo di esportazione: Movie Clip Clip filmato @msg name is empty il nome è vuoto +@msg an action is already recording è già in corso la registrazione di un’azione +@msg no such function key tasto funzione inesistente +@msg no such color colore inesistente +@msg {_1} cannot be used on Windows {_1} non può essere usato su Windows +@msg more than one action is named “{_1}” più di un’azione si chiama “{_1}” @msg no symbol named `{n}` nessun simbolo denominato `{n}` Smudge Stick… Sfumino… Smudge Stick Sfumino @@ -5353,6 +5358,13 @@ axis Y Axis Asse Y Action Options… Opzioni azione… Action Options Opzioni azione Rename Action Rinomina azione + New Action… Nuova azione… + New Action Nuova azione + Create New Action Crea nuova azione + Play Action Esegui azione + Action Set: Set di azioni: + Function Key: Tasto funzione: + Record Registra Create New Set Crea nuovo set Stop Global Edit Interrompi modifica globale Global Edit Options Opzioni modifica globale diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7b0a1e639..de9c692c6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5223,6 +5223,11 @@ lighting Light ライト Export Type: 書き出しタイプ: Movie Clip ムービークリップ @msg name is empty 名前が空です +@msg an action is already recording すでにアクションを記録中です +@msg no such function key 指定されたファンクションキーが見つかりません +@msg no such color 指定されたカラーが見つかりません +@msg {_1} cannot be used on Windows {_1} は Windows では使用できません +@msg more than one action is named “{_1}” 「{_1}」という名前のアクションが複数あります @msg no symbol named `{n}` `{n}` という名前のシンボルはありません Smudge Stick… スマッジスティック… Smudge Stick スマッジスティック @@ -5418,6 +5423,13 @@ axis Y Axis Y 軸 Action Options… アクションオプション… Action Options アクションオプション Rename Action アクション名を変更 + New Action… 新規アクション… + New Action 新規アクション + Create New Action 新規アクションを作成 + Play Action アクションを再生 + Action Set: アクションセット: + Function Key: ファンクションキー: + Record 記録 Create New Set 新規セットを作成 Stop Global Edit グローバル編集を終了 Global Edit Options グローバル編集オプション diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 52bdf7599..0eb9cd390 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5267,6 +5267,11 @@ imageTrace Outline კონტური Export Type: ექსპორტის ტიპი: Movie Clip ანიმაციური კლიპი @msg name is empty სახელი ცარიელია +@msg an action is already recording მოქმედება უკვე იწერება +@msg no such function key ასეთი ფუნქციური კლავიში არ არის +@msg no such color ასეთი ფერი არ არის +@msg {_1} cannot be used on Windows {_1} ვერ გამოიყენება Windows-ში +@msg more than one action is named “{_1}” „{_1}“ ერთზე მეტი მოქმედების სახელია @msg no symbol named `{n}` სიმბოლო სახელით `{n}` არ არის @msg no saved selection recalled შენახული მონიშვნა გამოძახებული არ არის @msg The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft-ის ბოლო გაშვება ფანჯრის გამოჩენამდე შეწყდა (გრაფიკული პროცესორი: {name}), ამიტომ VectorCraft გაეშვა: {summary}. ამ გრაფიკული პროცესორის ხელახლა საცდელად შეცვალეთ პარამეტრები › წარმადობა › გრაფიკული პროცესორი ან გაუშვით WGPU_BACKEND ცვლადის მითითებით @@ -5651,6 +5656,13 @@ axis Y Axis Y ღერძი Action Options… მოქმედების პარამეტრები… Action Options მოქმედების პარამეტრები Rename Action მოქმედების სახელის შეცვლა + New Action… ახალი მოქმედება… + New Action ახალი მოქმედება + Create New Action ახალი მოქმედების შექმნა + Play Action მოქმედების შესრულება + Action Set: მოქმედებების ნაკრები: + Function Key: ფუნქციური კლავიში: + Record ჩაწერა Create New Set ახალი ნაკრების შექმნა Stop Global Edit გლობალური რედაქტირების დასრულება Global Edit Options გლობალური რედაქტირების პარამეტრები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index a36341694..e4c58c1d5 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5141,6 +5141,11 @@ lighting Light 광원 Export Type: 내보내기 유형: Movie Clip 무비 클립 @msg name is empty 이름이 비어 있습니다 +@msg an action is already recording 이미 액션을 기록하고 있습니다 +@msg no such function key 해당 기능 키가 없습니다 +@msg no such color 해당 색상이 없습니다 +@msg {_1} cannot be used on Windows {_1}은(는) Windows에서 사용할 수 없습니다 +@msg more than one action is named “{_1}” “{_1}”(이)라는 이름의 액션이 둘 이상 있습니다 @msg no symbol named `{n}` 이름이 `{n}`인 심볼이 없습니다 Smudge Stick… 문지르기 막대… Smudge Stick 문지르기 막대 @@ -5314,6 +5319,13 @@ axis Y Axis Y축 Action Options… 액션 옵션… Action Options 액션 옵션 Rename Action 액션 이름 바꾸기 + New Action… 새 액션… + New Action 새 액션 + Create New Action 새 액션 만들기 + Play Action 액션 재생 + Action Set: 액션 세트: + Function Key: 기능 키: + Record 기록 Create New Set 새 세트 만들기 Stop Global Edit 전체 편집 중지 Global Edit Options 전체 편집 옵션 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index cc9970da9..af1269a7f 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5368,6 +5368,11 @@ swatch Bright Violet Felviolet Export Type: Exporttype: Movie Clip Filmclip @msg name is empty naam is leeg +@msg an action is already recording er wordt al een actie opgenomen +@msg no such function key functietoets bestaat niet +@msg no such color kleur bestaat niet +@msg {_1} cannot be used on Windows {_1} kan niet worden gebruikt in Windows +@msg more than one action is named “{_1}” meer dan één actie heet „{_1}” @msg no symbol named `{n}` geen symbool met de naam `{n}` Remove Color Profile… Kleurprofiel verwijderen… Color wheel Kleurenwiel @@ -5683,6 +5688,13 @@ axis Y Axis Y-as Action Options… Actieopties… Action Options Actieopties Rename Action Actie hernoemen + New Action… Nieuwe actie… + New Action Nieuwe actie + Create New Action Nieuwe actie maken + Play Action Actie afspelen + Action Set: Actieset: + Function Key: Functietoets: + Record Opnemen Create New Set Nieuwe set maken Stop Global Edit Globale bewerking stoppen Global Edit Options Opties voor globale bewerking diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 14765e9e7..08f91930e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5420,6 +5420,11 @@ mark-style Japanese Japońskie Export Type: Typ eksportu: Movie Clip Klip filmowy @msg name is empty nazwa jest pusta +@msg an action is already recording operacja jest już nagrywana +@msg no such function key brak takiego klawisza funkcyjnego +@msg no such color brak takiego koloru +@msg {_1} cannot be used on Windows Skrótu {_1} nie można używać w systemie Windows +@msg more than one action is named “{_1}” więcej niż jedna operacja nazywa się „{_1}” @msg no symbol named `{n}` brak symbolu o nazwie `{n}` Smudge Stick… Rozmazywacz… Smudge Stick Rozmazywacz @@ -5590,6 +5595,13 @@ axis Y Axis Oś Y Action Options… Opcje operacji… Action Options Opcje operacji Rename Action Zmień nazwę operacji + New Action… Nowa operacja… + New Action Nowa operacja + Create New Action Utwórz nową operację + Play Action Odtwórz operację + Action Set: Zestaw operacji: + Function Key: Klawisz funkcyjny: + Record Nagraj Create New Set Utwórz nowy zestaw Stop Global Edit Zakończ edycję globalną Global Edit Options Opcje edycji globalnej diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 798b3db35..40a253846 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5482,6 +5482,11 @@ mark-style Japanese Japonesas # Symbol Options › Export Type @msg name is empty o nome está vazio +@msg an action is already recording uma ação já está sendo gravada +@msg no such function key tecla de função inexistente +@msg no such color cor inexistente +@msg {_1} cannot be used on Windows {_1} não pode ser usado no Windows +@msg more than one action is named “{_1}” mais de uma ação se chama “{_1}” @msg no symbol named `{n}` nenhum símbolo chamado `{n}` Smudge Stick… Esfuminho… Smudge Stick Esfuminho @@ -5652,6 +5657,13 @@ axis Y Axis Eixo Y Action Options… Opções da Ação… Action Options Opções da Ação Rename Action Renomear Ação + New Action… Nova Ação… + New Action Nova Ação + Create New Action Criar Nova Ação + Play Action Reproduzir Ação + Action Set: Conjunto de Ações: + Function Key: Tecla de Função: + Record Gravar Create New Set Criar Novo Conjunto Stop Global Edit Parar Edição Global Global Edit Options Opções de Edição Global diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 52fc8da78..199d7a06d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5175,6 +5175,11 @@ lighting Light Свет Export Type: Тип экспорта: Movie Clip Видеоклип @msg name is empty имя пустое +@msg an action is already recording действие уже записывается +@msg no such function key такой функциональной клавиши нет +@msg no such color такого цвета нет +@msg {_1} cannot be used on Windows Сочетание {_1} нельзя использовать в Windows +@msg more than one action is named “{_1}” несколько действий называются «{_1}» @msg no symbol named `{n}` нет символа с именем `{n}` Smudge Stick… Растушёвка… Smudge Stick Растушёвка @@ -5348,6 +5353,13 @@ axis Y Axis Ось Y Action Options… Параметры действия… Action Options Параметры действия Rename Action Переименовать действие + New Action… Новое действие… + New Action Новое действие + Create New Action Создать новое действие + Play Action Воспроизвести действие + Action Set: Набор действий: + Function Key: Функциональная клавиша: + Record Записать Create New Set Создать новый набор Stop Global Edit Завершить глобальное редактирование Global Edit Options Параметры глобального редактирования diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 725bad018..8fb160cea 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5522,6 +5522,11 @@ theme Appearance Utseende Export Type: Exporttyp: Movie Clip Filmklipp @msg name is empty namnet är tomt +@msg an action is already recording en åtgärd spelas redan in +@msg no such function key funktionstangenten finns inte +@msg no such color färgen finns inte +@msg {_1} cannot be used on Windows {_1} kan inte användas i Windows +@msg more than one action is named “{_1}” fler än en åtgärd heter ”{_1}” @msg no symbol named `{n}` ingen symbol med namnet `{n}` Angular Vinkelmått Away from the Line Bort från linjen @@ -5760,6 +5765,13 @@ axis Y Axis Y-axel Action Options… Alternativ för åtgärd… Action Options Alternativ för åtgärd Rename Action Byt namn på åtgärd + New Action… Ny åtgärd… + New Action Ny åtgärd + Create New Action Skapa ny åtgärd + Play Action Spela upp åtgärd + Action Set: Åtgärdsuppsättning: + Function Key: Funktionstangent: + Record Spela in Create New Set Skapa ny uppsättning Stop Global Edit Avsluta global redigering Global Edit Options Alternativ för global redigering diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9f7c47f74..fe8244ad8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5232,6 +5232,11 @@ theme Appearance Görünüm Export Type: İhracat Türü: Movie Clip Film Klibi @msg name is empty isim boş +@msg an action is already recording zaten bir eylem kaydediliyor +@msg no such function key öyle bir işlev tuşu yok +@msg no such color öyle bir renk yok +@msg {_1} cannot be used on Windows {_1} Windows'ta kullanılamaz +@msg more than one action is named “{_1}” birden fazla eylemin adı “{_1}” @msg no symbol named `{n}` `{n}` adında bir sembol yok Artboard Options: edit with the Artboard tool Çalışma Yüzeyi Seçenekleri: Çalışma Yüzeyi aracıyla düzenleme @msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG bu henüz açılamayan bir PostScript dosyasıdır (eski bir formatta kaydedilmiş veya PDF uyumluluğu olmayan bir .ai veya bir EPS dosyası): PDF, PDF uyumlu .ai veya SVG olarak kaydedin @@ -5694,6 +5699,13 @@ axis Y Axis Y ekseni Action Options… Eylem Seçenekleri… Action Options Eylem Seçenekleri Rename Action Eylemi Yeniden Adlandır + New Action… Yeni Eylem… + New Action Yeni Eylem + Create New Action Yeni Eylem Oluştur + Play Action Eylemi Oynat + Action Set: Eylem Seti: + Function Key: İşlev Tuşu: + Record Kayıt Create New Set Yeni Set Oluştur Stop Global Edit Genel Düzenlemeyi Durdur Global Edit Options Genel Düzenleme Seçenekleri diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 43c5208dd..b676cbf4d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5160,6 +5160,11 @@ lighting Light Світло Export Type: Тип експорту: Movie Clip Кліп @msg name is empty ім’я порожнє +@msg an action is already recording дія вже записується +@msg no such function key такої функціональної клавіші немає +@msg no such color такого кольору немає +@msg {_1} cannot be used on Windows Сполучення {_1} не можна використовувати у Windows +@msg more than one action is named “{_1}” кілька дій мають назву «{_1}» @msg no symbol named `{n}` немає символу з назвою `{n}` Smudge Stick… Розтушовування… Smudge Stick Розтушовування @@ -5333,6 +5338,13 @@ axis Y Axis Вісь Y Action Options… Параметри дії… Action Options Параметри дії Rename Action Перейменувати дію + New Action… Нова дія… + New Action Нова дія + Create New Action Створити нову дію + Play Action Відтворити дію + Action Set: Набір дій: + Function Key: Функціональна клавіша: + Record Записати Create New Set Створити новий набір Stop Global Edit Завершити глобальне редагування Global Edit Options Параметри глобального редагування diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 49b89fb6f..1575decd9 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5161,6 +5161,11 @@ theme Appearance Giao diện Export Type: Kiểu xuất: Movie Clip Clip phim @msg name is empty tên trống +@msg an action is already recording đang ghi một hành động +@msg no such function key không có phím chức năng như vậy +@msg no such color không có màu như vậy +@msg {_1} cannot be used on Windows không thể dùng {_1} trên Windows +@msg more than one action is named “{_1}” có nhiều hành động tên là “{_1}” @msg no symbol named `{n}` không có biểu tượng nào tên `{n}` Smudge Stick… Bút chì xóa mờ… Smudge Stick Bút chì xóa mờ @@ -5334,6 +5339,13 @@ axis Y Axis Trục Y Action Options… Tùy chọn hành động… Action Options Tùy chọn hành động Rename Action Đổi tên hành động + New Action… Hành động mới… + New Action Hành động mới + Create New Action Tạo hành động mới + Play Action Phát hành động + Action Set: Bộ hành động: + Function Key: Phím chức năng: + Record Ghi Create New Set Tạo bộ mới Stop Global Edit Dừng chỉnh sửa toàn cục Global Edit Options Tùy chọn chỉnh sửa toàn cục diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 372a0681d..204aee9b7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5400,6 +5400,11 @@ lighting Light 光源 Export Type: 导出类型: Movie Clip 影片剪辑 @msg name is empty 名称为空 +@msg an action is already recording 已在记录动作 +@msg no such function key 没有此功能键 +@msg no such color 没有此颜色 +@msg {_1} cannot be used on Windows {_1} 在 Windows 上不能使用 +@msg more than one action is named “{_1}” 有多个动作名为“{_1}” @msg no symbol named `{n}` 没有名为 `{n}` 的符号 Smudge Stick… 涂抹棒… Smudge Stick 涂抹棒 @@ -5575,6 +5580,13 @@ axis Y Axis Y 轴 Action Options… 动作选项… Action Options 动作选项 Rename Action 重命名动作 + New Action… 新建动作… + New Action 新建动作 + Create New Action 创建新动作 + Play Action 播放动作 + Action Set: 动作组: + Function Key: 功能键: + Record 记录 Create New Set 创建新组 Stop Global Edit 停止全局编辑 Global Edit Options 全局编辑选项 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ac1629201..23b253f0e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5217,6 +5217,11 @@ lighting Light 光源 Export Type: 轉存類型: Movie Clip 影片片段 @msg name is empty 名稱為空 +@msg an action is already recording 已在錄製動作 +@msg no such function key 沒有這個功能鍵 +@msg no such color 沒有這個顏色 +@msg {_1} cannot be used on Windows {_1} 無法在 Windows 上使用 +@msg more than one action is named “{_1}” 有多個動作名為「{_1}」 @msg no symbol named `{n}` 沒有名為 `{n}` 的符號 Smudge Stick… 塗抹棒… Smudge Stick 塗抹棒 @@ -5371,6 +5376,13 @@ axis Y Axis Y 軸 Action Options… 動作選項… Action Options 動作選項 Rename Action 重新命名動作 + New Action… 新增動作… + New Action 新增動作 + Create New Action 建立新動作 + Play Action 播放動作 + Action Set: 動作組: + Function Key: 功能鍵: + Record 錄製 Create New Set 建立新組 Stop Global Edit 停止整體編輯 Global Edit Options 整體編輯選項 diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index b788cd58b..d97d09222 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -815,6 +815,10 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::package::open(app), crate::panels::actions::RENAME_ACTION => crate::panels::actions::rename_action(app, p), + crate::panels::actions::NEW_ACTION => crate::panels::actions::new_action(app, p), + crate::panels::actions::ACTION_OPTIONS => crate::panels::actions::action_options(app, p), + crate::panels::actions::PLAY_ACTION => crate::panels::actions::play_action(app, p), + crate::panels::actions::STOP_RECORDING => crate::panels::actions::stop(app, p), "ui.batchDialog" => { let set = p.get("set").and_then(Value::as_u64).unwrap_or(0) as usize; let action = p.get("action").and_then(Value::as_u64).unwrap_or(0) as usize; diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 97b7926e8..9e887870a 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1180,7 +1180,31 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::RENAME_ACTION, "Rename Action", "", - "{set: index, action: index, name} rename the Actions panel's action at action in set (0 is the top set) to name, with spaces trimmed from both ends; its steps stay. The panel menu's Action Options… (dialog `actionSetOptions` with set, action and name) runs this for the selected action. An index past the last set or action, or an empty name, is an error and renames nothing → {name}", + "{set: index, action: index, name} rename the Actions panel's action at action in set (0 is the top set) to name, with spaces trimmed from both ends; its steps, function key and color stay. An index past the last set or action, or an empty name, is an error and renames nothing → {name}", + ), + ( + crate::panels::actions::NEW_ACTION, + "New Action", + "", + "{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, or a key or color that is not one of these, it is an error and nothing is added → {set, action}", + ), + ( + crate::panels::actions::ACTION_OPTIONS, + "Action Options", + "", + "{set: index, action: index, name?, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} change the Actions panel's action at action in set (0 is the top set); its steps stay. name renames it (spaces trimmed from both ends), key 1 to 15 with cmd (Ctrl, Command on macOS) and shift sets the function key F1 to F15 that plays it (0 or null: none), and color sets its Button Mode color (null or \"\": none); a param left out keeps what the action has, and cmd or shift alone changes that modifier of its key. Action function keys take priority over command shortcuts and preserve the command's binding. The panel menu's Action Options… (dialog `actionSetOptions` with set, action, name, key, cmd, shift and color) runs this for the selected action. An index past the last set or action, an empty name, or a key or color that is not one of these is an error and changes nothing → {name, key, color}", + ), + ( + crate::panels::actions::PLAY_ACTION, + "Play Action", + "", + "{action: name, set?: name} play the Actions panel's action named action as one undoable batch; set names the set to look in (default: every set). A name no action has, or one that more than one action in the sets looked in has, is an error and nothing plays → what command.batch returns", + ), + ( + crate::panels::actions::STOP_RECORDING, + "Stop Playing/Recording", + "", + "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. With nothing recording it changes nothing → {stopped}", ), ( "ui.batchDialog", diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 30a95705c..174fab9bc 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -16,8 +16,116 @@ pub use files::{ACTIONS_EXT, LOAD_SET, SAVE_SET, load_set, save_set}; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Action { + /// Tells the action apart from every other action in this run, also one with the same name: + /// the recording New Action starts fills the action with this id wherever it is. It is not + /// saved: each action gets a new id when it is made, copied or loaded. + #[serde(skip, default = "next_action_id")] + pub id: u64, pub name: String, pub steps: Vec<(String, Value)>, + /// The function key that plays the action when shortcuts are read, if one is assigned. A + /// saved value that is not a function key loads as none. + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_key")] + pub key: Option, + /// The action's Button Mode color, one of [`COLORS`], if one is assigned. The panel does not + /// show it. A saved value that is not one of them loads as none. + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_color")] + pub color: Option, +} + +impl Action { + /// An action with a new [`Action::id`], no function key and no color. + pub fn new(name: impl Into, steps: Vec<(String, Value)>) -> Self { + Self { id: next_action_id(), name: name.into(), steps, key: None, color: None } + } + + /// A copy of the action with a new [`Action::id`] and no function key, so the key still plays + /// only the original. + pub fn copy(&self) -> Self { + Self { id: next_action_id(), key: None, ..self.clone() } + } +} + +/// An action id no other action in this run has. +fn next_action_id() -> u64 { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +/// The highest function key an action can play with: F1 to F15. +pub const FUNCTION_KEYS: u8 = 15; + +/// A function key that plays an action: F1 to F15, alone or with Ctrl (Command on macOS), Shift, +/// or both. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FunctionKey { + /// 1 to [`FUNCTION_KEYS`]: F1 to F15. + pub key: u8, + /// Held with Ctrl, Command on macOS. + #[serde(default)] + pub cmd: bool, + #[serde(default)] + pub shift: bool, +} + +impl FunctionKey { + /// The key as shortcut text (`Cmd+Shift+F5`). + fn shortcut(self) -> String { + format!("{}{}F{}", if self.cmd { "Cmd+" } else { "" }, if self.shift { "Shift+" } else { "" }, self.key) + } + + /// The chord the key is pressed as; none when `key` is not 1 to [`FUNCTION_KEYS`]. + pub fn chord(self) -> Option { + (1..=FUNCTION_KEYS).contains(&self.key).then(|| crate::keymap::vc_chord(&self.shortcut())).flatten() + } + + /// The key as the menus show shortcuts on this platform (`Ctrl+Shift+F5`, `⇧⌘F5`). + pub fn text(self) -> String { + crate::menus::pretty_shortcut(&self.shortcut()) + } + + /// The function key that params `p` give: `key` (1 to 15, F1 to F15; 0 or null for none), `cmd` + /// and `shift`. `Ok(None)` for no key; an error for a `key` that is not one of them. + pub fn from_params(p: &Value) -> Result, String> { + match p.get("key") { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_u64() { + Some(0) => Ok(None), + Some(n) if n <= u64::from(FUNCTION_KEYS) => { + let flag = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); + Ok(Some(Self { key: n as u8, cmd: flag("cmd"), shift: flag("shift") })) + } + _ => Err("no such function key".into()), + }, + } + } +} + +/// The Button Mode colors an action can have, as (value, label). +pub const COLORS: [(&str, &str); 5] = [("red", "Red"), ("yellow", "Yellow"), ("green", "Green"), ("blue", "Blue"), ("violet", "Violet")]; + +/// Serde reader for [`Action::key`]: a value that is not a function key is none. +fn deserialize_key<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let v = ::deserialize(d)?; + Ok(serde_json::from_value::(v).ok().filter(|k| k.chord().is_some())) +} + +/// Serde reader for [`Action::color`]: a value that is not one of [`COLORS`] is none. +fn deserialize_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let v = ::deserialize(d)?; + Ok(v.as_str().filter(|c| COLORS.iter().any(|(id, _)| id == c)).map(str::to_string)) +} + +/// The color that params `p` give: `color`, one of [`COLORS`], or null or `""` for none. +fn color_param(p: &Value) -> Result, String> { + match p.get("color") { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_str() { + Some("") => Ok(None), + Some(c) if COLORS.iter().any(|(id, _)| *id == c) => Ok(Some(c.to_string())), + _ => Err("no such color".into()), + }, + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -57,18 +165,15 @@ pub fn default_sets() -> Vec { vec![ActionSet::new( "Default Actions", vec![ - Action { name: "Duplicate and Offset 10 pt".into(), steps: vec![step("object.move", json!({"dx": 10, "dy": 10, "copy": true}))] }, - Action { - name: "Unite and Simplify".into(), - steps: vec![step("object.pathfinder.unite", json!({})), step("object.path.simplify", json!({"tolerance": 0.5}))], - }, - Action { - name: "Center on Artboard".into(), - steps: vec![step("object.align", json!({"horizontal": "center", "vertical": "center", "to": "artboard"}))], - }, - Action { name: "Outline Text".into(), steps: vec![step("type.createOutlines", json!({}))] }, - Action { name: "Rotate 90° CW".into(), steps: vec![step("object.rotate", json!({"angle": -90}))] }, - Action { name: "Reflect Horizontal".into(), steps: vec![step("object.reflect", json!({"axis": "vertical"}))] }, + Action::new("Duplicate and Offset 10 pt", vec![step("object.move", json!({"dx": 10, "dy": 10, "copy": true}))]), + Action::new( + "Unite and Simplify", + vec![step("object.pathfinder.unite", json!({})), step("object.path.simplify", json!({"tolerance": 0.5}))], + ), + Action::new("Center on Artboard", vec![step("object.align", json!({"horizontal": "center", "vertical": "center", "to": "artboard"}))]), + Action::new("Outline Text", vec![step("type.createOutlines", json!({}))]), + Action::new("Rotate 90° CW", vec![step("object.rotate", json!({"angle": -90}))]), + Action::new("Reflect Horizontal", vec![step("object.reflect", json!({"axis": "vertical"}))]), ], )] } @@ -84,9 +189,9 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result Result { let index = |k: &str| p.get(k).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); let name = p.get("name").and_then(Value::as_str).map(str::trim).unwrap_or_default(); @@ -98,6 +203,255 @@ pub fn rename_action(app: &mut VectorcraftApp, p: &Value) -> Result Result { + if app.ui.recording.is_some() { + return Err("an action is already recording".into()); + } + let len = app.ui.action_sets.len(); + let set = p.get("set").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).filter(|&s| s < len).ok_or("no such action set")?; + let name = p.get("name").and_then(Value::as_str).map(str::trim).unwrap_or_default().to_string(); + if name.is_empty() { + return Err("name is empty".into()); + } + let key = FunctionKey::from_params(p)?; + let color = color_param(p)?; + if let Some(message) = key.and_then(key_error) { + return Err(message); + } + let action = Action { key, color, ..Action::new(name.clone(), vec![]) }; + let id = action.id; + let Some(s) = app.ui.action_sets.get_mut(set) else { return Err("no such action set".into()) }; + s.actions.push(action); + let index = s.actions.len() - 1; + start_recording(app, set, name, Some(id)); + app.ui.action_set = Some(set); + app.ui.action_sets_selected = vec![set]; + if let Some(ctx) = app.ui_ctx.clone() { + set_pstate::>(&ctx, "actions-selected", Some((set, index))); + } + Ok(json!({ "set": set, "action": index })) +} + +/// The command that changes an action's name, function key and color ([`action_options`]). +pub const ACTION_OPTIONS: &str = "ui.action.options"; + +/// `ui.action.options {set, action, name?, key?, cmd?, shift?, color?}`: changes the +/// action at index `action` of set `set` (0 is the top set) and keeps its steps. `name` renames it +/// (spaces trimmed from both ends), `key` (1 to 15, F1 to F15; 0 or null for none) with `cmd` +/// (Ctrl, Command on macOS) and `shift` sets the function key that plays it, and `color` (one of +/// [`COLORS`]; null or `""` for none) its Button Mode color; a param left out keeps what the action +/// has, and `cmd` or `shift` alone changes that modifier of the action's key. The action's function +/// key takes priority over command shortcuts. An index past the last set or action, an empty name, +/// or a key or color that is not one of them is an error, and then nothing changes. The panel +/// menu's Action Options… runs this for the selected action. → {name, key, color} +pub fn action_options(app: &mut VectorcraftApp, p: &Value) -> Result { + let index = |k: &str| p.get(k).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + let (set, at) = index("set").zip(index("action")).ok_or("no such action")?; + let had = app.ui.action_sets.get(set).and_then(|s| s.actions.get(at)).map(|a| a.key).ok_or("no such action")?; + let name = match p.get("name") { + None | Some(Value::Null) => None, + Some(v) => match v.as_str().map(str::trim).unwrap_or_default() { + "" => return Err("name is empty".into()), + n => Some(n.to_string()), + }, + }; + // `cmd` and `shift` left out keep the modifiers of the action's key. + let flag = |k: &str, kept: bool| p.get(k).and_then(Value::as_bool).unwrap_or(kept); + let with_mods = + |k: FunctionKey| FunctionKey { cmd: flag("cmd", had.is_some_and(|h| h.cmd)), shift: flag("shift", had.is_some_and(|h| h.shift)), ..k }; + let key = if p.get("key").is_some() { + Some(FunctionKey::from_params(p)?.map(with_mods)) + } else if p.get("cmd").is_some() || p.get("shift").is_some() { + had.map(|h| Some(with_mods(h))) + } else { + None + }; + let color = if p.get("color").is_some() { Some(color_param(p)?) } else { None }; + if let Some(Some(k)) = key + && had != Some(k) + && let Some(message) = key_error(k) + { + return Err(message); + } + let Some(action) = app.ui.action_sets.get_mut(set).and_then(|s| s.actions.get_mut(at)) else { return Err("no such action".into()) }; + if let Some(name) = name { + action.name = name; + } + if let Some(key) = key { + action.key = key; + } + if let Some(color) = color { + action.color = color; + } + Ok(json!({ "name": action.name, "key": action.key, "color": action.color })) +} + +/// The command that plays an action by its name ([`play_action`]). +pub const PLAY_ACTION: &str = "ui.action.play"; + +/// `ui.action.play {action, set?}`: plays the action named `action` as one undoable batch, as +/// Play Current Selection does. `set` names the set to look in (default: every set); a `set` that +/// is not a string is an error. A name that no action has is an error, and so is a name that more +/// than one action in the sets looked in has: then nothing plays. → what `command.batch` returns +pub fn play_action(app: &mut VectorcraftApp, p: &Value) -> Result { + let name = p.get("action").and_then(Value::as_str).ok_or("no such action")?; + let in_set = match p.get("set") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_str().ok_or("no such action set")?), + }; + let found: Vec<(usize, usize)> = app + .ui + .action_sets + .iter() + .enumerate() + .filter(|(_, s)| in_set.is_none_or(|n| s.name == n)) + .flat_map(|(si, s)| s.actions.iter().enumerate().filter(|(_, a)| a.name == name).map(move |(ai, _)| (si, ai))) + .collect(); + match found[..] { + [] => Err("no such action".into()), + [(s, a)] => play(app, s, a), + _ => Err(format!("more than one action is named “{}”", name)), + } +} + +/// The command that ends a recording ([`stop`]). +pub const STOP_RECORDING: &str = "ui.action.stop"; + +/// `ui.action.stop {}`: ends the recording in progress and keeps its steps, in the action New +/// Action made for it, else in a new action after the last action of its set, as the bottom bar's +/// Stop Playing/Recording does. With nothing recording it changes nothing. → {stopped} +pub fn stop(app: &mut VectorcraftApp, _: &Value) -> Result { + let stopped = app.ui.recording.is_some(); + stop_recording(app); + Ok(json!({ "stopped": stopped })) +} + +/// The command that `chord` runs under `km`, pressed once or pressed twice. +#[cfg(test)] +fn command_on(km: &craft_keymap::Keymap, chord: &craft_keymap::Chord) -> Option { + crate::keymap::lookup(km, chord).or_else(|| match km.lookup(chord, true, &craft_keymap::Active::NONE) { + craft_keymap::Lookup::Run(c) => Some(c), + craft_keymap::Lookup::Cycle(v) => v.into_iter().next(), + _ => None, + }) +} + +/// The function returns an error message if the function key is invalid or reserved on this platform. +pub fn key_error(key: FunctionKey) -> Option { + let Some(chord) = key.chord() else { return Some("no such function key".into()) }; + if crate::keymap::platform() == craft_keymap::Platform::Windows && (key.key == 1 || key.cmd && !key.shift && matches!(key.key, 4 | 6)) { + return Some(format!("{} cannot be used on Windows", key.text())); + } + if crate::keymap::template().is_reserved(&chord) { + return Some(format!("{} is reserved by the system", key.text())); + } + None +} + +fn playable_chord(key: FunctionKey) -> Option { + key_error(key).is_none().then(|| key.chord()).flatten() +} + +/// Starts recording into set `set`: Stop Playing/Recording adds an action named `name` with the +/// steps, or, with `into`, gives them to the action with that id. +fn start_recording(app: &mut VectorcraftApp, set: usize, name: String, into: Option) { + let start = app.session.journal.len(); + // Keep every step from here on until recording stops, however long it runs. + app.session.journal.hold(Some(start)); + app.ui.recording = Some((set, name, start)); + app.ui.recording_into = into; +} + +/// Stop Playing/Recording: ends the recording and keeps its steps (see [`start_recording`]). An +/// action New Action made that was deleted while it recorded keeps nothing. +fn stop_recording(app: &mut VectorcraftApp) { + let Some((set, name, start)) = app.ui.recording.take() else { return }; + // A canceled undo group numbers the steps down when it drops entries made before them. + let start = app.session.journal.held().unwrap_or(start); + let steps = app.session.journal_for_action(start); + app.session.journal.hold(None); + match app.ui.recording_into.take() { + Some(id) => { + if let Some(a) = app.ui.action_sets.iter_mut().flat_map(|s| s.actions.iter_mut()).find(|a| a.id == id) { + a.steps.extend(steps); + } + } + None => { + if let Some(s) = app.ui.action_sets.get_mut(set) { + s.actions.push(Action::new(name, steps)); + } + } + } +} + +crate::shortcut_editor::ui_mirror!(fn function_key_chords() -> Vec); + +/// Mirrors the actions' function keys for code without app access (cheap when unchanged). +pub fn sync_function_keys(ui: &crate::UiState) { + let chords: Vec = ui.action_sets.iter().flat_map(|s| s.actions.iter().filter_map(|a| playable_chord(a.key?))).collect(); + if function_key_chords().read().is_ok_and(|v| *v != chords) + && let Ok(mut v) = function_key_chords().write() + { + *v = chords; + } +} + +/// Whether `chord` plays an action, by the function keys [`sync_function_keys`] last mirrored. +pub(crate) fn plays_an_action(chord: &craft_keymap::Chord) -> bool { + let platform = crate::keymap::platform(); + let chord = chord.effective(platform); + function_key_chords().read().is_ok_and(|v| v.iter().any(|c| c.effective(platform) == chord)) +} + +/// Plays each action whose function key was pressed this frame, in the order of the presses and +/// before any command with the same shortcut, and takes those key presses. The frame's other +/// events stay for the command, panel and tool shortcuts. +pub fn play_function_key(app: &mut VectorcraftApp, ctx: &egui::Context) { + let keys: Vec<(craft_keymap::Chord, (usize, usize))> = app + .ui + .action_sets + .iter() + .enumerate() + .flat_map(|(si, s)| s.actions.iter().enumerate().filter_map(move |(ai, a)| Some((playable_chord(a.key?)?, (si, ai))))) + .collect(); + if keys.is_empty() { + return; + } + let platform = crate::keymap::platform(); + let hits: Vec<(usize, usize)> = ctx.input_mut(|i| { + let mut hits = Vec::new(); + i.events.retain(|e| { + let hit = match e { + egui::Event::Key { key, pressed: true, modifiers, .. } => craft_keymap::Key::from_egui(*key) + .map(|k| craft_keymap::Chord::new(craft_keymap::Mods::from_egui(modifiers, platform), k)) + .and_then(|c| keys.iter().find(|(k, _)| *k == c).map(|(_, at)| *at)), + _ => None, + }; + if !matches!(e, egui::Event::Key { repeat: true, .. }) { + hits.extend(hit); + } + hit.is_none() + }); + hits + }); + for (set, action) in hits { + if let Err(e) = play(app, set, action) { + app.status(e); + } + } +} + /// The command that deletes action sets ([`delete_sets`]). pub const DELETE_SETS: &str = "ui.actionSet.delete"; @@ -143,8 +497,8 @@ fn delete_selection(app: &mut VectorcraftApp, ctx: &egui::Context) { /// ([`ActionSet::id`]), which Delete Selection's question passes: the sets with these ids are /// deleted wherever they are now, `sets` is not read, and when one of them no longer exists /// nothing is deleted. A recording into a deleted set stops, and its steps are dropped; a -/// recording into another set goes on. An open Set Options or Batch dialog for a deleted set -/// closes. When the current set is deleted, no set is current afterwards. +/// recording into another set goes on. An open Set Options, New Action or Batch dialog for a +/// deleted set closes. When the current set is deleted, no set is current afterwards. pub fn delete_sets(app: &mut VectorcraftApp, p: &Value) -> Result { let len = app.ui.action_sets.len(); let mut sets: Vec = match (p.get("ids"), p.get("sets")) { @@ -169,13 +523,14 @@ pub fn delete_sets(app: &mut VectorcraftApp, p: &Value) -> Result if let Some((rec, ..)) = app.ui.recording.as_mut() { if sets.contains(rec) { app.ui.recording = None; + app.ui.recording_into = None; app.session.journal.hold(None); } else { *rec -= sets.iter().filter(|&&s| s < *rec).count(); } } - // An open Set Options or Batch dialog holds a set index: it closes when its set is deleted, - // and otherwise follows its set down. + // An open Set Options, New Action or Batch dialog holds a set index: it closes when its set is + // deleted, and otherwise follows its set down. if let Some(d) = app.ui.dialog.as_mut().filter(|d| holds_set(d)) && let Some(set) = d.fields.get("set").and_then(Value::as_u64).and_then(|s| usize::try_from(s).ok()) { @@ -203,9 +558,12 @@ pub fn delete_sets(app: &mut VectorcraftApp, p: &Value) -> Result Ok(json!({ "deleted": sets.len() })) } -/// Whether dialog `d` is a Set Options or Batch dialog that holds a set index in its `set` field. +/// Whether dialog `d` is a Set Options, New Action or Batch dialog that holds a set index in its +/// `set` field. fn holds_set(d: &crate::state::Dialog) -> bool { - d.kind == crate::dialogs::action_batch::KIND || (d.kind == crate::dialogs::action_set_options::KIND && d.fields.contains_key("set")) + d.kind == crate::dialogs::action_batch::KIND + || d.kind == crate::dialogs::action_new::KIND + || (d.kind == crate::dialogs::action_set_options::KIND && d.fields.contains_key("set")) } /// The command that duplicates an action or an action set ([`duplicate`]). @@ -234,8 +592,9 @@ fn duplicate_selection(app: &mut VectorcraftApp, ctx: &egui::Context) { /// of set `set` into the same set, right after the original; without `action`, copies set `set` /// with all its actions to right after the original set (default: the current set). The copy is /// named after the original with " copy" added, and a copied set gets an id of its own -/// ([`ActionSet::id`]). The copy is selected and its set is the current one. A recording and an -/// open Set Options or Batch dialog keep to their own set and action. → {set, action?} +/// ([`ActionSet::id`]). Copied actions have no function key, so a key plays only the original. +/// The copy is selected and its set is the current one. A recording and an open Set Options or +/// Batch dialog keep to their own set and action. → {set, action?} pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result { const SELECTED: &str = "actions-selected"; let len = app.ui.action_sets.len(); @@ -250,7 +609,7 @@ pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result { Some(v) => Some(index(v).filter(|&a| a < src.actions.len()).ok_or("no such action")?), }; let (copy, result) = if let Some(a) = action { - let Some(mut copy) = src.actions.get(a).cloned() else { return Err("no such action".into()) }; + let Some(mut copy) = src.actions.get(a).map(Action::copy) else { return Err("no such action".into()) }; copy.name = format!("{} copy", copy.name); src.actions.insert(a + 1, copy); // A Batch dialog open on a later action of the set follows it down. @@ -262,7 +621,7 @@ pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result { } (Some((set, a + 1)), json!({ "set": set, "action": a + 1 })) } else { - let copy = ActionSet::new(format!("{} copy", src.name), src.actions.clone()); + let copy = ActionSet::new(format!("{} copy", src.name), src.actions.iter().map(Action::copy).collect()); app.ui.action_sets.insert(set + 1, copy); if let Some((rec, ..)) = app.ui.recording.as_mut() && *rec > set @@ -291,6 +650,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut egui::Ui) { // deleted since leaves the stored pair pointing at another set's action. let selected = pstate::>(ui.ctx(), "actions-selected").filter(|&(s, _)| app.ui.action_set == Some(s)); let has = selected.and_then(|(s, a)| app.ui.action_sets.get(s).and_then(|set| set.actions.get(a))).is_some(); + if widgets::menu_item(ui, tl!("New Action…"), crate::dialogs::action_new::can_open(app), false) + && let Err(e) = crate::dialogs::action_new::open(app) + { + app.status(e); + } if widgets::menu_item(ui, tl!("New Set…"), true, false) { crate::dialogs::action_set_options::open_new(app); } @@ -412,19 +776,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { app.ui.action_set = current; app.ui.action_sets_selected = sets_selected; widgets::divider(ui); - // Bottom bar: stop, record, play, new set, delete. + // Bottom bar: stop, record, play, new set, new action, delete. ui.horizontal(|ui| { let recording = app.ui.recording.is_some(); if widgets::icon_button(ui, "square", tl!("Stop Playing/Recording"), false, 24.0).clicked() - && let Some((set, name, start)) = app.ui.recording.take() + && let Err(e) = app.run(STOP_RECORDING, json!({})) { - // A canceled undo group numbers the steps down when it drops entries made before them. - let start = app.session.journal.held().unwrap_or(start); - let steps = app.session.journal_for_action(start); - app.session.journal.hold(None); - if let Some(s) = app.ui.action_sets.get_mut(set) { - s.actions.push(Action { name, steps }); - } + app.status(e); } let (r, resp) = ui.allocate_exact_size(egui::vec2(24.0, 24.0), egui::Sense::click()); ui.painter().circle_filled(r.center(), 6.0, if recording { egui::Color32::from_rgb(0xe0, 0x30, 0x30) } else { t.icon }); @@ -440,10 +798,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { app.ui.action_sets_selected = vec![set]; } let n = app.ui.action_sets.get(set).map_or(0, |s| s.actions.len()) + 1; - let start = app.session.journal.len(); - // Keep every step from here on until recording stops, however long it runs. - app.session.journal.hold(Some(start)); - app.ui.recording = Some((set, format!("Action {n}"), start)); + start_recording(app, set, format!("Action {n}"), None); } if widgets::icon_button(ui, "dc-actions", tl!("Play Current Selection"), false, 24.0).clicked() && let Some(s) = selected @@ -453,6 +808,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { if widgets::icon_button(ui, "dc-folder", tl!("Create New Set"), false, 24.0).clicked() { crate::dialogs::action_set_options::open_new(app); } + if widgets::icon_button(ui, "dc-new-item", tl!("Create New Action"), false, 24.0).clicked() + && let Err(e) = crate::dialogs::action_new::open(app) + { + app.status(e); + } if widgets::icon_button(ui, "trash-2", tl!("Delete Selection"), false, 24.0).clicked() { delete_selection(app, ui.ctx()); } @@ -501,10 +861,11 @@ mod tests { click(&ctx, &mut app, text_rect(&texts, "Reflect Horizontal").center(), show); frame_events(&ctx, &mut app, vec![], show); assert_eq!(pstate::>(&ctx, "actions-selected"), Some((0, 5)), "selected on the frames after the click"); - // The bottom bar's 24-point buttons, left to right: stop, record, play, new set, delete. + // The bottom bar's 24-point buttons, left to right: stop, record, play, new set, new action, + // delete. let mut bar = crate::toolbar::tests::widget_rects(&ctx, egui::vec2(24.0, 24.0)); bar.sort_by(|a, b| a.left().total_cmp(&b.left())); - let [_, _, play, _, delete] = bar[..] else { panic!("the bottom bar: {bar:?}") }; + let [_, _, play, _, _, delete] = bar[..] else { panic!("the bottom bar: {bar:?}") }; click(&ctx, &mut app, text_rect(&texts, "Duplicate and Offset 10 pt").center(), show); click(&ctx, &mut app, play.center(), show); assert_eq!(app.session.active().unwrap().selection.len(), 1); @@ -526,13 +887,13 @@ mod tests { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); let ctx = egui::Context::default(); - // The bottom bar's 24-point buttons, left to right: stop, record, play, new set, delete. A new - // set or action moves the bar down. + // The bottom bar's 24-point buttons, left to right: stop, record, play, new set, new action, + // delete. A new set or action moves the bar down. let bar = |ctx: &egui::Context, app: &mut VectorcraftApp| { frame_events(ctx, app, vec![], show); let mut bar = crate::toolbar::tests::widget_rects(ctx, egui::vec2(24.0, 24.0)); bar.sort_by(|a, b| a.left().total_cmp(&b.left())); - let [stop, record, _, new_set, _] = bar[..] else { panic!("the bottom bar: {bar:?}") }; + let [stop, record, _, new_set, _, _] = bar[..] else { panic!("the bottom bar: {bar:?}") }; (stop, record, new_set) }; let (_, _, new_set) = bar(&ctx, &mut app); @@ -667,7 +1028,7 @@ mod tests { fn action_options_is_off_after_a_set_above_the_selection_is_deleted() { use super::super::tests_appearance::{click, frame_events}; let mut app = app_with_sets(); - let action = |name: &str| Action { name: name.into(), steps: vec![] }; + let action = |name: &str| Action::new(name, vec![]); app.ui.action_sets[2].actions.push(action("Spin")); app.ui.action_sets.push(ActionSet::new("Last", vec![action("Wrong")])); let ctx = egui::Context::default(); @@ -698,13 +1059,13 @@ mod tests { app.ui.action_sets.push(ActionSet::new("Last", vec![])); app.ui.action_set = Some(2); set_pstate::>(&ctx, "actions-selected", Some((2, 0))); - app.ui.action_sets[2].actions.push(Action { name: "Spin".into(), steps: vec![] }); + app.ui.action_sets[2].actions.push(Action::new("Spin", vec![])); app.run(DELETE_SETS, json!({"sets": [2]})).unwrap(); assert_eq!(selected(&ctx), None, "the deleted set's action is no longer selected"); crate::dialogs::action_set_options::open_new(&mut app); crate::dialogs::confirm(&mut app).unwrap(); assert_eq!(app.ui.action_set, Some(2), "the new set is current, at the deleted set's index"); - app.ui.action_sets[2].actions.push(Action { name: "Recorded".into(), steps: vec![] }); + app.ui.action_sets[2].actions.push(Action::new("Recorded", vec![])); let texts = frame_events(&ctx, &mut app, vec![], menu); let item = texts.iter().find(|(s, _)| s.trim() == "Action Options…").map(|(_, r)| *r).expect("Action Options… in the menu"); click(&ctx, &mut app, item.center(), menu); @@ -714,7 +1075,7 @@ mod tests { /// An app with the default set and the sets Mine (two actions) and Other. fn app_with_sets() -> VectorcraftApp { let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let action = |name: &str| Action { name: name.into(), steps: vec![("object.move".into(), json!({"dx": 1, "dy": 0}))] }; + let action = |name: &str| Action::new(name, vec![("object.move".into(), json!({"dx": 1, "dy": 0}))]); app.ui.action_sets.push(ActionSet::new("Mine", vec![action("Nudge"), action("Nudge Again")])); app.ui.action_sets.push(ActionSet::new("Other", vec![])); app @@ -724,12 +1085,12 @@ mod tests { app.ui.action_sets.iter().map(|s| s.name.clone()).collect() } - /// The bottom bar's Delete Selection button (stop, record, play, new set, delete). + /// The bottom bar's Delete Selection button (stop, record, play, new set, new action, delete). fn trash(ctx: &egui::Context, app: &mut VectorcraftApp) -> egui::Pos2 { super::super::tests_appearance::frame_events(ctx, app, vec![], show); let mut bar = crate::toolbar::tests::widget_rects(ctx, egui::vec2(24.0, 24.0)); bar.sort_by(|a, b| a.left().total_cmp(&b.left())); - let [_, _, _, _, delete] = bar[..] else { panic!("the bottom bar: {bar:?}") }; + let [_, _, _, _, _, delete] = bar[..] else { panic!("the bottom bar: {bar:?}") }; delete.center() } @@ -871,7 +1232,7 @@ mod tests { fn ok_deletes_only_the_asked_one_of_two_sets_with_the_same_name() { use super::super::tests_appearance::click; let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let x = |n: &str| ActionSet::new("X", vec![Action { name: n.into(), steps: vec![] }]); + let x = |n: &str| ActionSet::new("X", vec![Action::new(n, vec![])]); app.ui.action_sets = vec![x("first"), x("second")]; let ctx = egui::Context::default(); click_set(&ctx, &mut app, "X", egui::Modifiers::NONE); @@ -943,7 +1304,7 @@ mod tests { frame_events(&ctx, &mut app, vec![], show); let mut bar = crate::toolbar::tests::widget_rects(&ctx, egui::vec2(24.0, 24.0)); bar.sort_by(|a, b| a.left().total_cmp(&b.left())); - let [stop, record, _, _, _] = bar[..] else { panic!("the bottom bar: {bar:?}") }; + let [stop, record, _, _, _, _] = bar[..] else { panic!("the bottom bar: {bar:?}") }; click(&ctx, &mut app, record.center(), show); app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); click(&ctx, &mut app, stop.center(), show); @@ -970,7 +1331,7 @@ mod tests { frame_events(&ctx, &mut app, vec![], show); let mut bar = crate::toolbar::tests::widget_rects(&ctx, egui::vec2(24.0, 24.0)); bar.sort_by(|a, b| a.left().total_cmp(&b.left())); - let [stop, record, _, _, _] = bar[..] else { panic!("the bottom bar: {bar:?}") }; + let [stop, record, _, _, _, _] = bar[..] else { panic!("the bottom bar: {bar:?}") }; click(&ctx, &mut app, record.center(), show); assert_eq!((app.ui.action_set, app.ui.action_sets_selected.clone()), (Some(0), vec![0]), "the set recorded into is current"); app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); @@ -1171,10 +1532,11 @@ mod tests { } let ctx = egui::Context::default(); frame_events(&ctx, &mut app, vec![], show); - // The bottom bar's 24-point buttons, left to right: stop, record, play, new set, delete. + // The bottom bar's 24-point buttons, left to right: stop, record, play, new set, new action, + // delete. let mut bar = crate::toolbar::tests::widget_rects(&ctx, egui::vec2(24.0, 24.0)); bar.sort_by(|a, b| a.left().total_cmp(&b.left())); - let [stop, record, _, _, _] = bar[..] else { panic!("the bottom bar: {bar:?}") }; + let [stop, record, _, _, _, _] = bar[..] else { panic!("the bottom bar: {bar:?}") }; click(&ctx, &mut app, record.center(), show); for (id, p) in [("document.activate", json!({"index": 1})), ("shape.rectangle", rect(50)), ("edit.undoGroup.end", json!({"cancel": true}))] { app.run(id, p).unwrap(); @@ -1200,7 +1562,7 @@ mod tests { let start = app.session.journal.len(); app.run(id, params).unwrap(); let steps = app.session.journal_for_action(start); - app.ui.action_sets[0].actions.push(Action { name: name.into(), steps }); + app.ui.action_sets[0].actions.push(Action::new(name, steps)); app.ui.action_sets[0].actions.len() - 1 }; app.run("edit.copy", json!({})).unwrap(); @@ -1249,7 +1611,7 @@ mod tests { let start = app.session.journal.len(); app.run(id, params).unwrap(); let steps = app.session.journal_for_action(start); - app.ui.action_sets[0].actions.push(Action { name: name.into(), steps }); + app.ui.action_sets[0].actions.push(Action::new(name, steps)); app.ui.action_sets[0].actions.len() - 1 }; let (x0, x1) = (boards[0].x0, boards[0].x1); @@ -1374,7 +1736,7 @@ mod tests { fn save_actions_does_not_write_a_set_load_actions_refuses() { let mut app = app_with_sets(); let files = with_files(&mut app, "/sets/none.vcactions"); - app.ui.action_sets[2].actions = vec![Action { name: "A".into(), steps: vec![] }; 1001]; + app.ui.action_sets[2].actions = vec![Action::new("A", vec![]); 1001]; let r = app.run(SAVE_SET, json!({"set": 2, "path": "/sets/big.vcactions"})); assert_eq!(r.unwrap_err(), "the action set file holds more than 1,000 actions"); assert!(files.borrow().is_empty(), "nothing written"); @@ -1396,4 +1758,559 @@ mod tests { assert_eq!(v["name"], json!("Other"), "the set chosen, not the current one"); assert_eq!(app.run(SAVE_SET, json!({"id": 0, "set": 0, "path": "/sets/y.vcactions"})).unwrap_err(), "no such action set"); } + + /// The bottom bar's stop and Create New Action buttons (stop, record, play, new set, new + /// action, delete). + fn stop_and_new_action(ctx: &egui::Context, app: &mut VectorcraftApp) -> (egui::Pos2, egui::Pos2) { + super::super::tests_appearance::frame_events(ctx, app, vec![], show); + let mut bar = crate::toolbar::tests::widget_rects(ctx, egui::vec2(24.0, 24.0)); + bar.sort_by(|a, b| a.left().total_cmp(&b.left())); + let [stop, _, _, _, new_action, _] = bar[..] else { panic!("the bottom bar: {bar:?}") }; + (stop.center(), new_action.center()) + } + + /// The texts the dialog layer draws once the open dialog has settled. + fn dialog_texts(app: &mut VectorcraftApp) -> Vec { + let ctx = egui::Context::default(); + let mut texts = vec![]; + for _ in 0..3 { + texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], |app, ui| crate::dialogs::show(app, ui.ctx())); + } + texts.into_iter().map(|(t, _)| t).collect() + } + + /// The Create New Action button and the panel menu's New Action… both open New Action with a + /// suggested name in the current set, no function key and no color; Cancel adds nothing and + /// starts no recording. Record with another set chosen adds the action there, selects it and + /// records into it, and Stop keeps exactly the steps recorded in that action; Record and Stop + /// with nothing done in between keep an empty action. + #[test] + fn new_action_asks_for_a_name_and_set_and_records_into_the_new_action() { + use super::super::tests_appearance::{click, frame_events}; + let mut app = app_with_sets(); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + click_set(&ctx, &mut app, "Mine", egui::Modifiers::NONE); + let (_, new_action) = stop_and_new_action(&ctx, &mut app); + click(&ctx, &mut app, new_action, show); + let d = app.ui.dialog.as_ref().expect("Create New Action opens New Action"); + assert_eq!(d.kind, crate::dialogs::action_new::KIND); + let fields = (d.fields["set"].clone(), d.str("name"), d.fields["key"].clone(), d.str("color")); + assert_eq!(fields, (json!(1), "Action 3".to_string(), json!(0), String::new()), "the current set, a free name, no key or color"); + crate::dialogs::cancel(&mut app); + let counts = |app: &VectorcraftApp| app.ui.action_sets.iter().map(|s| s.actions.len()).collect::>(); + assert_eq!(counts(&app), [6, 2, 0], "Cancel adds nothing"); + assert!(app.ui.recording.is_none(), "and records nothing"); + let texts = frame_events(&ctx, &mut app, vec![], menu); + let item = texts.iter().find(|(s, _)| s.trim() == "New Action…").map(|(_, r)| *r).expect("New Action… in the menu"); + click(&ctx, &mut app, item.center(), menu); + let d = app.ui.dialog.as_mut().expect("New Action… opens New Action"); + assert_eq!(d.kind, crate::dialogs::action_new::KIND); + d.fields.insert("set".into(), json!(2)); + d.fields.insert("name".into(), json!(" Box ")); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(action_names(&app, 2), ["Box"], "added to the set chosen, with the name trimmed"); + let selected = pstate::>(&ctx, "actions-selected"); + assert_eq!((app.ui.action_set, selected), (Some(2), Some((2, 0))), "its set is current and it is selected"); + assert!(app.ui.recording.is_some(), "Record starts recording"); + assert!(frame_events(&ctx, &mut app, vec![], show).iter().any(|(t, _)| t == "● Recording"), "the panel shows the recording"); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let (stop, _) = stop_and_new_action(&ctx, &mut app); + click(&ctx, &mut app, stop, show); + assert!(app.ui.recording.is_none()); + let steps: Vec = app.ui.action_sets[2].actions[0].steps.iter().map(|(c, _)| c.clone()).collect(); + assert_eq!(steps, ["shape.rectangle"], "the recorded steps, in the new action"); + assert_eq!(counts(&app), [6, 2, 1], "Stop adds no other action"); + app.run(NEW_ACTION, json!({"set": 2, "name": "Empty"})).unwrap(); + let (stop, _) = stop_and_new_action(&ctx, &mut app); + click(&ctx, &mut app, stop, show); + assert_eq!(action_names(&app, 2), ["Box", "Empty"]); + assert!(app.ui.action_sets[2].actions[1].steps.is_empty(), "nothing recorded: an empty action"); + } + + /// New Action shows Name, Action Set, Function Key with Ctrl (Command on macOS) and Shift, Color and Record. + #[test] + fn new_action_shows_its_fields() { + let mut app = app_with_sets(); + crate::dialogs::action_new::open(&mut app).unwrap(); + let texts = dialog_texts(&mut app); + for label in ["New Action", "Name:", "Action Set:", "Default Actions", "Function Key:", "Shift", "Color:", "None", "Record", "Cancel"] { + assert!(texts.iter().any(|t| t == label), "{label}: {texts:?}"); + } + assert!(!texts.iter().any(|t| t == "Replace")); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "edit.duplicate", "Shift+F14"); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("key".into(), json!(14)); + d.fields.insert("shift".into(), json!(true)); + let texts = dialog_texts(&mut app); + assert!(!texts.iter().any(|s| s == "Replace" || s.contains("already used")), "{texts:?}"); + } + + /// `ui.action.new` refuses an index past the last set, an empty name, a key or color that is + /// not one, and a second recording, and then adds nothing. New Action does not open without a + /// set or while recording, follows its set when an earlier set is deleted, and closes when its + /// set is deleted. On Windows F1, Ctrl+F4 and Ctrl+F6 are refused. + #[test] + fn new_action_refuses_bad_params_and_adds_nothing() { + let mut app = app_with_sets(); + let counts = |app: &VectorcraftApp| app.ui.action_sets.iter().map(|s| s.actions.len()).collect::>(); + for (p, want) in [ + (json!({"set": 3, "name": "A"}), "no such action set"), + (json!({"name": "A"}), "no such action set"), + (json!({"set": 1, "name": " "}), "name is empty"), + (json!({"set": 1}), "name is empty"), + (json!({"set": 1, "name": "A", "key": 16}), "no such function key"), + (json!({"set": 1, "name": "A", "key": "F5"}), "no such function key"), + (json!({"set": 1, "name": "A", "color": "pink"}), "no such color"), + ] { + assert_eq!(app.run(NEW_ACTION, p.clone()).unwrap_err(), want, "{p}"); + } + if crate::keymap::platform() == craft_keymap::Platform::Windows { + for (key, cmd) in [(1, false), (1, true), (4, true), (6, true)] { + let want = format!("{} cannot be used on Windows", FunctionKey { key, cmd, shift: false }.text()); + assert_eq!(app.run(NEW_ACTION, json!({"set": 1, "name": "A", "key": key, "cmd": cmd})).unwrap_err(), want); + } + } + assert_eq!(counts(&app), [6, 2, 0], "nothing added"); + assert!(app.ui.recording.is_none(), "nothing recording"); + // The dialog follows its set down, and closes with it. + crate::dialogs::action_new::open(&mut app).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("set".into(), json!(2)); + app.run(DELETE_SETS, json!({"sets": [1]})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields["set"].clone()), Some(json!(1)), "New Action follows Other"); + app.run(DELETE_SETS, json!({"sets": [1]})).unwrap(); + assert!(app.ui.dialog.is_none(), "New Action closes with its set"); + crate::dialogs::action_new::open(&mut app).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("set".into(), json!(9)); + assert!(crate::dialogs::confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some(), "a set that is gone: the dialog stays open"); + app.ui.dialog = None; + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run(NEW_ACTION, json!({"set": 0, "name": "A"})).unwrap(); + assert_eq!(app.run(NEW_ACTION, json!({"set": 0, "name": "B"})).unwrap_err(), "an action is already recording"); + assert_eq!(crate::dialogs::action_new::open(&mut app).unwrap_err(), "an action is already recording"); + assert!(!crate::dialogs::action_new::can_open(&app)); + assert_eq!(counts(&app), [7], "one action added"); + app.ui.action_sets.clear(); + app.ui.recording = None; + assert_eq!(crate::dialogs::action_new::open(&mut app).unwrap_err(), "no such action set"); + assert!(app.ui.dialog.is_none()); + } + + /// A recording that New Action started goes into its action wherever the action is when Stop + /// is pressed, and is dropped when the action was deleted meanwhile. + #[test] + fn a_new_actions_recording_follows_its_action() { + let mut app = app_with_sets(); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + app.run(NEW_ACTION, json!({"set": 2, "name": "Box"})).unwrap(); + app.run(DELETE_SETS, json!({"sets": [1]})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let (stop, _) = stop_and_new_action(&ctx, &mut app); + super::super::tests_appearance::click(&ctx, &mut app, stop, show); + assert_eq!((action_names(&app, 1), app.ui.action_sets[1].actions[0].steps.len()), (vec!["Box".to_string()], 1)); + app.run(NEW_ACTION, json!({"set": 1, "name": "Gone"})).unwrap(); + app.ui.action_sets[1].actions.pop(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let (stop, _) = stop_and_new_action(&ctx, &mut app); + super::super::tests_appearance::click(&ctx, &mut app, stop, show); + assert_eq!(action_names(&app, 1), ["Box"], "the deleted action is not made again"); + assert!(app.ui.recording.is_none() && app.ui.recording_into.is_none()); + // `ui.action.stop` ends a recording as the button does. + assert_eq!(app.run(STOP_RECORDING, json!({})).unwrap(), json!({"stopped": false}), "nothing recording"); + app.run(NEW_ACTION, json!({"set": 1, "name": "Remote"})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + assert_eq!(app.run(STOP_RECORDING, json!({})).unwrap(), json!({"stopped": true})); + assert_eq!((action_names(&app, 1), app.ui.action_sets[1].actions[1].steps.len()), (vec!["Box".to_string(), "Remote".into()], 1)); + assert!(app.ui.recording.is_none()); + } + + #[test] + fn shortcut_precedence_assignment_preserves_command_bindings() { + let mut app = app_with_sets(); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "edit.duplicate", "Shift+F14"); + let before = app.ui.shortcut_overrides.clone(); + let set = app.ui.shortcut_set.clone(); + app.run(ACTION_OPTIONS, json!({"set": 1, "action": 0, "key": 14, "shift": true})).unwrap(); + assert_eq!(app.ui.shortcut_overrides, before); + assert_eq!(app.ui.shortcut_set, set); + let ctx = egui::Context::default(); + let event = egui::Event::Key { key: egui::Key::F14, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::SHIFT }; + let mut output = ctx.run_ui(egui::RawInput { events: vec![event], ..Default::default() }, |ui| crate::shortcuts::handle(&mut app, ui.ctx())); + output.textures_delta.clear(); + assert_eq!(app.session.active().unwrap().history.undo.last().unwrap().label, "Nudge"); + assert_eq!(app.ui.shortcut_overrides, before); + let undo = app.session.active().unwrap().history.undo.len(); + let repeated = egui::Event::Key { key: egui::Key::F14, physical_key: None, pressed: true, repeat: true, modifiers: egui::Modifiers::SHIFT }; + let mut output = + ctx.run_ui(egui::RawInput { events: vec![repeated], ..Default::default() }, |ui| crate::shortcuts::handle(&mut app, ui.ctx())); + output.textures_delta.clear(); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + app.run(NEW_ACTION, json!({"set": 2, "name": "Another", "key": 14, "shift": true})).unwrap(); + assert_eq!(app.ui.shortcut_overrides, before); + assert_eq!(app.ui.shortcut_set, set); + } + + #[test] + fn shortcut_precedence_action_options_does_not_offer_replacement() { + let mut app = app_with_sets(); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "edit.duplicate", "Shift+F14"); + crate::dialogs::action_set_options::open_action(&mut app, 1, 0); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("key".into(), json!(14)); + d.fields.insert("shift".into(), json!(true)); + let texts = dialog_texts(&mut app); + assert!(!texts.iter().any(|s| s == "Replace" || s.contains("already used")), "{texts:?}"); + } + + #[test] + fn shortcut_precedence_duplicate_keys_play_the_first_action_once() { + let mut app = app_with_sets(); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let key = Some(FunctionKey { key: 15, cmd: false, shift: false }); + app.ui.action_sets[1].actions[0].key = key; + app.ui.action_sets[1].actions[1].key = key; + let before = app.session.active().unwrap().history.undo.len(); + let ctx = egui::Context::default(); + let event = egui::Event::Key { key: egui::Key::F15, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let mut output = ctx.run_ui(egui::RawInput { events: vec![event], ..Default::default() }, |ui| crate::shortcuts::handle(&mut app, ui.ctx())); + output.textures_delta.clear(); + let history = &app.session.active().unwrap().history.undo; + assert_eq!(history.len(), before + 1); + assert_eq!(history.last().unwrap().label, "Nudge"); + } + + #[test] + fn shortcut_precedence_preferences_and_files_retain_action_keys() { + let mut app = app_with_sets(); + let key = FunctionKey { key: 14, cmd: false, shift: true }; + app.ui.action_sets[1].actions[0].key = Some(key); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "edit.duplicate", "Shift+F14"); + let saved = serde_json::to_value(&app.ui).unwrap(); + let restored = serde_json::from_value::(saved).unwrap().sanitized(); + assert_eq!(restored.action_sets[1].actions[0].key, Some(key)); + let _files = with_files(&mut app, "/sets/Mine.vcactions"); + app.run(SAVE_SET, json!({"set": 1, "path": "/sets/Mine.vcactions"})).unwrap(); + app.run(LOAD_SET, json!({"path": "/sets/Mine.vcactions"})).unwrap(); + assert_eq!(app.ui.action_sets.last().unwrap().actions[0].key, Some(key)); + } + + #[test] + fn shortcut_precedence_applies_while_the_type_tool_edits() { + let mut app = app_with_sets(); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 50, "text": "Hi"})).unwrap(); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(app.session.tool_wants_text()); + let key = FunctionKey { key: 15, cmd: true, shift: false }; + app.ui.action_sets[1].actions[0].key = Some(key); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "edit.duplicate", "Cmd+F15"); + let before = app.session.active().unwrap().history.undo.len(); + let ctx = egui::Context::default(); + let event = egui::Event::Key { key: egui::Key::F15, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }; + let mut output = ctx.run_ui(egui::RawInput { events: vec![event], ..Default::default() }, |ui| crate::shortcuts::handle(&mut app, ui.ctx())); + output.textures_delta.clear(); + let history = &app.session.active().unwrap().history.undo; + assert_eq!(history.len(), before + 1); + assert_eq!(history.last().unwrap().label, "Nudge"); + } + + #[test] + fn shortcut_precedence_command_edits_retain_action_keys() { + let mut app = app_with_sets(); + let key = FunctionKey { key: 15, cmd: false, shift: false }; + app.ui.action_sets[1].actions[0].key = Some(key); + app.run("shortcuts.set", json!({"id": "edit.duplicate", "shortcut": "F15"})).unwrap(); + assert_eq!(app.ui.action_sets[1].actions[0].key, Some(key)); + crate::shortcut_editor::open(&mut app); + let mut overrides = app.ui.shortcut_overrides.clone(); + crate::keymap::set_override(&mut overrides, "edit.duplicate", "Shift+F14"); + app.ui.dialog.as_mut().unwrap().fields.insert("overrides".into(), crate::keymap::stored::to_value(&overrides)); + crate::shortcut_editor::confirm(&mut app).unwrap(); + assert_eq!(app.ui.action_sets[1].actions[0].key, Some(key)); + } + + /// Action function keys take priority over command shortcuts and keep both assignments. + #[test] + fn function_keys_play_their_action_and_preserve_other_assignments() { + let mut app = app_with_sets(); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let p = |set: usize, action: usize, k: FunctionKey| json!({"set": set, "action": action, "key": k.key, "cmd": k.cmd, "shift": k.shift}); + let keys = |app: &VectorcraftApp| app.ui.action_sets[1].actions.iter().map(|a| a.key).collect::>(); + let f15 = FunctionKey { key: 15, cmd: false, shift: false }; + app.run(ACTION_OPTIONS, p(1, 0, f15)).unwrap(); + assert_eq!(keys(&app), [Some(f15), None]); + app.run(ACTION_OPTIONS, p(1, 1, f15)).unwrap(); + app.run(NEW_ACTION, json!({"set": 2, "name": "New", "key": 15})).unwrap(); + stop_recording(&mut app); + assert_eq!(keys(&app), [Some(f15), Some(f15)]); + assert_eq!(app.ui.action_sets[2].actions[0].key, Some(f15)); + let shift_f14 = FunctionKey { key: 14, cmd: false, shift: true }; + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "edit.duplicate", "Shift+F14"); + app.run(ACTION_OPTIONS, p(1, 0, shift_f14)).unwrap(); + let km = |app: &VectorcraftApp| crate::keymap::keymap_with(&app.ui.shortcut_overrides); + assert!(crate::keymap::lookup(&km(&app), &shift_f14.chord().unwrap()).is_some(), "the command keeps its shortcut"); + assert_eq!(keys(&app), [Some(shift_f14), Some(f15)]); + // The keys play their actions. + app.ui.action_sets[1].actions[1].steps = vec![("shape.rectangle".into(), json!({"x": 10, "y": 10, "width": 20, "height": 20}))]; + let ctx = egui::Context::default(); + let press = |app: &mut VectorcraftApp, key, modifiers| { + let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx())); + out.textures_delta.clear(); + }; + let last = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().map(|s| s.label.clone()); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let before = undo(&app); + press(&mut app, egui::Key::F15, egui::Modifiers::NONE); + assert_eq!((last(&app), undo(&app)), (Some("Nudge Again".to_string()), before + 1), "F15 plays Nudge Again"); + let id = app.session.active().unwrap().selection.objects.clone(); + let x = |app: &VectorcraftApp| app.session.active().unwrap().doc.bounds_of(&id, false).unwrap().x0; + let before = x(&app); + press(&mut app, egui::Key::F14, egui::Modifiers::SHIFT); + assert_eq!((last(&app), x(&app)), (Some("Nudge".to_string()), before + 1.0), "Shift+F14 plays Nudge"); + let before = undo(&app); + press(&mut app, egui::Key::F14, egui::Modifiers::NONE); + assert_eq!(undo(&app), before, "F14 without Shift plays nothing"); + app.run("shortcuts.set", json!({"id": "edit.duplicate", "shortcut": "F15"})).unwrap(); + assert_eq!(keys(&app), [Some(shift_f14), Some(f15)]); + crate::shortcut_editor::open(&mut app); + let mut ov = app.ui.shortcut_overrides.clone(); + crate::keymap::set_override(&mut ov, "edit.duplicate", "Shift+F14"); + app.ui.dialog.as_mut().unwrap().fields.insert("overrides".into(), crate::keymap::stored::to_value(&ov)); + crate::shortcut_editor::confirm(&mut app).unwrap(); + assert_eq!(keys(&app), [Some(shift_f14), Some(f15)]); + } + + /// Off macOS, an action's Cmd function key and a command's Ctrl shortcut share the chord. + #[test] + fn an_action_keeps_a_commands_ctrl_shortcut_for_a_cmd_function_key() { + if crate::keymap::platform().is_mac() { + return; + } + let mut app = app_with_sets(); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "edit.duplicate", "Ctrl+F13"); + let cmd_f13 = FunctionKey { key: 13, cmd: true, shift: false }; + let km = |app: &VectorcraftApp| crate::keymap::keymap_with(&app.ui.shortcut_overrides); + assert!(crate::keymap::lookup(&km(&app), &cmd_f13.chord().unwrap()).is_some(), "Ctrl+F13 runs Duplicate"); + app.run(ACTION_OPTIONS, json!({"set": 1, "action": 0, "key": 13, "cmd": true, "shift": false})).unwrap(); + assert!(crate::keymap::lookup(&km(&app), &cmd_f13.chord().unwrap()).is_some(), "the command keeps its shortcut"); + assert_eq!(app.ui.action_sets[1].actions[0].key, Some(cmd_f13)); + } + + /// An action's function key leaves a command's double-press shortcut assigned. + #[test] + fn a_double_press_shortcut_keeps_the_function_key() { + let mut app = app_with_sets(); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "edit.duplicate", "F15 F15"); + let f15 = FunctionKey { key: 15, cmd: false, shift: false }; + let km = |app: &VectorcraftApp| crate::keymap::keymap_with(&app.ui.shortcut_overrides); + assert!(command_on(&km(&app), &f15.chord().unwrap()).is_some(), "F15 pressed twice runs Duplicate"); + app.run(ACTION_OPTIONS, json!({"set": 1, "action": 0, "key": 15})).unwrap(); + assert!(command_on(&km(&app), &f15.chord().unwrap()).is_some(), "the command keeps its shortcut"); + assert_eq!(app.ui.action_sets[1].actions[0].key, Some(f15)); + } + + /// The web build keeps an action's Ctrl or Cmd function key from the browser. + #[test] + fn an_action_key_runs_a_shortcut_for_the_web_build() { + let mut app = app_with_sets(); + let press = || crate::shortcuts::runs_a_shortcut("F13", "F13", egui::Modifiers::COMMAND); + sync_function_keys(&app.ui); + assert!(!press(), "Ctrl+F13 runs nothing"); + app.ui.action_sets[1].actions[0].key = Some(FunctionKey { key: 13, cmd: true, shift: false }); + sync_function_keys(&app.ui); + assert!(press(), "Ctrl+F13 plays Nudge"); + } + + /// Every action key pressed in one frame plays its action, and a command shortcut pressed in + /// the same frame still runs. + #[test] + fn action_keys_and_a_command_shortcut_in_one_frame_all_run() { + let mut app = app_with_sets(); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + app.ui.action_sets[1].actions[0].key = Some(FunctionKey { key: 15, cmd: false, shift: false }); + app.ui.action_sets[1].actions[1].key = Some(FunctionKey { key: 14, cmd: false, shift: false }); + app.run("shortcuts.set", json!({"id": "edit.duplicate", "shortcut": "F13"})).unwrap(); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.iter().map(|s| s.label.clone()).collect::>(); + let before = undo(&app).len(); + let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let events = vec![key(egui::Key::F15), key(egui::Key::F14), key(egui::Key::F13)]; + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| crate::shortcuts::handle(&mut app, ui.ctx())); + out.textures_delta.clear(); + assert_eq!(undo(&app)[before..], ["Nudge", "Nudge Again", "Duplicate"], "both actions played, then Duplicate ran"); + } + + /// `ui.action.play` plays an action by its name, in the set named when one is, and refuses a + /// name that more than one action has, or that none has. + #[test] + fn play_action_by_name_refuses_a_name_more_than_one_action_has() { + let mut app = app_with_sets(); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + app.ui.action_sets[2].actions.push(Action::new("Nudge", vec![("object.move".into(), json!({"dx": 5, "dy": 0}))])); + let id = app.session.active().unwrap().selection.objects.clone(); + let x = |app: &VectorcraftApp| app.session.active().unwrap().doc.bounds_of(&id, false).unwrap().x0; + let before = x(&app); + assert_eq!(app.run(PLAY_ACTION, json!({"action": "Nudge"})).unwrap_err(), "more than one action is named “Nudge”"); + assert_eq!(app.run(PLAY_ACTION, json!({"action": "Nope"})).unwrap_err(), "no such action"); + assert_eq!(app.run(PLAY_ACTION, json!({"action": "Nudge", "set": "Missing"})).unwrap_err(), "no such action"); + assert_eq!(app.run(PLAY_ACTION, json!({})).unwrap_err(), "no such action"); + assert_eq!(app.run(PLAY_ACTION, json!({"action": "Nudge Again", "set": 0})).unwrap_err(), "no such action set"); + assert_eq!(x(&app), before, "nothing played"); + app.run(PLAY_ACTION, json!({"action": "Nudge", "set": "Other"})).unwrap(); + assert_eq!(x(&app), before + 5.0, "Other's Nudge played"); + app.run(PLAY_ACTION, json!({"action": "Nudge Again"})).unwrap(); + assert_eq!(x(&app), before + 6.0, "a name one action has plays without a set"); + } + + /// Action Options shows the action's function key and color; OK sets them and keeps the name's + /// steps, Cancel keeps them as they were. A rename keeps them, and a copy has no key. + #[test] + fn action_options_sets_the_function_key_and_color_and_keeps_the_steps() { + use crate::dialogs::action_set_options::open_action; + let mut app = app_with_sets(); + open_action(&mut app, 1, 1); + let texts = dialog_texts(&mut app); + for label in ["Action Options", "Name:", "Function Key:", "Shift", "Color:"] { + assert!(texts.iter().any(|t| t == label), "{label}: {texts:?}"); + } + let set = |app: &mut VectorcraftApp| { + let d = app.ui.dialog.as_mut().expect("Action Options is open"); + assert_eq!((d.fields["key"].clone(), d.bool("shift"), d.str("color")), (json!(0), false, String::new())); + d.fields.insert("key".into(), json!(15)); + d.fields.insert("shift".into(), json!(true)); + d.fields.insert("color".into(), json!("green")); + }; + set(&mut app); + crate::dialogs::cancel(&mut app); + let a = |app: &VectorcraftApp, i: usize| app.ui.action_sets[1].actions[i].clone(); + assert_eq!((a(&app, 1).key, a(&app, 1).color), (None, None), "Cancel keeps them"); + open_action(&mut app, 1, 1); + set(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + let shift_f15 = FunctionKey { key: 15, cmd: false, shift: true }; + assert_eq!((a(&app, 1).name, a(&app, 1).key, a(&app, 1).color), ("Nudge Again".to_string(), Some(shift_f15), Some("green".to_string()))); + assert_eq!(a(&app, 1).steps, [("object.move".to_string(), json!({"dx": 1, "dy": 0}))], "the steps stay"); + open_action(&mut app, 1, 1); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.fields["key"].clone(), d.bool("shift"), d.str("color")), (json!(15), true, "green".to_string()), "shown when it opens again"); + assert!(dialog_texts(&mut app).iter().any(|t| t == "Green")); + app.ui.dialog = None; + app.run(RENAME_ACTION, json!({"set": 1, "action": 1, "name": "Push"})).unwrap(); + assert_eq!((a(&app, 1).key, a(&app, 1).color.as_deref()), (Some(shift_f15), Some("green")), "a rename keeps them"); + app.run(DUPLICATE, json!({"set": 1, "action": 1})).unwrap(); + assert_eq!( + (a(&app, 2).name.as_str(), a(&app, 2).key, a(&app, 2).color.as_deref()), + ("Push copy", None, Some("green")), + "the copy has no key" + ); + assert_ne!(a(&app, 2).id, a(&app, 1).id); + assert_eq!(a(&app, 1).key, Some(shift_f15), "the original keeps it"); + app.run(ACTION_OPTIONS, json!({"set": 1, "action": 1, "key": 0, "color": null})).unwrap(); + assert_eq!((a(&app, 1).name.as_str(), a(&app, 1).key, a(&app, 1).color.clone()), ("Push", None, None), "cleared; the name stays"); + app.run(ACTION_OPTIONS, json!({"set": 1, "action": 1, "key": 15, "cmd": true, "shift": true})).unwrap(); + app.run(ACTION_OPTIONS, json!({"set": 1, "action": 1, "key": 14})).unwrap(); + assert_eq!(a(&app, 1).key, Some(FunctionKey { key: 14, cmd: true, shift: true }), "a new key keeps the modifiers left out"); + app.run(ACTION_OPTIONS, json!({"set": 1, "action": 1, "shift": false})).unwrap(); + assert_eq!(a(&app, 1).key, Some(FunctionKey { key: 14, cmd: true, shift: false }), "shift alone changes Shift"); + app.run(ACTION_OPTIONS, json!({"set": 1, "action": 2, "shift": true})).unwrap(); + assert_eq!(a(&app, 2).key, None, "a modifier alone gives no key"); + for (p, want) in [ + (json!({"set": 1, "action": 9}), "no such action"), + (json!({"set": 1, "action": 1, "name": " "}), "name is empty"), + (json!({"set": 1, "action": 1, "color": "pink"}), "no such color"), + (json!({"set": 1, "action": 1, "key": 99}), "no such function key"), + ] { + assert_eq!(app.run(ACTION_OPTIONS, p.clone()).unwrap_err(), want, "{p}"); + } + } + + /// Function keys and colors are saved with the UI preferences and in action set files; older + /// preferences and files without them, or with values that are not one, load none. + #[test] + fn function_keys_and_colors_are_kept_with_the_preferences_and_in_set_files() { + let mut app = app_with_sets(); + let k = FunctionKey { key: 13, cmd: true, shift: true }; + app.run(ACTION_OPTIONS, json!({"set": 1, "action": 0, "key": 13, "cmd": true, "shift": true, "color": "violet"})).unwrap(); + let saved = serde_json::to_value(&app.ui).unwrap(); + let first = &saved["action_sets"][1]["actions"][0]; + assert_eq!((first["key"].clone(), first["color"].clone()), (json!({"key": 13, "cmd": true, "shift": true}), json!("violet"))); + assert!(saved["action_sets"][1]["actions"][1].get("key").is_none(), "no key: nothing written"); + let restored = serde_json::from_value::(saved.clone()).unwrap(); + let a = &restored.action_sets[1].actions[0]; + assert_eq!((a.key, a.color.as_deref()), (Some(k), Some("violet"))); + let mut odd = saved.clone(); + odd["action_sets"][1]["actions"][0]["key"] = json!("F13"); + odd["action_sets"][1]["actions"][0]["color"] = json!("pink"); + let restored = serde_json::from_value::(odd).unwrap(); + let a = &restored.action_sets[1].actions[0]; + assert_eq!((a.name.as_str(), a.key, a.color.clone()), ("Nudge", None, None), "values that are not one load as none"); + let files = with_files(&mut app, "/sets/Mine.vcactions"); + app.run(SAVE_SET, json!({"set": 1, "path": "/sets/Mine.vcactions"})).unwrap(); + let v: Value = serde_json::from_slice(&files.borrow()["/sets/Mine.vcactions"]).unwrap(); + assert_eq!( + (v["version"].clone(), v["actions"][0]["key"].clone(), v["actions"][0]["color"].clone()), + (json!(1), first["key"].clone(), json!("violet")) + ); + app.run(LOAD_SET, json!({"path": "/sets/Mine.vcactions"})).unwrap(); + let loaded = &app.ui.action_sets[3].actions[0]; + assert_eq!((loaded.key, loaded.color.as_deref()), (Some(k), Some("violet"))); + app.run(DELETE_SETS, json!({"sets": [1, 3]})).unwrap(); + app.run(LOAD_SET, json!({"path": "/sets/Mine.vcactions"})).unwrap(); + assert_eq!(app.ui.action_sets[2].actions[0].key, Some(k), "a free key loads with its action"); + let plain = json!({"format": "vcactions", "version": 1, "name": "P", "actions": [{"name": "A", "steps": []}]}); + app.run(LOAD_SET, json!({"data": plain.to_string()})).unwrap(); + let a = &app.ui.action_sets[3].actions[0]; + assert_eq!((a.key, a.color.clone()), (None, None), "a file without them loads none"); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "edit.duplicate", "Cmd+Shift+F13"); + app.run(DELETE_SETS, json!({"sets": [2]})).unwrap(); + app.run(LOAD_SET, json!({"path": "/sets/Mine.vcactions"})).unwrap(); + let a = &app.ui.action_sets.last().unwrap().actions[0]; + assert_eq!((a.key, a.color.as_deref()), (Some(k), Some("violet"))); + } + + /// Saved function keys survive loading; reserved keys cannot play actions on this platform. + #[test] + fn loaded_preferences_keep_function_keys_and_skip_reserved_keys_at_playback() { + let app = app_with_sets(); + let mut saved = serde_json::to_value(&app.ui).unwrap(); + let key = |k: u8, cmd: bool, shift: bool| json!({"key": k, "cmd": cmd, "shift": shift}); + for (set, action, k) in [ + (1, 0, key(13, false, false)), + (1, 1, key(13, false, false)), + (0, 0, key(1, false, false)), + (0, 1, key(12, false, true)), + (0, 2, key(4, true, false)), + ] { + saved["action_sets"][set]["actions"][action]["key"] = k; + } + let mut overrides = app.ui.shortcut_overrides.clone(); + crate::keymap::set_override(&mut overrides, "edit.duplicate", "Shift+F12"); + saved["shortcut_overrides"] = crate::keymap::stored::to_value(&overrides); + let ui = serde_json::from_value::(saved).unwrap().sanitized(); + let keys = |set: usize| ui.action_sets[set].actions.iter().map(|a| a.key).collect::>(); + let f13 = FunctionKey { key: 13, cmd: false, shift: false }; + assert_eq!(keys(1), [Some(f13), Some(f13)]); + let windows = crate::keymap::platform() == craft_keymap::Platform::Windows; + let f1 = FunctionKey { key: 1, cmd: false, shift: false }; + let ctrl_f4 = FunctionKey { key: 4, cmd: true, shift: false }; + let shift_f12 = FunctionKey { key: 12, cmd: false, shift: true }; + assert_eq!(keys(0)[..3], [Some(f1), Some(shift_f12), Some(ctrl_f4)]); + assert_eq!(playable_chord(f1).is_some(), !windows); + assert_eq!(playable_chord(ctrl_f4).is_some(), !windows); + assert!( + crate::keymap::lookup(&crate::keymap::keymap_with(&ui.shortcut_overrides), &shift_f12.chord().unwrap()).is_some(), + "the command keeps its shortcut" + ); + } } diff --git a/crates/ui-egui/src/panels/actions/files.rs b/crates/ui-egui/src/panels/actions/files.rs index df5ffe852..cd1e1e018 100644 --- a/crates/ui-egui/src/panels/actions/files.rs +++ b/crates/ui-egui/src/panels/actions/files.rs @@ -1,8 +1,10 @@ //! Action set files: the Actions panel menu's Save Actions… and Load Actions…. //! //! An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1, "name", -//! "actions": [{"name", "steps": [{"command", "params"}]}]}`. Each step is a command id and its -//! params, as `command.batch` takes them. +//! "actions": [{"name", "steps": [{"command", "params"}], "key"?, "color"?}]}`. Each step is a +//! command id and its params, as `command.batch` takes them. `key` is the action's function key +//! (`{"key": 1 to 15, "cmd", "shift"}`) and `color` its Button Mode color; an action without them, +//! or with a value that is not one, has none. use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -42,6 +44,10 @@ struct SetFile { struct FileAction { name: String, steps: Vec, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "super::deserialize_key")] + key: Option, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "super::deserialize_color")] + color: Option, } /// A step of an action in an action set file: a command id and its params, as `command.batch` @@ -61,6 +67,8 @@ pub fn write_set(set: &ActionSet) -> String { .map(|a| FileAction { name: a.name.clone(), steps: a.steps.iter().map(|(command, params)| FileStep { command: command.clone(), params: params.clone() }).collect(), + key: a.key, + color: a.color.clone(), }) .collect(); let file = SetFile { format: ACTIONS_EXT.into(), version: 1, name: set.name.clone(), actions }; @@ -85,8 +93,11 @@ pub fn read_set(text: &str, name: &str) -> Result { if f.actions.iter().any(|a| a.steps.len() > MAX_ACTION_STEPS) { return Err("an action in the action set file has more than 10,000 steps".into()); } - let actions = - f.actions.into_iter().map(|a| Action { name: a.name, steps: a.steps.into_iter().map(|s| (s.command, s.params)).collect() }).collect(); + let actions = f + .actions + .into_iter() + .map(|a| Action { key: a.key, color: a.color, ..Action::new(a.name, a.steps.into_iter().map(|s| (s.command, s.params)).collect()) }) + .collect(); let set_name = if f.name.trim().is_empty() { name.to_string() } else { f.name }; Ok(ActionSet::new(set_name, actions)) } @@ -151,8 +162,10 @@ pub(crate) fn can_load(app: &VectorcraftApp) -> bool { /// `ui.actionSet.load {path? | data?}`: reads the action set file at `path` (else a picked one), or /// the file's text `data`, and adds its set after the others, with its actions. A set with the -/// same name as one in the panel gets the next free number (`Print 2`). The current set and the -/// other sets stay as they are → `{set: index, name, actions: n}`. +/// same name as one in the panel gets the next free number (`Print 2`). A loaded action whose +/// function key an action in the panel or a command, tool or panel already has, or that cannot be +/// used on this platform, gets no key. The current set and the other sets stay as they are → +/// `{set: index, name, actions: n}`. pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { let (text, stem) = match p.get("data") { Some(Value::String(data)) => { diff --git a/crates/ui-egui/src/panels/pathfinder.rs b/crates/ui-egui/src/panels/pathfinder.rs index 34f2b58f3..4d08938c5 100644 --- a/crates/ui-egui/src/panels/pathfinder.rs +++ b/crates/ui-egui/src/panels/pathfinder.rs @@ -522,9 +522,7 @@ mod tests { fn actions_panel_play_records_pathfinder_step() { let mut app = two_squares(); click_button(&mut app, "Intersect"); - app.ui.action_sets[0] - .actions - .push(crate::panels::actions::Action { name: "Unite".into(), steps: vec![("object.pathfinder.unite".into(), json!({}))] }); + app.ui.action_sets[0].actions.push(crate::panels::actions::Action::new("Unite", vec![("object.pathfinder.unite".into(), json!({}))])); let idx = app.ui.action_sets[0].actions.len() - 1; select_two_squares(&mut app, 110.0); crate::panels::actions::play(&mut app, 0, idx).unwrap(); diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 0d531a2ae..3b4671024 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -1445,6 +1445,35 @@ mod tests { assert!(import_json(&json!({})).is_err()); } + /// The dialog's Import… leaves an action its function key when the imported set gives the key + /// to a command, through both Cancel and OK. + #[test] + fn importing_into_the_dialog_preserves_action_keys_on_ok() { + use crate::panels::actions::{Action, ActionSet, FunctionKey}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let f15 = FunctionKey { key: 15, cmd: false, shift: false }; + let mut nudge = Action::new("Nudge", vec![]); + nudge.key = Some(f15); + app.ui.action_sets.push(ActionSet::new("Mine", vec![nudge])); + let mut ov = Overrides::new(); + crate::keymap::set_override(&mut ov, "edit.duplicate", "F15"); + let bytes = serde_json::to_vec(&export_json("Imported", &ov)).unwrap(); + app.services.pick_open = Some(Box::new(|_: &crate::FilePick| Some("/sets/Imported.json".to_string()))); + app.services.read = Some(Box::new(move |_: &str| Ok(bytes.clone()))); + let key = |app: &VectorcraftApp| app.ui.action_sets.last().and_then(|s| s.actions.first()).and_then(|a| a.key); + open(&mut app); + import_into_dialog(&mut app); + assert_eq!(key(&app), Some(f15), "the import alone keeps the action's key"); + assert!(app.ui.shortcut_overrides.is_empty(), "the import is only in the dialog"); + app.ui.dialog = None; + assert_eq!(key(&app), Some(f15), "Cancel keeps the action's key"); + open(&mut app); + import_into_dialog(&mut app); + assert_eq!(key(&app), Some(f15)); + confirm(&mut app).unwrap(); + assert_eq!(key(&app), Some(f15), "OK keeps the action's function key"); + } + #[test] fn presets_are_the_defaults() { for p in PRESETS { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f93bde770..9c62eb188 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -154,14 +154,18 @@ fn runs_a_command(i: &egui::InputState, key: Key, cmd_only: bool) -> bool { /// `key` and `code` are the browser's names for the character typed and the key's position, which /// egui reads with [`Key::from_name`]; `modifiers` as egui reports them. The press is matched as /// the dispatcher matches it ([`dispatch`]) on a layout about which nothing is known: what typing -/// has shown of the layout is left out, and so are double presses. +/// has shown of the layout is left out, and so are double presses. An action's function key runs +/// a shortcut too. pub fn runs_a_shortcut(key: &str, code: &str, modifiers: Modifiers) -> bool { let physical_key = Key::from_name(code); let Some(key) = Key::from_name(key).or(physical_key) else { return false }; let press = egui::Event::Key { key, physical_key, pressed: true, repeat: false, modifiers }; let Some(input) = craft_keymap::KeyInput::from_egui(&press, crate::keymap::platform()) else { return false }; let mirror = crate::shortcut_editor::current(); - craft_keymap::Layout::default().candidates(&input).iter().any(|c| mirror.keymap.lookup(c, false, &Active::NONE) != craft_keymap::Lookup::None) + craft_keymap::Layout::default() + .candidates(&input) + .iter() + .any(|c| mirror.keymap.lookup(c, false, &Active::NONE) != craft_keymap::Lookup::None || crate::panels::actions::plays_an_action(c)) } /// Process one frame's events against the keymap at most once per context, viewport and frame, so @@ -338,6 +342,8 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { if claimed_tool_keys(app, ctx, view, rows_keys) { return; } + // An action's function key plays the action, also when a command has the same shortcut. + crate::panels::actions::play_function_key(app, ctx); // Command, panel and tool shortcuts. A bound shifted punctuation chord uses its key press; // other text keys use the typed character below. let row_key = |chord: &craft_keymap::Chord| { @@ -487,6 +493,7 @@ fn type_tool_input(app: &mut VectorcraftApp, ctx: &egui::Context, composing: boo }) }); } + crate::panels::actions::play_function_key(app, ctx); // 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). Only chords with the diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4dd6d9012..4fc10697f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -491,6 +491,11 @@ pub struct UiState { /// Recording in progress: (set index, action name, journal length when recording started). #[serde(skip)] pub recording: Option<(usize, String, usize)>, + /// The id of the action New Action made for the recording in progress + /// ([`crate::panels::actions::Action::id`]): Stop gives it the recorded steps. None when Stop + /// adds a new action, as after Begin Recording. + #[serde(skip)] + pub recording_into: Option, /// Keyboard shortcut overrides: command id, `tool:` or `panel:` → chord ("" = none), /// stored in the form every earlier version reads. #[serde(default, with = "crate::keymap::stored")] @@ -961,6 +966,7 @@ impl Default for UiState { action_set: None, action_sets_selected: vec![], recording: None, + recording_into: None, shortcut_overrides: Default::default(), shortcut_set: default_shortcut_set(), workspace: default_workspace(), diff --git a/docs/mcp.md b/docs/mcp.md index a7fb8f10f..501ce8c73 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1757,9 +1757,32 @@ already walks Image Trace. Short agent entry points: refused, and no set is added. The Actions panel menu's Save Actions… and Load Actions… run these; Load Actions… is disabled where there is no open dialog (the web). - Actions: `ui.action.rename {set, action, name}` renames the Actions panel's action at `action` in set `set` (0 is - the top set) to `name` with spaces trimmed from both ends, keeps its steps, and returns `{name}`. An index past the - last set or action, or an empty name, is an error, and then nothing is renamed. The panel menu's Action Options… - runs this for the selected action. + the top set) to `name` with spaces trimmed from both ends, keeps its steps, function key and color, and returns + `{name}`. An index past the last set or action, or an empty name, is an error, and then nothing is renamed. +- Actions: `ui.action.new {set, name, key?, cmd?, shift?, color?}` adds an action named `name` (spaces + trimmed) after the last action of set `set` (0 is the top set), starts recording into it and returns + `{set, action}`; Stop Playing/Recording (`ui.action.stop`) ends the recording. `key` 1 to 15 is the function key F1 to F15 that plays the + action (0 or null for none), with `cmd` (Ctrl, Command on macOS) and `shift`. `color` is its Button Mode color + (`red`, `yellow`, `green`, `blue` or `violet`; null or `""` for none), which the panel does not show. Action function + keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 + cannot be used. While an action is recording, and for an index past the last set, + an empty name, or a key or color that is not one of these, it is an error and nothing is added. The panel's Create + New Action button and menu's New Action… open the `actionNew` dialog, whose Record runs this. +- Actions: `ui.action.options {set, action, name?, key?, cmd?, shift?, color?}` changes the action at + `action` in set `set` and keeps its steps: `name` renames it, `key` with `cmd` and `shift` sets its function key, + and `color` its Button Mode color, as `ui.action.new` takes them; a param left out keeps what the action has, and + `cmd` or `shift` alone changes that modifier of its key. It + returns `{name, key, color}`. The panel menu's Action Options… runs this for the selected action. A function key + plays its action from the keyboard; like command shortcuts, it does nothing while a dialog, the command palette or + a text field has the keyboard, or while a tool is busy. Editing command shortcuts (`shortcuts.set`, Keyboard Shortcuts), loading preferences and + `ui.actionSet.load` preserve action function keys. When several actions have the same key, the first in panel order + plays. A stored key reserved on the current platform does not play an action. +- Actions: `ui.action.play {action, set?}` plays the action named `action` as one undoable batch; `set` names the set + to look in (default: every set), and a `set` that is not a string is an error. A name that no action has, or that + more than one action in the sets looked in has, is an error, and then nothing plays. +- Actions: `ui.action.stop {}` ends the recording in progress and keeps its steps, in the action `ui.action.new` made + for it, else in a new action after the last action of its set, and returns `{stopped}`. With nothing recording it + changes nothing. The panel's Stop Playing/Recording button runs this. - Action sets: `ui.actionSet.delete {sets?: [index], ids?: [id]}` deletes the Actions panel's sets at these indexes (0 is the top set) with their actions, by default the current set, and returns `{deleted}`. An index past the last set is an error, and then no set is deleted. `ids` holds the panel's own set ids, which its question passes: the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7513ebe09..48f2194f4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -562,7 +562,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: Lock Variables (`variable.lock`, in the Variables panel's bottom bar and menu) stops variables from being defined, deleted or renamed until they are unlocked, while objects can still be bound and unbound and data sets still change and apply; the lock is saved with the document. - Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`. - Actions panel menu: Save Actions… writes the current action set with all its actions to a file, and Load Actions… adds the set from such a file to the panel ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html): "Select Save Actions from the Actions panel menu", "You can save only the entire contents of a set", "Select Load Actions from the Actions panel menu"; `ui.actionSet.save`, `ui.actionSet.load`). The file is VectorCraft's own action set file (`.vcactions`); a loaded set named like one in the panel gets the next free number. - - Actions panel: the panel menu's Action Options… renames the selected action and keeps its steps ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): "Opens a dialog to rename or assign function keys/colors to the action"; `ui.action.rename`). The dialog has the name only; the function key and color are not there yet. + - Actions panel: the panel menu's Action Options… renames the selected action and keeps its steps ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): the menu item opens a dialog that renames the action or gives it a function key and color; `ui.action.options`). It also sets the action's function key and color. + - Actions panel: Create New Action (bottom bar) and the panel menu's New Action… ask for the new action's name, set, function key and color, and Record adds the action and starts recording into it ([Create new actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html): the New Action dialog takes the action's name and the set it goes in; `ui.action.new`, `ui.action.play`). The function key (F1 to F15, with Ctrl or Command and Shift) plays the action. VectorCraft's own rules, which the page does not cover: a key another action or a command has is refused until Replace is ticked, which takes it from them; a shortcut given to a command later takes the key from the action; a copied action, and a loaded action whose key is taken, have no key; New Action does not open while an action is recording. The color is kept with the action for Button Mode, which VectorCraft does not have yet. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Actions panel: the current set is kept with the UI preferences, so after a restart a recording goes into the set that was current, not into the first set. With no set current, Begin Recording records into the first set and makes it current. - Actions panel: Delete Selection deletes the selected action set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): "Removes the selected action, command, or action set from the panel"; `ui.actionSet.delete`). VectorCraft's own rules, which the overview does not cover: deleting a set asks first, Shift-click on a set's name selects a range of sets to delete together, the panel menu's Delete does the same, and Default Actions and the last set can be deleted. Undo does not bring a deleted set back. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a0b422584..75bb06032 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -303,9 +303,10 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Image Trace panel: the Preview checkbox before Trace chooses whether a setting change traces the selected Image Trace object again at once | done | Per the public documentation, Preview shows the result of the current settings and Trace traces with them (). Preview is on by default and kept with the UI preferences; Reset to Default leaves it. Off, changes wait in the panel until Trace applies them as one undo step, or until Preview is turned on again. Changes stay with the object they were made for while another object is selected, and come back when it is selected again. `imageTrace.preview {on?}`, `ui.set {imageTracePreview}`; `panels/image_trace.rs` headless tests (`with_preview_off_a_slider_change_waits_for_trace`, `turning_preview_on_traces_the_changes_that_waited`, `preview_turned_on_with_nothing_selected_traces_when_the_object_is_selected_again`, `settings_that_wait_stay_with_their_object`, `settings_that_wait_come_back_when_their_object_is_selected_again`, `settings_that_wait_are_dropped_when_their_object_changed_since`, `preview_is_kept_with_the_preferences_and_reset_to_default_leaves_it`) | | Toolbar without group labels by default, one or two columns; All Tools drawer by flyout group with shortcuts, its menu (Reset, Basic, Advanced) and Show section | done | #1751; a preference brings the labels back; `toolbar.show` sets the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls | | Actions panel: ≡ › Save Actions… saves the current set with all its actions to a file and ≡ › Load Actions… adds a saved set ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html)) | partial | `ui.actionSet.save`, `ui.actionSet.load`; `panels/actions.rs` tests. The file is VectorCraft's own `.vcactions` JSON; the reference app's `.aia` files and Reset, Clear and Replace Actions remain | -| Actions panel: ≡ › Action Options… renames the selected action ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): "Opens a dialog to rename or assign function keys/colors to the action") | partial | `ui.action.rename`; `panels/actions.rs` tests (part of #1629). The dialog has the name only: the function key and color remain | +| Actions panel: ≡ › Action Options… renames the selected action and sets its function key and color ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): "Opens a dialog to rename or assign function keys/colors to the action") | partial | `ui.action.options`; `panels/actions.rs` tests (part of #1629). The color shows nowhere until Button Mode exists | | Actions panel: a clicked action stays selected for Play Current Selection and Delete Selection | partial | #1853 (part of #1629); editing actions and action shortcuts remain | -| Actions panel: Create New Set (bottom bar, ≡ › New Set…) names a new set and Set Options… renames the current one; a click on a set's name makes it current and a recording goes into the current set ([helpx: Automation with actions](https://helpx.adobe.com/illustrator/using/automation-actions.html)); the current set is kept with the UI preferences, and with no set current a recording goes into the first set, which becomes current | partial | #3712; the New Action dialog (name, set menu, shortcut, colour) and dragging actions between sets remain | +| Actions panel: Create New Set (bottom bar, ≡ › New Set…) names a new set and Set Options… renames the current one; a click on a set's name makes it current and a recording goes into the current set ([helpx: Automation with actions](https://helpx.adobe.com/illustrator/using/automation-actions.html)); the current set is kept with the UI preferences, and with no set current a recording goes into the first set, which becomes current | partial | #3712; dragging actions between sets remains | +| Actions panel: Create New Action (bottom bar, ≡ › New Action…) asks for the name, set, function key and color, and Record adds the action and records into it ([Create new actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html)) | partial | `ui.action.new`, `ui.action.play`; `panels/actions.rs` tests. A function key plays its action; a taken key needs Replace, and a shortcut given to a command later takes the key from the action. Button Mode, which shows the color, remains | | Actions panel: Delete Selection (bottom bar) deletes the selected set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): "Removes the selected action, command, or action set from the panel") | partial | `ui.actionSet.delete`; `panels/actions.rs` tests. VectorCraft's own rules, which the overview does not cover: deleting a set asks first (a selected action is still deleted at once), Shift-click on a set's name selects a range of sets, ≡ › Delete does the same as the button, and Default Actions and the last set can be deleted. Undo does not restore a deleted set, and recorded commands cannot be selected or deleted | | Actions panel: ≡ › Duplicate copies the selected action or set ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): "Makes a copy of the selected action or set.") | partial | `ui.actionSet.duplicate`; `panels/actions.rs` tests. VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected, and a copied set holds copies of all its actions. Undo does not remove the copy, and recorded commands cannot be duplicated | | Properties panel per context (every selection type's Quick Actions) | partial | one clip group: Edit Clipping Path and Edit Contents, at the top of the panel rather than in Quick Actions (G1); the overview's additional controls ([Properties panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html)): Blend Options (one blend selected, with any tool, #4817) and Merge Live Paint (a Live Paint group with more) in Quick Actions, the Width Profile list (Width tool or a variable-width stroke) and an applied effect's Delete icon in Appearance, Corner and Corner Radius for corners selected with Direct Selection; Variable Font was already there |