diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 43e274505..ed9f909b3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -11,6 +11,7 @@ pub mod action_batch; pub mod action_new; pub mod action_playback; pub mod action_set_options; +pub mod action_stop; pub mod artboard_options; pub mod blend_options; mod brush_art_options; @@ -372,6 +373,8 @@ registry! { ActionSetOptions: [action_set_options::KIND] => action_set_options::SPEC, ActionNew: [action_new::KIND] => action_new::SPEC, ActionPlayback: [action_playback::KIND] => action_playback::SPEC, + ActionStopRecord: [action_stop::RECORD_KIND] => action_stop::RECORD_SPEC, + ActionStopMessage: [action_stop::MESSAGE_KIND] => action_stop::MESSAGE_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_batch.rs b/crates/ui-egui/src/dialogs/action_batch.rs index fc7c7ceb3..0d6382e9a 100644 --- a/crates/ui-egui/src/dialogs/action_batch.rs +++ b/crates/ui-egui/src/dialogs/action_batch.rs @@ -23,6 +23,9 @@ pub fn open(app: &mut VectorcraftApp, set: usize, action: usize) -> Result Result { if commands.is_empty() { return Err("the action has no included steps".into()); } + if crate::panels::actions::has_stop(&commands) { + return Err("Batch cannot play an action with a stop".into()); + } let source = d.str("source"); if source == "folder" && d.str("folder").trim().is_empty() { return Err("choose a source folder".into()); diff --git a/crates/ui-egui/src/dialogs/action_stop.rs b/crates/ui-egui/src/dialogs/action_stop.rs new file mode 100644 index 000000000..ccf5096a2 --- /dev/null +++ b/crates/ui-egui/src/dialogs/action_stop.rs @@ -0,0 +1,107 @@ +//! Actions panel menu › Insert Stop… (`ui.action.insertStop`) and the message box a playing action +//! shows at a stop. +//! +//! Record Stop's fields: `message`, `allowContinue`, and `set`, `action` and `step`, the place of the +//! new stop (no `set` and `action`: the recording in progress). An invalid place is an error, and +//! the dialog stays open. +//! +//! The message box's fields: `action` (the action's name, shown as the heading), `message` and +//! `allowContinue`. Stop or Cancel closes it, and the playback keeps waiting for Play Current +//! Selection or Stop Playing/Recording. With `allowContinue`, Continue or `ui.dialog.confirm` plays +//! on; without it, `ui.dialog.confirm` presses Stop. + +use serde_json::{Value, json}; + +use super::{DialogAction, DialogSpec}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Record Stop. +pub const RECORD_KIND: &str = "actionStopRecord"; + +/// The dialog kind of a stop's message box. +pub const MESSAGE_KIND: &str = "actionStopMessage"; + +/// The width of Record Stop's label column. +const LABEL_W: f32 = 70.0; + +pub(super) const RECORD_SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("Record Stop").into(), body: record_body, confirm: record_confirm, min_width: 340.0, ..DialogSpec::FORM }; + +pub(super) const MESSAGE_SPEC: DialogSpec = DialogSpec { + heading: |d| d.str("action"), + body_with_actions: Some(message_body), + confirm: message_confirm, + ok: None, + min_width: 300.0, + max_width: Some(460.0), + ..DialogSpec::FORM +}; + +/// Opens Record Stop for action `target` as (set, action), after `step`, else for the recording in +/// progress. +pub fn open_record(app: &mut VectorcraftApp, target: Option<(usize, usize)>, step: Option) { + let mut fields = json!({"message": "", "allowContinue": false}); + if let Some((set, action)) = target { + fields["set"] = json!(set); + fields["action"] = json!(action); + if let Some(step) = step { + fields["step"] = json!(step); + } + } + app.ui.dialog = Some(Dialog::new(RECORD_KIND, fields)); +} + +fn record_body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + widgets::label_row(ui, "Message:", LABEL_W, |ui| { + super::form::text(ui, d, "message", 240.0); + }); + super::form::check(ui, d, "allowContinue", "Allow Continue"); + false +} + +fn record_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut params = json!({"message": d.str("message"), "allowContinue": d.bool("allowContinue")}); + for key in ["set", "action", "step"] { + if let Some(value) = d.fields.get(key) { + params[key] = value.clone(); + } + } + let r = app.run(crate::panels::actions::INSERT_STOP, params)?; + app.ui.dialog = None; + Ok(r) +} + +/// Shows the message box of a stop in the action named `action`. +pub(crate) fn open_message(app: &mut VectorcraftApp, action: &str, message: &str, allow_continue: bool) { + app.ui.dialog = Some(Dialog::new(MESSAGE_KIND, json!({"action": action, "message": message, "allowContinue": allow_continue}))); +} + +fn message_body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> DialogAction { + let message = d.str("message"); + if !message.is_empty() { + ui.label(message); + ui.add_space(8.0); + } + ui.label(egui::RichText::new(tl!("Press Play Current Selection in the Actions panel to go on.")).color(Tokens::get(ui.ctx()).text_dim)); + ui.add_space(16.0); + let mut action = DialogAction::None; + ui.horizontal(|ui| { + if d.bool("allowContinue") && widgets::primary_button_enabled(ui, tl!("Continue"), true).clicked() { + action = DialogAction::Confirm; + } + if widgets::secondary_button(ui, tl!("Stop")).clicked() { + action = DialogAction::Cancel; + } + }); + action +} + +fn message_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + if d.bool("allowContinue") { + return app.run(crate::panels::actions::CONTINUE_PLAYBACK, json!({})); + } + app.ui.dialog = None; + Ok(Value::Null) +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f6a8debdc..0da4c8b03 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5680,6 +5680,19 @@ axis Y Axis Y ос Action Options… Опции на действието… Action Options Опции на действието Playback Options… Опции за възпроизвеждане… + Insert Stop… Вмъкване на спиране… + Insert Stop Вмъкване на спиране + Record Stop Записване на спиране + Allow Continue Разрешаване на продължаване + Continue Продължаване + Continue Action Продължаване на действието + Delete Action Step Изтриване на стъпка от действието + Press Play Current Selection in the Actions panel to go on. За да продължите, натиснете „Изпълнение на текущия избор“ в панела „Действия“. + Stop: {message} Спиране: {message} +@msg no action is waiting at a stop няма действие, което чака при спиране +@msg message must be text message трябва да бъде текст +@msg allowContinue must be true or false allowContinue трябва да бъде true или false +@msg Batch cannot play an action with a stop Пакет не може да изпълни действие със спиране Playback Options Опции за възпроизвеждане Accelerated Ускорено Step by Step Стъпка по стъпка diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a8db1ebea..a90a73f64 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5703,6 +5703,19 @@ axis Y Axis Y osa Action Options… Opcije akcije… Action Options Opcije akcije Playback Options… Opcije reprodukcije… + Insert Stop… Umetni zaustavljanje… + Insert Stop Umetni zaustavljanje + Record Stop Snimi zaustavljanje + Allow Continue Dozvoli nastavak + Continue Nastavi + Continue Action Nastavi akciju + Delete Action Step Izbriši korak akcije + Press Play Current Selection in the Actions panel to go on. Za nastavak pritisnite „Reprodukuj trenutni odabir“ na panelu Akcije. + Stop: {message} Zaustavljanje: {message} +@msg no action is waiting at a stop nijedna akcija ne čeka na zaustavljanju +@msg message must be text message mora biti tekst +@msg allowContinue must be true or false allowContinue mora biti true ili false +@msg Batch cannot play an action with a stop Paketna obrada ne može reprodukovati akciju sa zaustavljanjem Playback Options Opcije reprodukcije Accelerated Ubrzano Step by Step Korak po korak diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 3898a677f..42befdbc1 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3803,6 +3803,19 @@ digits Malayalam Malaiàlam Action Options… Opcions de l’acció… Action Options Opcions de l’acció Playback Options… Opcions de reproducció… + Insert Stop… Insereix una aturada… + Insert Stop Insereix una aturada + Record Stop Enregistra una aturada + Allow Continue Permet continuar + Continue Continua + Continue Action Continua l’acció + Delete Action Step Suprimeix el pas de l’acció + Press Play Current Selection in the Actions panel to go on. Per continuar, premeu «Reprodueix la selecció actual» al tauler Accions. + Stop: {message} Aturada: {message} +@msg no action is waiting at a stop cap acció no espera en una aturada +@msg message must be text message ha de ser text +@msg allowContinue must be true or false allowContinue ha de ser true o false +@msg Batch cannot play an action with a stop Lot no pot reproduir una acció amb una aturada Playback Options Opcions de reproducció Accelerated Accelerada Step by Step Pas a pas diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index eff1d2d34..4fcc1bcf4 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5796,6 +5796,19 @@ axis Y Axis osa Y Action Options… Možnosti akce… Action Options Možnosti akce Playback Options… Možnosti přehrávání… + Insert Stop… Vložit zastavení… + Insert Stop Vložit zastavení + Record Stop Zaznamenat zastavení + Allow Continue Povolit pokračování + Continue Pokračovat + Continue Action Pokračovat v akci + Delete Action Step Odstranit krok akce + Press Play Current Selection in the Actions panel to go on. Chcete-li pokračovat, stiskněte na panelu Akce tlačítko Přehrát aktuální výběr. + Stop: {message} Zastavení: {message} +@msg no action is waiting at a stop žádná akce nečeká na zastavení +@msg message must be text message musí být text +@msg allowContinue must be true or false allowContinue musí být true nebo false +@msg Batch cannot play an action with a stop Dávka nemůže přehrát akci se zastavením Playback Options Možnosti přehrávání Accelerated Zrychleně Step by Step Krok za krokem diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index abfe581e7..43da801a7 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5695,6 +5695,19 @@ axis Y Axis Y-akse Action Options… Indstillinger for handling… Action Options Indstillinger for handling Playback Options… Afspilningsindstillinger… + Insert Stop… Indsæt stop… + Insert Stop Indsæt stop + Record Stop Optag stop + Allow Continue Tillad fortsæt + Continue Fortsæt + Continue Action Fortsæt handling + Delete Action Step Slet handlingstrin + Press Play Current Selection in the Actions panel to go on. Tryk på Afspil aktuel markering i panelet Handlinger for at fortsætte. + Stop: {message} Stop: {message} +@msg no action is waiting at a stop ingen handling venter ved et stop +@msg message must be text message skal være tekst +@msg allowContinue must be true or false allowContinue skal være true eller false +@msg Batch cannot play an action with a stop Batch kan ikke afspille en handling med et stop Playback Options Afspilningsindstillinger Accelerated Accelereret Step by Step Trin for trin diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4a4142d88..d0d3fcc9e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5378,6 +5378,19 @@ axis Y Axis Y-Achse Action Options… Aktionsoptionen… Action Options Aktionsoptionen Playback Options… Wiedergabeoptionen… + Insert Stop… Stopp einfügen… + Insert Stop Stopp einfügen + Record Stop Stopp aufzeichnen + Allow Continue Fortsetzen erlauben + Continue Fortsetzen + Continue Action Aktion fortsetzen + Delete Action Step Aktionsschritt löschen + Press Play Current Selection in the Actions panel to go on. Klicken Sie im Bedienfeld „Aktionen“ auf „Aktuelle Auswahl abspielen“, um fortzufahren. + Stop: {message} Stopp: {message} +@msg no action is waiting at a stop keine Aktion wartet an einem Stopp +@msg message must be text message muss Text sein +@msg allowContinue must be true or false allowContinue muss true oder false sein +@msg Batch cannot play an action with a stop Die Stapelverarbeitung kann keine Aktion mit einem Stopp abspielen Playback Options Wiedergabeoptionen Accelerated Beschleunigt Step by Step Schrittweise diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6b2a022d5..7c290163b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5747,6 +5747,19 @@ axis Y Axis Άξονας Y Action Options… Επιλογές ενέργειας… Action Options Επιλογές ενέργειας Playback Options… Επιλογές αναπαραγωγής… + Insert Stop… Εισαγωγή διακοπής… + Insert Stop Εισαγωγή διακοπής + Record Stop Καταγραφή διακοπής + Allow Continue Να επιτρέπεται η συνέχεια + Continue Συνέχεια + Continue Action Συνέχεια ενέργειας + Delete Action Step Διαγραφή βήματος ενέργειας + Press Play Current Selection in the Actions panel to go on. Για να συνεχίσετε, πατήστε «Αναπαραγωγή τρέχουσας επιλογής» στο πάνελ Ενέργειες. + Stop: {message} Διακοπή: {message} +@msg no action is waiting at a stop καμία ενέργεια δεν περιμένει σε διακοπή +@msg message must be text Το message πρέπει να είναι κείμενο +@msg allowContinue must be true or false Το allowContinue πρέπει να είναι true ή false +@msg Batch cannot play an action with a stop Η μαζική επεξεργασία δεν μπορεί να αναπαράγει ενέργεια με διακοπή Playback Options Επιλογές αναπαραγωγής Accelerated Επιταχυμένα Step by Step Βήμα προς βήμα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6ccd50b46..aff3c9b87 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5737,6 +5737,19 @@ axis Y Axis Eje Y Action Options… Opciones de acción… Action Options Opciones de acción Playback Options… Opciones de reproducción… + Insert Stop… Insertar parada… + Insert Stop Insertar parada + Record Stop Grabar parada + Allow Continue Permitir continuar + Continue Continuar + Continue Action Continuar acción + Delete Action Step Eliminar paso de la acción + Press Play Current Selection in the Actions panel to go on. Para continuar, pulse Reproducir la selección actual en el panel Acciones. + Stop: {message} Parada: {message} +@msg no action is waiting at a stop ninguna acción está esperando en una parada +@msg message must be text message debe ser texto +@msg allowContinue must be true or false allowContinue debe ser true o false +@msg Batch cannot play an action with a stop Lote no puede reproducir una acción con una parada Playback Options Opciones de reproducción Accelerated Acelerada Step by Step Paso a paso diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 3cf8607c0..58da7396e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5826,6 +5826,19 @@ axis Y Axis Y-akseli Action Options… Toiminnon asetukset… Action Options Toiminnon asetukset Playback Options… Toistoasetukset… + Insert Stop… Lisää pysäytys… + Insert Stop Lisää pysäytys + Record Stop Nauhoita pysäytys + Allow Continue Salli jatkaminen + Continue Jatka + Continue Action Jatka toimintoa + Delete Action Step Poista toiminnon vaihe + Press Play Current Selection in the Actions panel to go on. Jatka valitsemalla Toiminnot-paneelista Toista nykyinen valinta. + Stop: {message} Pysäytys: {message} +@msg no action is waiting at a stop mikään toiminto ei odota pysäytyksessä +@msg message must be text message on oltava tekstiä +@msg allowContinue must be true or false allowContinue on oltava true tai false +@msg Batch cannot play an action with a stop Erä ei voi toistaa toimintoa, jossa on pysäytys Playback Options Toistoasetukset Accelerated Nopeutettu Step by Step Vaihe kerrallaan diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 67a7cb29c..fe935bf47 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5791,6 +5791,19 @@ axis Y Axis Axe Y Action Options… Options de script… Action Options Options de script Playback Options… Options d’exécution… + Insert Stop… Insérer un arrêt… + Insert Stop Insérer un arrêt + Record Stop Enregistrer un arrêt + Allow Continue Autoriser la poursuite + Continue Continuer + Continue Action Continuer le script + Delete Action Step Supprimer l’étape du script + Press Play Current Selection in the Actions panel to go on. Pour continuer, cliquez sur Exécuter la sélection actuelle dans le panneau Scripts. + Stop: {message} Arrêt : {message} +@msg no action is waiting at a stop aucun script n’attend à un arrêt +@msg message must be text message doit être du texte +@msg allowContinue must be true or false allowContinue doit valoir true ou false +@msg Batch cannot play an action with a stop Le traitement par lots ne peut pas exécuter un script comportant un arrêt Playback Options Options d’exécution Accelerated Accélérée Step by Step Pas à pas diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index e1af82a3a..ea6911fd2 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3290,6 +3290,19 @@ Play Action खेल कार्रवाई Play Current Selection वर्तमान सिलेक्शन Play: चलाएँ: + Insert Stop… स्टॉप जोड़ें… + Insert Stop स्टॉप जोड़ें + Record Stop स्टॉप रिकॉर्ड करें + Allow Continue जारी रखने दें + Continue जारी रखें + Continue Action एक्शन जारी रखें + Delete Action Step एक्शन का चरण हटाएं + Press Play Current Selection in the Actions panel to go on. आगे बढ़ने के लिए एक्शन्स पैनल में वर्तमान सिलेक्शन प्ले करें दबाएँ। + Stop: {message} स्टॉप: {message} +@msg no action is waiting at a stop कोई एक्शन स्टॉप पर प्रतीक्षा नहीं कर रहा है +@msg message must be text message टेक्स्ट होना चाहिए +@msg allowContinue must be true or false allowContinue true या false होना चाहिए +@msg Batch cannot play an action with a stop बैच स्टॉप वाला एक्शन नहीं चला सकता Playback Options प्लेबैक ऑप्शन्स Playback Options… प्लेबैक ऑप्शन्स… Plug-in Info प्लगइन जानकारी diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 82f75da72..c97563bee 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5779,6 +5779,19 @@ axis Y Axis Y tengely Action Options… Művelet beállításai… Action Options Művelet beállításai Playback Options… Lejátszási beállítások… + Insert Stop… Megállás beszúrása… + Insert Stop Megállás beszúrása + Record Stop Megállás rögzítése + Allow Continue Folytatás engedélyezése + Continue Folytatás + Continue Action Művelet folytatása + Delete Action Step Műveletlépés törlése + Press Play Current Selection in the Actions panel to go on. A folytatáshoz nyomja meg a Műveletek panelen a Kijelölt művelet lejátszása gombot. + Stop: {message} Megállás: {message} +@msg no action is waiting at a stop egyetlen művelet sem várakozik megállásnál +@msg message must be text message: szöveg kell +@msg allowContinue must be true or false allowContinue: true vagy false kell +@msg Batch cannot play an action with a stop A kötegelt feldolgozás nem játszhat le megállást tartalmazó műveletet Playback Options Lejátszási beállítások Accelerated Gyorsított Step by Step Lépésenként diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 7230b6bfa..9bd658195 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5695,6 +5695,19 @@ axis Y Axis Sumbu Y Action Options… Opsi tindakan… Action Options Opsi tindakan Playback Options… Opsi pemutaran… + Insert Stop… Sisipkan henti… + Insert Stop Sisipkan henti + Record Stop Rekam henti + Allow Continue Izinkan lanjutkan + Continue Lanjutkan + Continue Action Lanjutkan tindakan + Delete Action Step Hapus langkah tindakan + Press Play Current Selection in the Actions panel to go on. Untuk melanjutkan, tekan Putar seleksi saat ini di panel Tindakan. + Stop: {message} Henti: {message} +@msg no action is waiting at a stop tidak ada tindakan yang menunggu di titik henti +@msg message must be text message harus berupa teks +@msg allowContinue must be true or false allowContinue harus true atau false +@msg Batch cannot play an action with a stop Batch tidak dapat memutar tindakan dengan titik henti Playback Options Opsi pemutaran Accelerated Dipercepat Step by Step Langkah demi langkah diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 723fa4ab0..38b72c8c5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5412,6 +5412,19 @@ axis Y Axis Asse Y Action Options… Opzioni azione… Action Options Opzioni azione Playback Options… Opzioni di riproduzione… + Insert Stop… Inserisci interruzione… + Insert Stop Inserisci interruzione + Record Stop Registra interruzione + Allow Continue Consenti di continuare + Continue Continua + Continue Action Continua azione + Delete Action Step Elimina passaggio dell’azione + Press Play Current Selection in the Actions panel to go on. Per continuare, premi Esegui selezione corrente nel pannello Azioni. + Stop: {message} Interruzione: {message} +@msg no action is waiting at a stop nessuna azione è in attesa a un'interruzione +@msg message must be text message deve essere testo +@msg allowContinue must be true or false allowContinue deve essere true o false +@msg Batch cannot play an action with a stop L'elaborazione batch non può eseguire un'azione con un'interruzione Playback Options Opzioni di riproduzione Accelerated Accelerata Step by Step Passo per passo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8bb101269..4c25ed744 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5477,6 +5477,19 @@ axis Y Axis Y 軸 Action Options… アクションオプション… Action Options アクションオプション Playback Options… 再生オプション… + Insert Stop… 停止を挿入… + Insert Stop 停止を挿入 + Record Stop 停止を記録 + Allow Continue 続行を許可 + Continue 続行 + Continue Action アクションを続行 + Delete Action Step アクションステップを削除 + Press Play Current Selection in the Actions panel to go on. 続行するには、アクションパネルの「選択項目を再生」を押します。 + Stop: {message} 停止: {message} +@msg no action is waiting at a stop 停止で待機中のアクションはありません +@msg message must be text message にはテキストを指定してください +@msg allowContinue must be true or false allowContinue には true または false を指定してください +@msg Batch cannot play an action with a stop バッチでは停止を含むアクションを再生できません Playback Options 再生オプション Accelerated 高速 Step by Step ステップ実行 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 343b415a4..1bada5e40 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5719,6 +5719,19 @@ axis Y Axis Y ღერძი Action Options… მოქმედების პარამეტრები… Action Options მოქმედების პარამეტრები Playback Options… შესრულების პარამეტრები… + Insert Stop… გაჩერების ჩასმა… + Insert Stop გაჩერების ჩასმა + Record Stop გაჩერების ჩაწერა + Allow Continue გაგრძელების დაშვება + Continue გაგრძელება + Continue Action მოქმედების გაგრძელება + Delete Action Step მოქმედების ნაბიჯის წაშლა + Press Play Current Selection in the Actions panel to go on. გასაგრძელებლად მოქმედებების პანელზე დააჭირეთ „მიმდინარე მონიშნულის შესრულებას“. + Stop: {message} გაჩერება: {message} +@msg no action is waiting at a stop გაჩერებაზე არცერთი მოქმედება არ ელოდება +@msg message must be text message უნდა იყოს ტექსტი +@msg allowContinue must be true or false allowContinue უნდა იყოს true ან false +@msg Batch cannot play an action with a stop პაკეტურ დამუშავებას არ შეუძლია გაჩერების მქონე მოქმედების შესრულება Playback Options შესრულების პარამეტრები Accelerated დაჩქარებული Step by Step ნაბიჯ-ნაბიჯ diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f4cc45b82..1039fba60 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5373,6 +5373,19 @@ axis Y Axis Y축 Action Options… 액션 옵션… Action Options 액션 옵션 Playback Options… 재생 옵션… + Insert Stop… 중지 삽입… + Insert Stop 중지 삽입 + Record Stop 중지 기록 + Allow Continue 계속 허용 + Continue 계속 + Continue Action 액션 계속 + Delete Action Step 액션 단계 삭제 + Press Play Current Selection in the Actions panel to go on. 계속하려면 액션 패널에서 현재 선택 항목 재생을 누르세요. + Stop: {message} 중지: {message} +@msg no action is waiting at a stop 중지에서 대기 중인 액션이 없습니다 +@msg message must be text message의 값은 텍스트여야 합니다 +@msg allowContinue must be true or false allowContinue의 값은 true 또는 false여야 합니다 +@msg Batch cannot play an action with a stop 일괄 처리에서는 중지가 있는 액션을 재생할 수 없습니다 Playback Options 재생 옵션 Accelerated 가속 Step by Step 단계별 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 2e8d6f34c..00431e1a9 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5741,6 +5741,19 @@ axis Y Axis Y-as Action Options… Actieopties… Action Options Actieopties Playback Options… Afspeelopties… + Insert Stop… Stop invoegen… + Insert Stop Stop invoegen + Record Stop Stop opnemen + Allow Continue Doorgaan toestaan + Continue Doorgaan + Continue Action Actie voortzetten + Delete Action Step Actiestap verwijderen + Press Play Current Selection in the Actions panel to go on. Druk in het paneel Acties op Huidige selectie afspelen om door te gaan. + Stop: {message} Stop: {message} +@msg no action is waiting at a stop geen actie wacht bij een stop +@msg message must be text message moet tekst zijn +@msg allowContinue must be true or false allowContinue moet true of false zijn +@msg Batch cannot play an action with a stop Batch kan geen actie met een stop afspelen Playback Options Afspeelopties Accelerated Versneld Step by Step Stap voor stap diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c9e1256c3..3b52097d4 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5649,6 +5649,19 @@ axis Y Axis Oś Y Action Options… Opcje operacji… Action Options Opcje operacji Playback Options… Opcje odtwarzania… + Insert Stop… Wstaw zatrzymanie… + Insert Stop Wstaw zatrzymanie + Record Stop Nagraj zatrzymanie + Allow Continue Zezwalaj na kontynuację + Continue Kontynuuj + Continue Action Kontynuuj operację + Delete Action Step Usuń krok operacji + Press Play Current Selection in the Actions panel to go on. Aby kontynuować, naciśnij Odtwórz bieżące zaznaczenie w panelu Operacje. + Stop: {message} Zatrzymanie: {message} +@msg no action is waiting at a stop żadna operacja nie czeka na zatrzymaniu +@msg message must be text message musi być tekstem +@msg allowContinue must be true or false allowContinue musi mieć wartość true lub false +@msg Batch cannot play an action with a stop Przetwarzanie wsadowe nie może odtworzyć operacji z zatrzymaniem Playback Options Opcje odtwarzania Accelerated Przyspieszone Step by Step Krok po kroku diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 062b7d88b..219c6e8e7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5711,6 +5711,19 @@ axis Y Axis Eixo Y Action Options… Opções da Ação… Action Options Opções da Ação Playback Options… Opções de Reprodução… + Insert Stop… Inserir Parada… + Insert Stop Inserir Parada + Record Stop Gravar Parada + Allow Continue Permitir Continuar + Continue Continuar + Continue Action Continuar Ação + Delete Action Step Excluir Etapa da Ação + Press Play Current Selection in the Actions panel to go on. Para continuar, pressione Reproduzir Seleção Atual no painel Ações. + Stop: {message} Parada: {message} +@msg no action is waiting at a stop nenhuma ação está aguardando em uma parada +@msg message must be text message deve ser texto +@msg allowContinue must be true or false allowContinue deve ser true ou false +@msg Batch cannot play an action with a stop Lote não pode reproduzir uma ação com uma parada Playback Options Opções de Reprodução Accelerated Acelerada Step by Step Passo a Passo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a83d9edf3..1186b6df8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5407,6 +5407,19 @@ axis Y Axis Ось Y Action Options… Параметры действия… Action Options Параметры действия Playback Options… Параметры воспроизведения… + Insert Stop… Вставить остановку… + Insert Stop Вставить остановку + Record Stop Записать остановку + Allow Continue Разрешить продолжение + Continue Продолжить + Continue Action Продолжить действие + Delete Action Step Удалить шаг действия + Press Play Current Selection in the Actions panel to go on. Чтобы продолжить, нажмите «Воспроизвести текущее выделение» на панели «Действия». + Stop: {message} Остановка: {message} +@msg no action is waiting at a stop ни одно действие не ожидает на остановке +@msg message must be text message должно быть текстом +@msg allowContinue must be true or false allowContinue должно быть true или false +@msg Batch cannot play an action with a stop Пакет не может воспроизвести действие с остановкой Playback Options Параметры воспроизведения Accelerated Ускоренно Step by Step Пошагово diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 780d23bea..c491e1abf 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5819,6 +5819,19 @@ axis Y Axis Y-axel Action Options… Alternativ för åtgärd… Action Options Alternativ för åtgärd Playback Options… Uppspelningsalternativ… + Insert Stop… Infoga stopp… + Insert Stop Infoga stopp + Record Stop Spela in stopp + Allow Continue Tillåt fortsättning + Continue Fortsätt + Continue Action Fortsätt åtgärd + Delete Action Step Radera åtgärdssteg + Press Play Current Selection in the Actions panel to go on. Fortsätt genom att trycka på Spela upp aktuell markering i panelen Åtgärder. + Stop: {message} Stopp: {message} +@msg no action is waiting at a stop ingen åtgärd väntar vid ett stopp +@msg message must be text message måste vara text +@msg allowContinue must be true or false allowContinue måste vara true eller false +@msg Batch cannot play an action with a stop Batch kan inte spela upp en åtgärd med ett stopp Playback Options Uppspelningsalternativ Accelerated Accelererad Step by Step Steg för steg diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 55a9cdc73..3cf0f6d32 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5753,6 +5753,19 @@ axis Y Axis Y ekseni Action Options… Eylem Seçenekleri… Action Options Eylem Seçenekleri Playback Options… Oynatma Seçenekleri… + Insert Stop… Durak Ekle… + Insert Stop Durak Ekle + Record Stop Durak Kaydet + Allow Continue Devam Etmeye İzin Ver + Continue Devam Et + Continue Action Eyleme Devam Et + Delete Action Step Eylem Adımını Sil + Press Play Current Selection in the Actions panel to go on. Devam etmek için Eylemler panelinde Geçerli Seçimi Oynat'a basın. + Stop: {message} Durak: {message} +@msg no action is waiting at a stop durakta bekleyen eylem yok +@msg message must be text "message" metin olmalıdır +@msg allowContinue must be true or false "allowContinue" true veya false olmalıdır +@msg Batch cannot play an action with a stop Toplu işlem durak içeren bir eylemi oynatamaz Playback Options Oynatma Seçenekleri Accelerated Hızlandırılmış Step by Step Adım Adım diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bbfc332f9..11f6bed60 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5392,6 +5392,19 @@ axis Y Axis Вісь Y Action Options… Параметри дії… Action Options Параметри дії Playback Options… Параметри відтворення… + Insert Stop… Вставити зупинку… + Insert Stop Вставити зупинку + Record Stop Записати зупинку + Allow Continue Дозволити продовження + Continue Продовжити + Continue Action Продовжити дію + Delete Action Step Видалити крок дії + Press Play Current Selection in the Actions panel to go on. Щоб продовжити, натисніть «Відтворити поточне виділення» на панелі «Дії». + Stop: {message} Зупинка: {message} +@msg no action is waiting at a stop жодна дія не очікує на зупинці +@msg message must be text message має бути текстом +@msg allowContinue must be true or false allowContinue має бути true або false +@msg Batch cannot play an action with a stop Пакет не може відтворити дію із зупинкою Playback Options Параметри відтворення Accelerated Прискорено Step by Step Покроково diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 47915fb17..f470e83b8 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5393,6 +5393,19 @@ axis Y Axis Trục Y Action Options… Tùy chọn hành động… Action Options Tùy chọn hành động Playback Options… Tùy chọn phát lại… + Insert Stop… Chèn điểm dừng… + Insert Stop Chèn điểm dừng + Record Stop Ghi điểm dừng + Allow Continue Cho phép tiếp tục + Continue Tiếp tục + Continue Action Tiếp tục hành động + Delete Action Step Xóa bước hành động + Press Play Current Selection in the Actions panel to go on. Để tiếp tục, nhấn Phát vùng chọn hiện tại trong bảng Hành động. + Stop: {message} Điểm dừng: {message} +@msg no action is waiting at a stop không có hành động nào đang chờ ở điểm dừng +@msg message must be text message phải là văn bản +@msg allowContinue must be true or false allowContinue phải là true hoặc false +@msg Batch cannot play an action with a stop Hàng loạt không thể phát hành động có điểm dừng Playback Options Tùy chọn phát lại Accelerated Tăng tốc Step by Step Từng bước diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e646c956b..0f0f90caa 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5634,6 +5634,19 @@ axis Y Axis Y 轴 Action Options… 动作选项… Action Options 动作选项 Playback Options… 播放选项… + Insert Stop… 插入停止… + Insert Stop 插入停止 + Record Stop 记录停止 + Allow Continue 允许继续 + Continue 继续 + Continue Action 继续动作 + Delete Action Step 删除动作步骤 + Press Play Current Selection in the Actions panel to go on. 要继续,请按“动作”面板中的“播放当前选择项目”。 + Stop: {message} 停止:{message} +@msg no action is waiting at a stop 没有动作在停止处等待 +@msg message must be text message 必须是文本 +@msg allowContinue must be true or false allowContinue 必须是 true 或 false +@msg Batch cannot play an action with a stop 批处理无法播放含有停止的动作 Playback Options 播放选项 Accelerated 加速 Step by Step 逐步 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 468ad851a..ccb88d64c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5430,6 +5430,19 @@ axis Y Axis Y 軸 Action Options… 動作選項… Action Options 動作選項 Playback Options… 播放選項… + Insert Stop… 插入停止… + Insert Stop 插入停止 + Record Stop 記錄停止 + Allow Continue 允許繼續 + Continue 繼續 + Continue Action 繼續動作 + Delete Action Step 刪除動作步驟 + Press Play Current Selection in the Actions panel to go on. 若要繼續,請按「動作」面板中的「播放目前選取項目」。 + Stop: {message} 停止:{message} +@msg no action is waiting at a stop 沒有動作在停止處等待 +@msg message must be text message 必須是文字 +@msg allowContinue must be true or false allowContinue 必須是 true 或 false +@msg Batch cannot play an action with a stop 批次無法播放含有停止的動作 Playback Options 播放選項 Accelerated 加速 Step by Step 逐步 diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index dbee7082f..8ccb80c04 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -848,6 +848,9 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::actions::playback_options(app, p), crate::panels::actions::START_RECORDING => crate::panels::actions::record(app, p), crate::panels::actions::STOP_RECORDING => crate::panels::actions::stop(app, p), + crate::panels::actions::INSERT_STOP => crate::panels::actions::insert_stop(app, p), + crate::panels::actions::CONTINUE_PLAYBACK => crate::panels::actions::continue_playback(app, p), + crate::panels::actions::DELETE_STEP => crate::panels::actions::delete_step(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 988abad5b..41504f5e2 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1269,7 +1269,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::PLAY_ACTION, "Play Action", "", - "{action: name, set?: name} play the Actions panel's action named action at the speed ui.action.playbackOptions sets; set names the set to look in (default: every set). Accelerated plays it as one undoable batch → what command.batch returns. Step by Step and Pause For play its first step now and the others one at a time, each its own undo step, until the last, ui.action.stop, or a step that comes due while a different document is active than after the step before → {results: [the first step's result], pending: the steps left}. A name no action has, or one that more than one action in the sets looked in has, or an action played while another is still playing step by step or with pauses, is an error and nothing plays", + "{action: name, set?: name} play the Actions panel's action named action at the speed ui.action.playbackOptions sets; set names the set to look in (default: every set). Accelerated plays it as one undoable batch → what command.batch returns. Step by Step and Pause For play its first step now and the others one at a time, each its own undo step, until the last, ui.action.stop, or a step that comes due while a different document is active than after the step before → {results: [the first step's result], pending: the steps left}. An action with a stop, at any speed, plays up to the stop and waits until ui.action.continue or ui.action.stop; at Accelerated the steps between stops are one undoable batch each → {results, pending, atStop?: true when it already waits}. A name no action has, or one that more than one action in the sets looked in has, or an action played while another is still playing step by step or with pauses, is an error and nothing plays", ), ( crate::panels::actions::PLAYBACK_OPTIONS, @@ -1287,7 +1287,25 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ 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. It also ends an action playing step by step or with pauses: the steps played stay and the rest do not run. With nothing recording or playing it changes nothing → {stopped}", + "{} 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. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. With nothing recording or playing it changes nothing → {stopped}", + ), + ( + crate::panels::actions::INSERT_STOP, + "Insert Stop", + "", + "{set?: index, action?: index, step?: index, message?, allowContinue?: bool} insert a stop, at which a playing action waits and shows message. With set and action (0 is the top set or action), the stop goes into that action, after step, else after its last step; without them it goes into the recording in progress, after the steps recorded so far. message defaults to none, and allowContinue, which offers Continue in the message box, defaults to false. A stop in an action becomes the panel's selected step, and its action and set become the selected ones. An index past the last set, action or step, a param of another type, params that are not an object, or no action and no recording is an error and changes nothing. The panel menu's Insert Stop… (dialog `actionStopRecord` with message, allowContinue, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", + ), + ( + crate::panels::actions::CONTINUE_PLAYBACK, + "Continue Action", + "", + "{} play on from the stop the Actions panel's playing action waits at, on the document active now, and close the stop's message box (dialog `actionStopMessage`): the next step runs at once, or at Accelerated the steps up to the next stop as one undo step. The panel's Play Current Selection runs this while an action waits. With no action waiting at a stop it is an error → {results, pending, atStop?}", + ), + ( + crate::panels::actions::DELETE_STEP, + "Delete Action Step", + "", + "{set: index, action: index, step: index} delete one step, a stop or a command, of the Actions panel's action at action in set (0 is the top set, action or step). The steps after it keep whether they are excluded, and no step of that action stays selected. A Record Stop dialog open on that step closes, and one open on a later step of the action follows it up. A missing or invalid index is an error and changes nothing. Undo does not bring the step back. The panel's Delete Selection runs this for the selected step → {set, action, step}", ), ( "ui.batchDialog", diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 58ed08493..64c8e6fbc 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -61,6 +61,22 @@ impl Action { } } + /// Puts `step` at index `at`; the steps from there on keep whether they are excluded. An + /// exclusion past the last step, which saved preferences can hold, goes. + fn insert_step(&mut self, at: usize, step: (String, Value)) { + let len = self.steps.len(); + self.steps.insert(at, step); + self.excluded_steps = self.excluded_steps.iter().filter(|&&i| i < len).map(|&i| if i >= at { i + 1 } else { i }).collect(); + } + + /// Removes the step at index `at`; the steps after it keep whether they are excluded. An + /// exclusion past the last step, which saved preferences can hold, goes. + fn remove_step(&mut self, at: usize) { + let len = self.steps.len(); + self.steps.remove(at); + self.excluded_steps = self.excluded_steps.iter().filter(|&&i| i < len && i != at).map(|&i| if i > at { i - 1 } else { i }).collect(); + } + /// The included steps as `command.batch` takes them. A recorded `command.batch` step (an action /// played, or a panel operation that runs as one batch, while recording) gives its own steps, /// since batches do not nest. @@ -214,6 +230,10 @@ pub fn default_sets() -> Vec { /// returns. Step by Step and Pause For run the first step now, each as its own undo step, and leave /// the rest to [`tick_playback`] → {results: [the first step's result], pending: the steps left}. /// While a paced playback is in progress, another action does not play. +/// +/// An action with an included stop ([`STOP_STEP`]) plays up to the stop and waits there at any speed +/// ([`continue_playback`]). At Accelerated, the steps between two stops are one undo step. → {results: +/// [the results of the steps run], pending: the steps left, stops included, atStop: true when it waits} pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result { let Some(a) = app.ui.action_sets.get(set).and_then(|s| s.actions.get(idx)).cloned() else { return Err("no such action".into()) }; let commands = a.included_commands(); @@ -224,6 +244,9 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result Result Result, options: PlaybackOptions) -> Result { + let mut steps: std::collections::VecDeque = commands.into(); + let run = next_run(&mut steps, options.speed); + let ran = !run.is_empty(); + let results = if ran { play_steps(app, &label, run)? } else { vec![] }; + app.ui.action_playing = Some(Playing { label, steps, options, next_at: None, hold: false, doc: None, played_in: None, stop: None }); + after_run(app, ran); + Ok(playback_result(app, results)) +} + +/// Runs steps of a playback as one undo step, named `label` → the steps' results. +fn play_steps(app: &mut VectorcraftApp, label: &str, steps: Vec) -> Result, String> { + let r = app.run("command.batch", json!({"label": label, "commands": steps}))?; + Ok(r.get("results").and_then(Value::as_array).cloned().unwrap_or_default()) +} + +/// Takes the steps that run next as one undo step off the front of `steps`: at Accelerated all of +/// them up to the next stop, at the other speeds one. None when a stop comes next. +fn next_run(steps: &mut std::collections::VecDeque, speed: PlaybackSpeed) -> Vec { + let mut run = vec![]; + while steps.front().is_some_and(|s| !is_stop(s)) { + run.extend(steps.pop_front()); + if speed != PlaybackSpeed::Accelerated { + break; + } + } + run +} + +/// Sets up the playback in progress after steps ran (`ran`), or none did: the next pause and frame +/// start over, and a stop that comes next at Accelerated, or with no step run before it, holds now. +fn after_run(app: &mut VectorcraftApp, ran: bool) { + let doc = active_doc(app); + let played_in = app.tick_iteration.filter(|&t| t == app.host_iteration); + let Some(playing) = app.ui.action_playing.as_mut() else { return }; + playing.doc = doc; + playing.played_in = played_in; + playing.next_at = None; + playing.hold = ran && playing.options.speed == PlaybackSpeed::Step; + if (!ran || playing.options.speed == PlaybackSpeed::Accelerated) && playing.steps.front().is_some_and(is_stop) { + hold_at_stop(app); + } + if let Some(ctx) = &app.ui_ctx { + ctx.request_repaint(); + } +} + +/// What [`play`] and [`continue_playback`] return. +fn playback_result(app: &VectorcraftApp, results: Vec) -> Value { + let pending = app.ui.action_playing.as_ref().map_or(0, |p| p.steps.len()); + let mut result = json!({"results": results, "pending": pending}); + if waiting_at_stop(app) { + result["atStop"] = json!(true); + } + result +} + +/// The command id of a stop in [`Action::steps`], with {message, allowContinue} as its params. No +/// command runs under this id: playback holds where the step is. +pub const STOP_STEP: &str = "action.stop"; + +/// Whether a step as [`Action::included_commands`] gives it is a stop. +fn is_stop(step: &Value) -> bool { + step.get("command").and_then(Value::as_str) == Some(STOP_STEP) +} + +/// Whether one of the steps as [`Action::included_commands`] gives them is a stop. +pub(crate) fn has_stop(commands: &[Value]) -> bool { + commands.iter().any(is_stop) +} + +/// The message and Allow Continue setting in a stop's params; each may be left out. A saved stop +/// with no params reads as null here. A value of another type, or params that are neither null +/// nor an object, is an error. +pub(crate) fn stop_params(p: &Value) -> Result<(String, bool), String> { + if !(p.is_null() || p.is_object()) { + return Err("`params` must be an object".into()); + } + let message = match p.get("message") { + None => "", + Some(v) => v.as_str().ok_or("message must be text")?, + }; + let allow_continue = match p.get("allowContinue") { + None => false, + Some(v) => v.as_bool().ok_or("allowContinue must be true or false")?, + }; + Ok((message.to_string(), allow_continue)) +} + +/// The stop a playback waits at. A saved value of the wrong type reads as left out. +#[derive(Clone, Debug)] +struct AtStop { + message: String, + allow_continue: bool, + /// Whether the message box was shown. It waits for a dialog already open to close. + shown: bool, +} + +/// Takes the stop at the front of the playback's steps and waits there. A recording in progress +/// records the stop too. +fn hold_at_stop(app: &mut VectorcraftApp) { + let Some(playing) = app.ui.action_playing.as_mut() else { return }; + let Some(step) = playing.steps.pop_front() else { return }; + let params = step.get("params").cloned().unwrap_or(Value::Null); + let message = params.get("message").and_then(Value::as_str).unwrap_or_default().to_string(); + let allow_continue = params.get("allowContinue").and_then(Value::as_bool).unwrap_or(false); + playing.stop = Some(AtStop { message, allow_continue, shown: false }); + playing.hold = false; + if app.ui.recording.is_some() { + app.session.journal.push((STOP_STEP.to_string(), params)); + } + show_stop_message(app); +} + +/// Opens the message box of the stop the playback waits at, once, when no other dialog is open. +fn show_stop_message(app: &mut VectorcraftApp) { + if app.ui.dialog.is_some() { + return; + } + let Some(playing) = app.ui.action_playing.as_mut() else { return }; + let label = playing.label.clone(); + let Some(stop) = playing.stop.as_mut().filter(|s| !s.shown) else { return }; + stop.shown = true; + let (message, allow_continue) = (stop.message.clone(), stop.allow_continue); + crate::dialogs::action_stop::open_message(app, &label, &message, allow_continue); +} + +/// Closes the message box of a stop, if it is open. +fn close_stop_message(app: &mut VectorcraftApp) { + if app.ui.dialog.as_ref().is_some_and(|d| d.kind == crate::dialogs::action_stop::MESSAGE_KIND) { + app.ui.dialog = None; + } +} + +/// Whether the playback in progress waits at a stop. +pub fn waiting_at_stop(app: &VectorcraftApp) -> bool { + app.ui.action_playing.as_ref().is_some_and(|p| p.stop.is_some()) +} + +/// The command that plays on from a stop ([`continue_playback`]). +pub const CONTINUE_PLAYBACK: &str = "ui.action.continue"; + +/// `ui.action.continue {}`: plays on from the stop the playback waits at, on the document active +/// now, and closes the stop's message box. The next step runs at once, or at Accelerated the steps +/// up to the next stop as one undo step; a failing step ends the playback with its error. With no +/// playback waiting at a stop it is an error. → {results, pending, atStop?}, as [`play`] +pub fn continue_playback(app: &mut VectorcraftApp, _: &Value) -> Result { + let Some(playing) = app.ui.action_playing.as_mut().filter(|p| p.stop.is_some()) else { + return Err("no action is waiting at a stop".into()); + }; + playing.stop = None; + let run = next_run(&mut playing.steps, playing.options.speed); + let label = playing.label.clone(); + if playing.steps.is_empty() { + app.ui.action_playing = None; + } + close_stop_message(app); + let ran = !run.is_empty(); + let results = if ran { play_steps(app, &label, run).inspect_err(|_| app.ui.action_playing = None)? } else { vec![] }; + after_run(app, ran); + Ok(playback_result(app, results)) +} + /// The speed actions play at (Actions panel menu › Playback Options…). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -311,6 +499,9 @@ pub struct Playing { /// /// [`tick`]: VectorcraftApp::tick played_in: Option, + /// The stop the playback waits at, if any: no step runs until [`continue_playback`] or + /// [`stop`]. + stop: Option, } fn active_doc(app: &VectorcraftApp) -> Option { @@ -324,11 +515,15 @@ fn active_doc(app: &VectorcraftApp) -> Option { /// frame does not shorten it. A step that fails ends the playback and shows its error. When a step /// is due and the active document is not the one that was active after the last step (another /// document was made active, or that one was closed), the playback ends with a status and the rest -/// do not run. → the seconds until the window should draw again while playback goes on (none while +/// do not run, except that a stop is held whichever document is active. → the seconds until the window should draw again while playback goes on (none while /// Step by Step waits for a UI pass, which asks for the next frame itself) pub fn tick_playback(app: &mut VectorcraftApp, now: f64) -> Option { let doc = active_doc(app); let playing = app.ui.action_playing.as_mut()?; + if playing.stop.is_some() { + show_stop_message(app); + return None; + } if playing.hold { return None; } @@ -342,6 +537,10 @@ pub fn tick_playback(app: &mut VectorcraftApp, now: f64) -> Option { return Some(due - now); } } + if playing.steps.front().is_some_and(is_stop) { + hold_at_stop(app); + return None; + } if playing.doc != doc { app.ui.action_playing = None; app.status("the action stopped playing: its document is no longer active"); @@ -649,16 +848,114 @@ pub fn record(app: &mut VectorcraftApp, p: &Value) -> Result { /// `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. It also ends a playback at Step by Step or Pause For: the steps -/// already played stay, and the rest do not run. With nothing recording or playing it changes -/// nothing. → {stopped} +/// Stop Playing/Recording does. It also ends a playback at Step by Step or Pause For, or one that +/// waits at a stop, whose message box closes: the steps already played stay, and the rest do not +/// run. With nothing recording or playing it changes nothing. → {stopped} pub fn stop(app: &mut VectorcraftApp, _: &Value) -> Result { let playing = app.ui.action_playing.take().is_some(); + close_stop_message(app); let recording = app.ui.recording.is_some(); stop_recording(app); Ok(json!({ "stopped": recording || playing })) } +/// The command that inserts a stop ([`insert_stop`]). +pub const INSERT_STOP: &str = "ui.action.insertStop"; + +/// The panel state that holds the selected step as (the action's [`Action::id`], the step's index). +const SELECTED_STEP: &str = "actions-selected-step"; + +/// `ui.action.insertStop {set?, action?, step?, message?, allowContinue?}`: inserts a stop. With `set` +/// and `action`, it goes into that action, after step `step`, else after its last step. Without +/// them, it goes into the recording in progress, after the steps recorded so far. `message` is +/// the text playback shows (default: none), and `allowContinue` offers Continue in the message box +/// (default: false). An index past the last set, action or step, a param of another type, params +/// that are not an object, or no action and no recording is an error, and then nothing changes. +/// Record Stop's OK runs this. A stop in an action becomes the selected step, and its action and set +/// become the selected ones. → {set, action, step}, or {recording: true} +pub fn insert_stop(app: &mut VectorcraftApp, p: &Value) -> Result { + if !p.is_object() { + return Err("`params` must be an object".into()); + } + let (message, allow_continue) = stop_params(p)?; + let step = (STOP_STEP.to_string(), json!({"message": message, "allowContinue": allow_continue})); + let index = |k: &str| p.get(k).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + if p.get("set").is_none() && p.get("action").is_none() && p.get("step").is_none() { + if app.ui.recording.is_none() { + return Err("no such action".into()); + } + app.session.journal.push(step); + return Ok(json!({"recording": true})); + } + let (set, at) = index("set").zip(index("action")).ok_or("no such action")?; + let action = app.ui.action_sets.get_mut(set).and_then(|s| s.actions.get_mut(at)).ok_or("no such action")?; + let after = match p.get("step") { + None => action.steps.len(), + Some(_) => index("step").filter(|&s| s < action.steps.len()).ok_or("no such action step")? + 1, + }; + action.insert_step(after, step); + let id = action.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, at))); + set_pstate::>(&ctx, SELECTED_STEP, Some((id, after))); + let mut expanded: BTreeSet = pstate(&ctx, "actions-expanded"); + expanded.insert(id); + set_pstate(&ctx, "actions-expanded", expanded); + } + Ok(json!({"set": set, "action": at, "step": after})) +} + +/// The selected step of the action `selected`, if it has one and the panel shows its steps. +fn selected_step(app: &VectorcraftApp, ctx: &egui::Context, selected: Option<(usize, usize)>) -> Option { + let (s, a) = selected?; + let action = app.ui.action_sets.get(s)?.actions.get(a)?; + if !pstate::>(ctx, "actions-expanded").contains(&action.id) { + return None; + } + pstate::>(ctx, SELECTED_STEP).filter(|&(id, step)| id == action.id && step < action.steps.len()).map(|(_, step)| step) +} + +/// The command that deletes one step of an action ([`delete_step`]). +pub const DELETE_STEP: &str = "ui.action.deleteStep"; + +/// `ui.action.deleteStep {set, action, step}`: deletes step `step`, a stop or a command, of the +/// action at index `action` of set `set`. The steps after it keep whether they are excluded, and +/// no step of that action stays selected. A Record Stop dialog open on that step closes, and one +/// open on a later step of the action follows it up. A missing or invalid index is an error, and +/// then nothing changes. Undo does not bring the step back. Delete Selection runs this for the +/// selected step. → {set, action, step} +pub fn delete_step(app: &mut VectorcraftApp, p: &Value) -> Result { + let number = |v: &Value| v.as_u64().and_then(|i| usize::try_from(i).ok()); + let index = |k: &str| p.get(k).and_then(number); + let (set, at) = index("set").zip(index("action")).ok_or("no such action")?; + let action = app.ui.action_sets.get_mut(set).and_then(|s| s.actions.get_mut(at)).ok_or("no such action")?; + let step = index("step").filter(|&s| s < action.steps.len()).ok_or("no such action step")?; + action.remove_step(step); + let id = action.id; + if let Some(ctx) = app.ui_ctx.clone() + && pstate::>(&ctx, SELECTED_STEP).is_some_and(|(selected, _)| selected == id) + { + set_pstate::>(&ctx, SELECTED_STEP, None); + } + let held = app + .ui + .dialog + .as_ref() + .filter(|d| d.kind == crate::dialogs::action_stop::RECORD_KIND) + .filter(|d| d.fields.get("set").and_then(number) == Some(set) && d.fields.get("action").and_then(number) == Some(at)) + .and_then(|d| d.fields.get("step").and_then(number)); + if held == Some(step) { + app.ui.dialog = None; + } else if let Some(later) = held.filter(|&s| s > step) + && let Some(d) = app.ui.dialog.as_mut() + { + d.fields.insert("step".into(), json!(later - 1)); + } + Ok(json!({"set": set, "action": at, "step": step})) +} + /// 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 { @@ -786,12 +1083,20 @@ fn selected_sets(app: &VectorcraftApp) -> Vec { if current.is_some_and(|c| sets.contains(&c)) { sets } else { current.into_iter().collect() } } -/// Delete Selection, from the bottom bar and the panel menu: deletes the selected action, else asks -/// whether to delete the selected sets ([`DELETE_SETS`]). +/// Delete Selection, from the bottom bar and the panel menu: deletes the selected step +/// ([`DELETE_STEP`]), else the selected action, else asks whether to delete the selected sets +/// ([`DELETE_SETS`]). fn delete_selection(app: &mut VectorcraftApp, ctx: &egui::Context) { const SELECTED: &str = "actions-selected"; // A selected action counts only while its set is the current one, as the panel shows it. - if let Some((si, ai)) = pstate::>(ctx, SELECTED).filter(|&(s, _)| app.ui.action_set == Some(s)) { + let selected = pstate::>(ctx, SELECTED).filter(|&(s, _)| app.ui.action_set == Some(s)); + if let Some(((set, action), step)) = selected.zip(selected_step(app, ctx, selected)) { + if let Err(e) = app.run(DELETE_STEP, json!({"set": set, "action": action, "step": step})) { + app.status(e); + } + return; + } + if let Some((si, ai)) = selected { if let Some(s) = app.ui.action_sets.get_mut(si) && ai < s.actions.len() { @@ -885,6 +1190,7 @@ pub fn delete_sets(app: &mut VectorcraftApp, p: &Value) -> Result fn holds_set(d: &crate::state::Dialog) -> bool { d.kind == crate::dialogs::action_batch::KIND || d.kind == crate::dialogs::action_new::KIND + || d.kind == crate::dialogs::action_stop::RECORD_KIND || (d.kind == crate::dialogs::action_set_options::KIND && d.fields.contains_key("set")) } @@ -934,8 +1240,9 @@ pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result { 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. - if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == crate::dialogs::action_batch::KIND) + // A Batch or Record Stop dialog open on a later action of the set follows it down. + if let Some(d) = + app.ui.dialog.as_mut().filter(|d| d.kind == crate::dialogs::action_batch::KIND || d.kind == crate::dialogs::action_stop::RECORD_KIND) && d.fields.get("set").and_then(index) == Some(set) && let Some(da) = d.fields.get("action").and_then(index).filter(|&da| da > a) { @@ -998,11 +1305,17 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut egui::Ui) { { app.status(e); } - if widgets::menu_item(ui, tl!("Play Current Selection"), has, false) - && let Some((s, a)) = selected - && let Err(e) = play(app, s, a) - { - app.status(e); + let waiting = waiting_at_stop(app); + if widgets::menu_item(ui, tl!("Play Current Selection"), has || waiting, false) { + let played = if waiting { app.run(CONTINUE_PLAYBACK, json!({})) } else { selected.map_or(Ok(Value::Null), |(s, a)| play(app, s, a)) }; + if let Err(e) = played { + app.status(e); + } + } + let recording = app.ui.recording.is_some(); + if widgets::menu_item(ui, tl!("Insert Stop…"), recording || has, false) { + let step = selected_step(app, ui.ctx(), selected); + crate::dialogs::action_stop::open_record(app, if recording { None } else { selected }, step); } if widgets::menu_item(ui, tl!("Action Options…"), has, false) && let Some((s, a)) = selected @@ -1075,6 +1388,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { if selected.is_some_and(|(s, _)| app.ui.action_set != Some(s)) { selected = None; } + // The selected step as (the action's id, the step), of the selected action only. + let mut step_selected = + selected_step(app, ui.ctx(), selected).zip(selected).and_then(|(step, (s, a))| Some((app.ui.action_sets[s].actions.get(a)?.id, step))); let mut current = app.ui.action_set; let mut sets_selected = selected_sets(app); let shift = ui.input(|i| i.modifiers.shift); @@ -1115,6 +1431,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } } selected = None; + step_selected = None; } for (ai, a) in set.actions.iter().enumerate() { let sel = selected == Some((si, ai)); @@ -1158,6 +1475,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { ); if resp.clicked() && !resp.interact_pointer_pos().is_some_and(|p| check_rect.contains(p) || disclosure.contains(p)) { selected = Some((si, ai)); + step_selected = None; current = Some(si); sets_selected = vec![si]; } @@ -1166,18 +1484,34 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } if open { for (step, (command, params)) in a.steps.iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 20.0), egui::Sense::hover()); - if resp.hovered() { + let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 20.0), egui::Sense::click()); + if step_selected == Some((a.id, step)) { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } - let (clicked, _) = inclusion_box(ui, r, 55.0, ("step-inclusion", a.id, step), !a.excluded_steps.contains(&step), false); + let (clicked, check_rect) = + inclusion_box(ui, r, 55.0, ("step-inclusion", a.id, step), !a.excluded_steps.contains(&step), false); if clicked { inclusion_changes.push(InclusionChange::Step(si, ai, step, alt)); } + if resp.clicked() && !resp.interact_pointer_pos().is_some_and(|p| check_rect.contains(p)) { + selected = Some((si, ai)); + step_selected = Some((a.id, step)); + current = Some(si); + sets_selected = vec![si]; + } + // A stop's row shows its message; other steps show their command. + let shown = if command == STOP_STEP { + let message = params.get("message").and_then(Value::as_str).and_then(|m| m.lines().next()).unwrap_or_default(); + crate::i18n::fmt(tl!("Stop: {message}"), &[("message", message)]) + } else { + command.clone() + }; ui.painter().text( r.left_center() + egui::vec2(70.0, 0.0), egui::Align2::LEFT_CENTER, - command, + shown, egui::FontId::proportional(11.0), t.text_dim, ); @@ -1189,6 +1523,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } } }); + if waiting_at_stop(app) { + dim_label(ui, tl!("Press Play Current Selection in the Actions panel to go on.")); + } for change in inclusion_changes { let params = match change { InclusionChange::Set(si) => { @@ -1211,6 +1548,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } set_pstate(ui.ctx(), "actions-expanded", expanded); set_pstate(ui.ctx(), SELECTED, selected); + set_pstate(ui.ctx(), SELECTED_STEP, step_selected); app.ui.action_set = current; app.ui.action_sets_selected = sets_selected; widgets::divider(ui); @@ -1230,10 +1568,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { { app.status(e); } - if widgets::icon_button(ui, "dc-actions", tl!("Play Current Selection"), false, 24.0).clicked() - && let Some(s) = selected - { - play_req = Some(s); + if widgets::icon_button(ui, "dc-actions", tl!("Play Current Selection"), false, 24.0).clicked() { + if waiting_at_stop(app) { + if let Err(e) = app.run(CONTINUE_PLAYBACK, json!({})) { + app.status(e); + } + } else if let Some(s) = selected { + play_req = Some(s); + } } if widgets::icon_button(ui, "dc-folder", tl!("Create New Set"), false, 24.0).clicked() { crate::dialogs::action_set_options::open_new(app); @@ -2304,7 +2646,7 @@ mod tests { (json!(long.to_string()), "an action in the action set file has more than 10,000 steps"), (json!(" ".repeat(files::MAX_SET_FILE + 1)), "the action set file is larger than 8 MiB"), ( - json!("{\"format\": \"vcactions\", \"version\": 3, \"name\": \"S\", \"actions\": []}"), + json!("{\"format\": \"vcactions\", \"version\": 4, \"name\": \"S\", \"actions\": []}"), "the action set file needs a later version of VectorCraft", ), ] { @@ -3318,4 +3660,592 @@ mod tests { assert_eq!(frame(&mut app, 0.0), None); assert_eq!(x(&app), 1.0, "the step after the failing one does not run"); } + + /// [`app_with_three_moves`] with a stop after the first move. + fn app_with_a_stop(allow_continue: bool) -> (VectorcraftApp, impl Fn(&VectorcraftApp) -> f64) { + let (mut app, x) = app_with_three_moves(); + let params = json!({"set": 0, "action": 0, "step": 0, "message": "Paint it", "allowContinue": allow_continue}); + assert_eq!(app.run(INSERT_STOP, params).unwrap(), json!({"set": 0, "action": 0, "step": 1})); + (app, x) + } + + fn open_kind(app: &VectorcraftApp) -> Option<&str> { + app.ui.dialog.as_ref().map(|d| d.kind.as_str()) + } + + #[test] + fn insert_stop_goes_after_the_given_step_or_at_the_end_and_keeps_exclusions() { + let (mut app, _) = app_with_three_moves(); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([0, 2]); + app.run(INSERT_STOP, json!({"set": 0, "action": 0, "step": 0, "message": "After the first"})).unwrap(); + app.run(INSERT_STOP, json!({"set": 0, "action": 0})).unwrap(); + let action = &app.ui.action_sets[0].actions[0]; + let ids: Vec<&str> = action.steps.iter().map(|(c, _)| c.as_str()).collect(); + assert_eq!(ids, ["object.move", STOP_STEP, "object.move", "object.move", STOP_STEP]); + assert_eq!(action.steps[1].1, json!({"message": "After the first", "allowContinue": false})); + assert_eq!(action.steps[4].1, json!({"message": "", "allowContinue": false}), "the message and Allow Continue are optional"); + assert_eq!(action.excluded_steps, BTreeSet::from([0, 3]), "the step excluded after the insertion point moved with it"); + + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([1, usize::MAX]); + app.run(INSERT_STOP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[0].excluded_steps, BTreeSet::from([2]), "an exclusion past the last step is dropped"); + } + + #[test] + fn insert_stop_refuses_bad_params_and_changes_nothing() { + let (mut app, _) = app_with_three_moves(); + for (params, want) in [ + (json!({"message": "m"}), "no such action"), + (json!({"set": 0}), "no such action"), + (json!({"action": 0}), "no such action"), + (json!({"step": 0}), "no such action"), + (json!({"set": 1, "action": 0}), "no such action"), + (json!({"set": 0, "action": 1}), "no such action"), + (json!({"set": "0", "action": 0}), "no such action"), + (json!({"set": 0, "action": null}), "no such action"), + (json!({"set": 0, "action": 0, "step": 3}), "no such action step"), + (json!({"set": 0, "action": 0, "step": -1}), "no such action step"), + (json!({"set": 0, "action": 0, "step": null}), "no such action step"), + (json!({"set": 0, "action": 0, "message": 5}), "message must be text"), + (json!({"set": 0, "action": 0, "message": null}), "message must be text"), + (json!({"set": 0, "action": 0, "allowContinue": "yes"}), "allowContinue must be true or false"), + (json!(5), "`params` must be an object"), + (json!([1]), "`params` must be an object"), + (json!("message"), "`params` must be an object"), + (json!(null), "`params` must be an object"), + ] { + assert_eq!(app.run(INSERT_STOP, params.clone()).unwrap_err(), want, "{params}"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "{params}"); + } + app.run(START_RECORDING, json!({})).unwrap(); + assert_eq!(app.run(INSERT_STOP, json!({"message": 5})).unwrap_err(), "message must be text"); + for params in [json!(5), json!([1]), json!(true), json!(null)] { + assert_eq!(app.run(INSERT_STOP, params.clone()).unwrap_err(), "`params` must be an object", "{params}"); + } + app.run(STOP_RECORDING, json!({})).unwrap(); + assert!(app.ui.action_sets[0].actions.last().unwrap().steps.is_empty(), "the refused stop is not recorded"); + } + + #[test] + fn delete_step_removes_one_step_and_moves_the_exclusions_up() { + let (mut app, _) = app_with_a_stop(false); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([0, 1, 3, usize::MAX]); + assert_eq!(app.run(DELETE_STEP, json!({"set": 0, "action": 0, "step": 1})).unwrap(), json!({"set": 0, "action": 0, "step": 1})); + let action = &app.ui.action_sets[0].actions[0]; + let ids: Vec<&str> = action.steps.iter().map(|(c, _)| c.as_str()).collect(); + assert_eq!(ids, ["object.move", "object.move", "object.move"]); + assert_eq!(action.excluded_steps, BTreeSet::from([0, 2]), "the deleted step's exclusion goes and a later one moves up"); + } + + #[test] + fn delete_step_refuses_bad_params_and_changes_nothing() { + let (mut app, _) = app_with_three_moves(); + for (params, want) in [ + (json!({}), "no such action"), + (json!(5), "no such action"), + (json!([1]), "no such action"), + (json!({"set": 1, "action": 0, "step": 0}), "no such action"), + (json!({"set": 0, "action": 1, "step": 0}), "no such action"), + (json!({"set": "0", "action": 0, "step": 0}), "no such action"), + (json!({"set": 0, "action": 0}), "no such action step"), + (json!({"set": 0, "action": 0, "step": 3}), "no such action step"), + (json!({"set": 0, "action": 0, "step": -1}), "no such action step"), + (json!({"set": 0, "action": 0, "step": null}), "no such action step"), + ] { + assert_eq!(app.run(DELETE_STEP, params.clone()).unwrap_err(), want, "{params}"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "{params}"); + } + } + + #[test] + fn delete_step_keeps_an_open_record_stop_on_its_place() { + let (mut app, _) = app_with_three_moves(); + crate::dialogs::action_stop::open_record(&mut app, Some((0, 0)), Some(2)); + app.run(DELETE_STEP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["step"], json!(1), "a deleted step above moves it up"); + app.run(DELETE_STEP, json!({"set": 0, "action": 0, "step": 1})).unwrap(); + assert_eq!(open_kind(&app), None, "deleting its step closes it"); + } + + /// Selects the first step of "Three Moves" by clicking its row, with the action open. + fn click_first_step(ctx: &egui::Context, app: &mut VectorcraftApp) { + use super::super::tests_appearance::{click, frame_events}; + set_pstate(ctx, "actions-expanded", BTreeSet::from([app.ui.action_sets[0].actions[0].id])); + let texts = frame_events(ctx, app, vec![], show); + let first = texts.iter().filter(|(s, _)| s == "object.move").map(|(_, r)| *r).min_by(|a, b| a.top().total_cmp(&b.top())).unwrap(); + click(ctx, app, first.center(), show); + } + + #[test] + fn delete_selection_on_a_selected_step_deletes_that_step_and_keeps_the_action() { + use super::super::tests_appearance::click; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([2]); + click_first_step(&ctx, &mut app); + let delete = trash(&ctx, &mut app); + click(&ctx, &mut app, delete, show); + let action = &app.ui.action_sets[0].actions[0]; + assert_eq!(action.name, "Three Moves", "the action stays"); + assert_eq!(action.steps.len(), 2, "one step goes"); + assert_eq!(action.excluded_steps, BTreeSet::from([1])); + assert_eq!(pstate::>(&ctx, SELECTED_STEP), None, "no step stays selected"); + assert_eq!(pstate::>(&ctx, "actions-selected"), Some((0, 0)), "the action stays selected"); + + let delete = trash(&ctx, &mut app); + click(&ctx, &mut app, delete, show); + assert!(app.ui.action_sets[0].actions.is_empty(), "with no step selected, the selected action goes"); + } + + #[test] + fn the_panel_menus_delete_deletes_the_selected_step_and_a_stop_inserted_there() { + use super::super::tests_appearance::{click, frame_events}; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + click_first_step(&ctx, &mut app); + menu_insert_stop(&ctx, &mut app, "Paint it", false); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 4); + let texts = frame_events(&ctx, &mut app, vec![], menu); + let item = texts.iter().find(|(s, _)| s.trim() == "Delete").map(|(_, r)| *r).expect("Delete in the menu"); + click(&ctx, &mut app, item.center(), menu); + let action = &app.ui.action_sets[0].actions[0]; + assert!(action.steps.iter().all(|(c, _)| c == "object.move"), "the new stop was selected, and it goes"); + assert_eq!(action.steps.len(), 3); + } + + #[test] + fn a_stop_inserted_into_another_action_selects_it_and_delete_selection_deletes_the_stop() { + use super::super::tests_appearance::click; + let (mut app, _) = app_with_three_moves(); + let step = ("object.move".to_string(), json!({"dx": 8, "dy": 0})); + app.ui.action_sets[0].actions.push(Action::new("Same Set", vec![step.clone()])); + app.ui.action_sets.push(ActionSet::new("More", vec![Action::new("Other Set", vec![step])])); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + for (set, at) in [(0, 1), (1, 0)] { + app.ui.action_set = Some(0); + app.ui.action_sets_selected = vec![0]; + set_pstate::>(&ctx, "actions-selected", Some((0, 0))); + app.run(INSERT_STOP, json!({"set": set, "action": at, "message": "Paint it"})).unwrap(); + assert_eq!(app.ui.action_set, Some(set)); + assert_eq!(app.ui.action_sets_selected, vec![set]); + let selected = pstate::>(&ctx, "actions-selected"); + assert_eq!(selected, Some((set, at)), "the stop's action is the selected one"); + assert_eq!(selected_step(&app, &ctx, selected), Some(1), "the new stop is the selected step"); + let delete = trash(&ctx, &mut app); + click(&ctx, &mut app, delete, show); + let action = &app.ui.action_sets[set].actions[at]; + assert!(action.steps.iter().all(|(c, _)| c == "object.move"), "the stop goes"); + assert_eq!(action.steps.len(), 1, "the command stays"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "the action selected before keeps its steps"); + } + assert_eq!(app.ui.action_sets[0].actions.len(), 2); + assert_eq!(app.ui.action_sets[1].actions.len(), 1); + } + + #[test] + fn a_step_in_a_closed_action_is_not_the_selection() { + use super::super::tests_appearance::click; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + click_first_step(&ctx, &mut app); + set_pstate(&ctx, "actions-expanded", BTreeSet::::new()); + let delete = trash(&ctx, &mut app); + click(&ctx, &mut app, delete, show); + assert!(app.ui.action_sets[0].actions.is_empty(), "the step's row is hidden and the action's row is the one highlighted"); + } + + #[test] + fn a_stop_inserted_while_recording_goes_at_the_recording_position() { + let (mut app, _) = app_with_three_moves(); + app.run(START_RECORDING, json!({})).unwrap(); + app.run("object.move", json!({"dx": 1, "dy": 0})).unwrap(); + assert_eq!(app.run(INSERT_STOP, json!({"message": "Now", "allowContinue": true})).unwrap(), json!({"recording": true})); + app.run("object.move", json!({"dx": 2, "dy": 0})).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + let recorded = app.ui.action_sets[0].actions.last().unwrap(); + let ids: Vec<&str> = recorded.steps.iter().map(|(c, _)| c.as_str()).collect(); + assert_eq!(ids, ["object.move", STOP_STEP, "object.move"]); + assert_eq!(recorded.steps[1].1, json!({"message": "Now", "allowContinue": true})); + } + + #[test] + fn accelerated_playback_holds_at_a_stop_and_play_plays_the_rest() { + let (mut app, x) = app_with_a_stop(false); + let undo = undo_steps(&app); + let r = play(&mut app, 0, 0).unwrap(); + assert_eq!((r["pending"].clone(), r["atStop"].clone(), r["results"].as_array().map(Vec::len)), (json!(2), json!(true), Some(1))); + assert_eq!(x(&app), 1.0, "the step before the stop runs"); + let d = app.ui.dialog.as_ref().expect("the stop shows its message"); + assert_eq!( + (d.kind.as_str(), d.str("action"), d.str("message"), d.bool("allowContinue")), + (crate::dialogs::action_stop::MESSAGE_KIND, "Three Moves".to_string(), "Paint it".to_string(), false) + ); + for now in [0.0, 100.0, 1000.0] { + assert_eq!(frame(&mut app, now), None); + assert_eq!(x(&app), 1.0, "no later step runs while the action waits"); + } + assert_eq!(play(&mut app, 0, 0).unwrap_err(), "an action is already playing"); + let r = app.run(CONTINUE_PLAYBACK, json!({})).unwrap(); + assert_eq!((r["pending"].clone(), r["atStop"].clone()), (json!(0), Value::Null)); + assert_eq!(x(&app), 7.0, "Play runs every step after the stop"); + assert!(app.ui.action_playing.is_none()); + assert_eq!(open_kind(&app), None, "the message box closes"); + assert_eq!(undo_steps(&app), undo + 2, "the steps on each side of the stop are one undo step each"); + assert_eq!(app.run(CONTINUE_PLAYBACK, json!({})).unwrap_err(), "no action is waiting at a stop"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(x(&app), 1.0, "one undo takes back the steps after the stop"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(x(&app), 0.0, "and one more the steps before it"); + } + + #[test] + fn step_by_step_and_pause_for_hold_at_a_stop() { + for (speed, seconds) in [("step", 1), ("pause", 2)] { + let (mut app, x) = app_with_a_stop(false); + app.run(PLAYBACK_OPTIONS, json!({"speed": speed, "seconds": seconds})).unwrap(); + let r = play(&mut app, 0, 0).unwrap(); + assert_eq!((r["pending"].clone(), r["atStop"].clone()), (json!(3), Value::Null), "{speed}"); + assert_eq!(open_kind(&app), None, "{speed}: the stop comes after the step before it is shown"); + let mut now = 0.0; + while !waiting_at_stop(&app) { + assert!(now < 100.0, "{speed}: the playback reaches the stop"); + frame(&mut app, now); + now += 1.0; + } + assert_eq!(open_kind(&app), Some(crate::dialogs::action_stop::MESSAGE_KIND), "{speed}"); + for _ in 0..5 { + assert_eq!(frame(&mut app, now + 1000.0), None, "{speed}"); + assert_eq!(x(&app), 1.0, "{speed}: no later step runs while the action waits"); + } + let r = app.run(CONTINUE_PLAYBACK, json!({})).unwrap(); + assert_eq!(r["pending"], json!(1), "{speed}: Play runs the next step, and the last stays for its turn"); + assert_eq!(x(&app), 3.0, "{speed}"); + for _ in 0..20 { + if app.ui.action_playing.is_none() { + break; + } + frame(&mut app, now); + now += 10.0; + } + assert_eq!(x(&app), 7.0, "{speed}: the paced playback goes on to the end"); + assert!(app.ui.action_playing.is_none(), "{speed}"); + } + } + + #[test] + fn a_paced_playback_holds_at_a_stop_when_another_document_became_active() { + let (mut app, _) = app_with_a_stop(false); + app.run(PLAYBACK_OPTIONS, json!({"speed": "pause", "seconds": 1})).unwrap(); + play(&mut app, 0, 0).unwrap(); + assert_eq!(tick_playback(&mut app, 0.0), Some(1.0)); + app.run("file.new", json!({"width": 50, "height": 50})).unwrap(); + app.run("shape.rectangle", json!({"x": 1, "y": 1, "width": 5, "height": 5})).unwrap(); + assert_eq!(tick_playback(&mut app, 1.0), None); + assert!(waiting_at_stop(&app), "the stop holds the playback, as it does at Accelerated"); + assert_ne!(app.ui.status, "the action stopped playing: its document is no longer active"); + let r = app.run(CONTINUE_PLAYBACK, json!({})).unwrap(); + assert_eq!(r["pending"], json!(1), "Continue runs the next step in the document active now"); + } + + #[test] + fn allow_continue_offers_continue_and_stop_closes_the_box_and_keeps_waiting() { + let (mut app, x) = app_with_a_stop(true); + play(&mut app, 0, 0).unwrap(); + assert!(dialog_texts(&mut app).iter().any(|s| s == "Continue"), "Allow Continue shows Continue"); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(x(&app), 7.0, "Continue plays the rest"); + assert!(app.ui.action_playing.is_none()); + assert_eq!(open_kind(&app), None); + + let (mut app, x) = app_with_a_stop(false); + play(&mut app, 0, 0).unwrap(); + let texts = dialog_texts(&mut app); + assert!(!texts.iter().any(|s| s == "Continue"), "without Allow Continue there is no Continue: {texts:?}"); + assert!(texts.iter().any(|s| s == "Paint it") && texts.iter().any(|s| s == "Stop"), "{texts:?}"); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(open_kind(&app), None, "the box's only button closes it"); + assert!(waiting_at_stop(&app), "the action waits for the manual task"); + assert_eq!(x(&app), 1.0); + frame(&mut app, 100.0); + assert_eq!(open_kind(&app), None, "the box does not open again"); + app.run(CONTINUE_PLAYBACK, json!({})).unwrap(); + assert_eq!(x(&app), 7.0); + + let (mut app, x) = app_with_a_stop(true); + play(&mut app, 0, 0).unwrap(); + crate::dialogs::cancel(&mut app); + assert!(waiting_at_stop(&app), "Stop closes the box and the action waits"); + assert_eq!(x(&app), 1.0); + } + + #[test] + fn play_in_the_panel_plays_on_from_a_stop_with_nothing_selected() { + use super::super::tests_appearance::{click, frame_events}; + let (mut app, x) = app_with_a_stop(false); + play(&mut app, 0, 0).unwrap(); + crate::dialogs::cancel(&mut app); + let ctx = egui::Context::default(); + let texts = frame_events(&ctx, &mut app, vec![], show); + assert!(texts.iter().any(|(s, _)| s.contains("Play Current Selection")), "the panel says how to go on: {texts:?}"); + 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_button, _, _, _] = bar[..] else { panic!("the bottom bar: {bar:?}") }; + click(&ctx, &mut app, play_button.center(), show); + assert_eq!(x(&app), 7.0); + assert!(app.ui.action_playing.is_none()); + + let (mut app, x) = app_with_a_stop(false); + play(&mut app, 0, 0).unwrap(); + crate::dialogs::cancel(&mut app); + let texts = frame_events(&ctx, &mut app, vec![], menu); + let item = texts.iter().find(|(s, _)| s.trim() == "Play Current Selection").map(|(_, r)| *r).expect("the item in the menu"); + click(&ctx, &mut app, item.center(), menu); + assert_eq!(x(&app), 7.0, "the panel menu's item plays on too, with nothing selected"); + } + + #[test] + fn stop_at_a_stop_ends_the_playback_and_keeps_the_steps_run() { + let (mut app, x) = app_with_a_stop(false); + let undo = undo_steps(&app); + play(&mut app, 0, 0).unwrap(); + assert_eq!(app.run(STOP_RECORDING, json!({})).unwrap(), json!({"stopped": true})); + assert!(app.ui.action_playing.is_none()); + assert_eq!(open_kind(&app), None, "the message box closes"); + assert_eq!(frame(&mut app, 100.0), None); + assert_eq!(x(&app), 1.0, "the steps after the stop do not run; the one played stays"); + assert_eq!(undo_steps(&app), undo + 1); + assert_eq!(app.run(CONTINUE_PLAYBACK, json!({})).unwrap_err(), "no action is waiting at a stop"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(x(&app), 0.0, "the step played undoes"); + play(&mut app, 0, 0).unwrap(); + assert!(waiting_at_stop(&app), "the action plays again after Stop"); + } + + #[test] + fn stops_first_last_and_in_a_row_each_wait() { + let (mut app, x) = app_with_three_moves(); + let stop = |message: &str| (STOP_STEP.to_string(), json!({"message": message, "allowContinue": false})); + let moved = ("object.move".to_string(), json!({"dx": 1, "dy": 0})); + app.ui.action_sets[0].actions[0].steps = vec![stop("first"), stop("second"), moved, stop("last")]; + let message = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.str("message")); + assert_eq!(play(&mut app, 0, 0).unwrap()["atStop"], json!(true)); + assert_eq!((message(&app).as_deref(), x(&app)), (Some("first"), 0.0)); + assert_eq!(app.run(CONTINUE_PLAYBACK, json!({})).unwrap()["atStop"], json!(true)); + assert_eq!((message(&app).as_deref(), x(&app)), (Some("second"), 0.0)); + assert_eq!(app.run(CONTINUE_PLAYBACK, json!({})).unwrap()["atStop"], json!(true)); + assert_eq!((message(&app).as_deref(), x(&app)), (Some("last"), 1.0)); + assert_eq!(app.run(CONTINUE_PLAYBACK, json!({})).unwrap(), json!({"results": [], "pending": 0})); + assert!(app.ui.action_playing.is_none(), "after the last stop the playback ends"); + assert_eq!(message(&app), None); + } + + #[test] + fn a_stop_with_an_empty_message_still_waits() { + let (mut app, x) = app_with_three_moves(); + app.run(INSERT_STOP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + play(&mut app, 0, 0).unwrap(); + assert!(waiting_at_stop(&app)); + assert_eq!(x(&app), 1.0); + assert_eq!(open_kind(&app), Some(crate::dialogs::action_stop::MESSAGE_KIND)); + } + + #[test] + fn an_excluded_stop_does_not_hold_and_batch_refuses_an_included_one() { + let (mut app, x) = app_with_a_stop(false); + assert_eq!(crate::dialogs::action_batch::open(&mut app, 0, 0).unwrap_err(), "Batch cannot play an action with a stop"); + assert_eq!(open_kind(&app), None); + app.run(INCLUDE_STEPS, json!({"set": 0, "action": 0, "step": 1, "included": false})).unwrap(); + crate::dialogs::action_batch::open(&mut app, 0, 0).unwrap(); + let d = app.ui.dialog.take().unwrap(); + app.run(INCLUDE_STEPS, json!({"set": 0, "action": 0, "step": 1, "included": true})).unwrap(); + app.ui.dialog = Some(d); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap_err(), "Batch cannot play an action with a stop", "a stop included since it opened"); + app.ui.dialog = None; + app.run(INCLUDE_STEPS, json!({"set": 0, "action": 0, "step": 1, "included": false})).unwrap(); + play(&mut app, 0, 0).unwrap(); + assert!(app.ui.action_playing.is_none()); + assert_eq!(x(&app), 7.0, "the excluded stop does not hold the playback"); + } + + #[test] + fn a_stop_nested_in_a_recorded_batch_holds_too() { + let (mut app, x) = app_with_three_moves(); + let moved = json!({"command": "object.move", "params": {"dx": 1, "dy": 0}}); + let stop = json!({"command": STOP_STEP, "params": {"message": "Inner", "allowContinue": false}}); + app.ui.action_sets[0].actions[0].steps = vec![("command.batch".into(), json!({"commands": [moved, stop, moved]}))]; + play(&mut app, 0, 0).unwrap(); + assert!(waiting_at_stop(&app)); + assert_eq!(x(&app), 1.0); + app.run(CONTINUE_PLAYBACK, json!({})).unwrap(); + assert_eq!(x(&app), 2.0); + } + + #[test] + fn a_stop_reached_while_recording_is_recorded() { + let (mut app, _) = app_with_a_stop(false); + app.run(START_RECORDING, json!({})).unwrap(); + play(&mut app, 0, 0).unwrap(); + app.run(CONTINUE_PLAYBACK, json!({})).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + let recorded = app.ui.action_sets[0].actions.last().unwrap(); + let ids: Vec<&str> = recorded.steps.iter().map(|(c, _)| c.as_str()).collect(); + assert_eq!(ids, ["command.batch", STOP_STEP, "command.batch"]); + assert_eq!(recorded.steps[1].1, json!({"message": "Paint it", "allowContinue": false})); + } + + #[test] + fn the_message_waits_for_a_dialog_already_open() { + let (mut app, _) = app_with_a_stop(false); + crate::dialogs::action_playback::open(&mut app); + play(&mut app, 0, 0).unwrap(); + assert!(waiting_at_stop(&app)); + frame(&mut app, 0.0); + assert_eq!(open_kind(&app), Some(crate::dialogs::action_playback::KIND), "the open dialog stays"); + crate::dialogs::cancel(&mut app); + frame(&mut app, 0.0); + assert_eq!(open_kind(&app), Some(crate::dialogs::action_stop::MESSAGE_KIND), "the message shows once it closes"); + } + + #[test] + fn play_on_from_a_stop_uses_the_document_active_then() { + let (mut app, x) = app_with_a_stop(false); + play(&mut app, 0, 0).unwrap(); + app.run("file.new", json!({"width": 50, "height": 50})).unwrap(); + app.run("document.activate", json!({"index": 0})).unwrap(); + app.run(CONTINUE_PLAYBACK, json!({})).unwrap(); + assert_eq!(x(&app), 7.0); + let (mut app, _) = app_with_a_stop(false); + app.run(PLAYBACK_OPTIONS, json!({"speed": "step"})).unwrap(); + play(&mut app, 0, 0).unwrap(); + frame(&mut app, 0.0); + frame(&mut app, 0.0); + assert!(waiting_at_stop(&app)); + app.run("file.new", json!({"width": 50, "height": 50})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run(CONTINUE_PLAYBACK, json!({})).unwrap(); + assert!(app.ui.action_playing.is_some(), "a document opened at the stop does not end the playback"); + let bounds = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + assert_eq!(bounds.x0, 2.0, "the next step runs on the document active now"); + } + + #[test] + fn a_saved_stop_survives_and_older_files_still_load() { + let (mut app, _) = app_with_a_stop(true); + let saved = files::write_set(&app.ui.action_sets[0]); + let file: Value = serde_json::from_str(&saved).unwrap(); + assert_eq!(file["version"], json!(3), "older builds must refuse a stop"); + let steps = file["actions"][0]["steps"].clone(); + assert_eq!(steps[1], json!({"command": "action.stop", "params": {"message": "Paint it", "allowContinue": true}})); + let loaded = files::read_set(&saved, "Test").unwrap(); + assert_eq!(loaded.actions[0].steps, app.ui.action_sets[0].actions[0].steps); + let kept: Action = serde_json::from_value(serde_json::to_value(&loaded.actions[0]).unwrap()).unwrap(); + assert_eq!(kept.steps[1].1, json!({"message": "Paint it", "allowContinue": true}), "the preferences keep it too"); + app.ui.action_sets[0].actions[0].steps.remove(1); + let without = files::write_set(&app.ui.action_sets[0]); + assert_eq!(serde_json::from_str::(&without).unwrap()["version"], json!(1), "a set with no stop stays version 1"); + for version in [1, 2] { + let old = + json!({"format": "vcactions", "version": version, "name": "Old", "actions": [{"name": "A", "steps": [{"command": "object.move"}]}]}); + assert_eq!(files::read_set(&old.to_string(), "Test").unwrap().actions[0].steps.len(), 1, "version {version}"); + } + let later = json!({"format": "vcactions", "version": 4, "name": "New", "actions": []}); + assert_eq!(files::read_set(&later.to_string(), "Test").unwrap_err(), "the action set file needs a later version of VectorCraft"); + for params in [json!({"message": 5}), json!({"allowContinue": "yes"}), json!(42)] { + let stop = json!({"command": "action.stop", "params": params}); + for step in [stop.clone(), json!({"command": "command.batch", "params": {"commands": [stop]}})] { + let bad = json!({"format": "vcactions", "version": 3, "name": "Bad", "actions": [{"name": "A", "steps": [step]}]}); + assert_eq!(files::read_set(&bad.to_string(), "Test").unwrap_err(), "not an action set file", "{step}"); + } + } + let bare = json!({"format": "vcactions", "version": 3, "name": "Bare", "actions": [{"name": "A", "steps": [{"command": "action.stop"}]}]}); + assert!(files::read_set(&bare.to_string(), "Test").is_ok(), "a stop may leave its params out"); + let nested = json!({"commands": [{"command": "action.stop", "params": {"message": "Inner"}}]}); + let set = ActionSet::new("Nested", vec![Action::new("A", vec![("command.batch".into(), nested)])]); + assert_eq!(serde_json::from_str::(&files::write_set(&set)).unwrap()["version"], json!(3), "a stop in a recorded batch counts"); + } + + /// The panel menu's Insert Stop…, with Record Stop's fields as given, and OK. + fn menu_insert_stop(ctx: &egui::Context, app: &mut VectorcraftApp, message: &str, allow_continue: bool) { + use super::super::tests_appearance::{click, frame_events}; + let texts = frame_events(ctx, app, vec![], menu); + let item = texts.iter().find(|(s, _)| s.trim() == "Insert Stop…").map(|(_, r)| *r).expect("Insert Stop… in the menu"); + click(ctx, app, item.center(), menu); + let d = app.ui.dialog.as_mut().expect("Insert Stop… opens Record Stop"); + assert_eq!(d.kind, crate::dialogs::action_stop::RECORD_KIND); + d.fields.insert("message".into(), json!(message)); + d.fields.insert("allowContinue".into(), json!(allow_continue)); + crate::dialogs::confirm(app).unwrap(); + assert!(app.ui.dialog.is_none(), "OK closes Record Stop"); + } + + #[test] + fn insert_stop_in_the_menu_goes_after_the_selected_step_or_action() { + use super::super::tests_appearance::{click, frame_events, text_rect}; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + let texts = frame_events(&ctx, &mut app, vec![], show); + let label = text_rect(&texts, "Three Moves"); + click(&ctx, &mut app, label.center(), show); + menu_insert_stop(&ctx, &mut app, "At the end", false); + let ids = |app: &VectorcraftApp| app.ui.action_sets[0].actions[0].steps.iter().map(|(c, _)| c.clone()).collect::>(); + assert_eq!(ids(&app).iter().position(|c| c == STOP_STEP), Some(3), "the action selected: the stop goes at its end"); + assert!(pstate::>(&ctx, "actions-expanded").contains(&app.ui.action_sets[0].actions[0].id), "the action opens to show it"); + + let texts = frame_events(&ctx, &mut app, vec![], show); + assert!(texts.iter().any(|(s, _)| s == "Stop: At the end"), "the step's row shows the message: {texts:?}"); + let first = texts.iter().filter(|(s, _)| s == "object.move").map(|(_, r)| *r).min_by(|a, b| a.top().total_cmp(&b.top())).unwrap(); + click(&ctx, &mut app, first.center(), show); + assert_eq!(pstate::>(&ctx, "actions-selected"), Some((0, 0)), "a click on a step selects its action"); + menu_insert_stop(&ctx, &mut app, "After the first", true); + assert_eq!(app.ui.action_sets[0].actions[0].steps[1].1, json!({"message": "After the first", "allowContinue": true})); + + menu_insert_stop(&ctx, &mut app, "Next", false); + assert_eq!(app.ui.action_sets[0].actions[0].steps[2].1["message"], json!("Next"), "the new stop was the selected step"); + + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "Three Moves").center(), show); + menu_insert_stop(&ctx, &mut app, "End again", false); + assert_eq!(app.ui.action_sets[0].actions[0].steps.last().unwrap().1["message"], json!("End again"), "a click on the action clears the step"); + } + + #[test] + fn insert_stop_in_the_menu_goes_into_the_recording_and_needs_something_to_go_into() { + use super::super::tests_appearance::{click, frame_events}; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + let texts = frame_events(&ctx, &mut app, vec![], menu); + let item = texts.iter().find(|(s, _)| s.trim() == "Insert Stop…").map(|(_, r)| *r).expect("Insert Stop… in the menu"); + click(&ctx, &mut app, item.center(), menu); + assert!(app.ui.dialog.is_none(), "nothing selected and nothing recording: the item is off"); + + app.run(START_RECORDING, json!({})).unwrap(); + menu_insert_stop(&ctx, &mut app, "While recording", false); + app.run(STOP_RECORDING, json!({})).unwrap(); + let recorded = app.ui.action_sets[0].actions.last().unwrap(); + assert_eq!(recorded.steps, [(STOP_STEP.to_string(), json!({"message": "While recording", "allowContinue": false}))]); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3); + } + + #[test] + fn set_edits_keep_an_open_record_stop_on_its_action() { + let mut app = app_with_sets(); + crate::dialogs::action_stop::open_record(&mut app, Some((1, 0)), None); + app.run(DUPLICATE, json!({"set": 0})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["set"], json!(2), "a copied set above moves it down"); + app.run(DELETE_SETS, json!({"sets": [0]})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["set"], json!(1), "a deleted set above moves it up"); + app.run(DELETE_SETS, json!({"sets": [1]})).unwrap(); + assert_eq!(open_kind(&app), None, "deleting its set closes it"); + } } diff --git a/crates/ui-egui/src/panels/actions/files.rs b/crates/ui-egui/src/panels/actions/files.rs index ead40505f..4e5092cef 100644 --- a/crates/ui-egui/src/panels/actions/files.rs +++ b/crates/ui-egui/src/panels/actions/files.rs @@ -1,12 +1,14 @@ //! Action set files: the Actions panel menu's Save Actions… and Load Actions…. //! -//! An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1 or 2, "name", +//! An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1, 2 or 3, "name", //! "actions": [{"name", "steps": [{"command", "params", "excluded"?}], "key"?, "color"?}]}`. Each step is a -//! command id and its params, as `command.batch` takes them. `key` is the action's function key +//! command id and its params, as `command.batch` takes them, or a stop: the command `action.stop` +//! with `{"message", "allowContinue"}`. `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. `excluded: true` keeps a step but skips it during //! playback. Sets with exclusions use version 2 so older builds refuse them rather than replay -//! excluded steps. Sets without exclusions use version 1, and earlier version 1 files still work. +//! excluded steps, and sets with stops use version 3 so older builds refuse them rather than fail +//! at a stop. Other sets use version 1, and earlier version 1 and 2 files still work. use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -67,6 +69,19 @@ fn is_false(value: &bool) -> bool { !value } +/// The params of the stops in a step: a stop's own, or those of the stops a `command.batch` step +/// holds, which playback runs as its own steps. +fn stops_in<'a>(command: &str, params: &'a Value) -> Vec<&'a Value> { + if command == super::STOP_STEP { + return vec![params]; + } + if command != "command.batch" { + return vec![]; + } + let steps = params.get("commands").and_then(Value::as_array).into_iter().flatten(); + steps.filter(|step| super::is_stop(step)).map(|step| step.get("params").unwrap_or(&Value::Null)).collect() +} + /// `set` as the text of an action set file. pub fn write_set(set: &ActionSet) -> String { let actions = set @@ -84,7 +99,12 @@ pub fn write_set(set: &ActionSet) -> String { color: a.color.clone(), }) .collect(); - let version = if set.actions.iter().any(|a| !a.excluded_steps.is_empty()) { 2 } else { 1 }; + let has_stop = set.actions.iter().flat_map(|a| &a.steps).any(|(command, params)| !stops_in(command, params).is_empty()); + let version = match (has_stop, set.actions.iter().any(|a| !a.excluded_steps.is_empty())) { + (true, _) => 3, + (false, true) => 2, + (false, false) => 1, + }; let file = SetFile { format: ACTIONS_EXT.into(), version, name: set.name.clone(), actions }; serde_json::to_string_pretty(&file).unwrap_or_default() } @@ -98,7 +118,7 @@ pub fn read_set(text: &str, name: &str) -> Result { if f.format != ACTIONS_EXT { return Err("not an action set file".into()); } - if !matches!(f.version, 1 | 2) { + if !matches!(f.version, 1..=3) { return Err("the action set file needs a later version of VectorCraft".into()); } if f.actions.len() > MAX_SET_ACTIONS { @@ -107,6 +127,10 @@ 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 mut stops = f.actions.iter().flat_map(|a| &a.steps).flat_map(|s| stops_in(&s.command, &s.params)); + if stops.any(|p| super::stop_params(p).is_err()) { + return Err("not an action set file".into()); + } let actions = f .actions .into_iter() diff --git a/docs/mcp.md b/docs/mcp.md index aeb3c12e6..8ddd96d57 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1852,11 +1852,13 @@ already walks Image Trace. Short agent entry points: the file at `path`, else a picked one, or the file's text `data`, adds its set after the other sets and returns `{set, name, actions}`. A set named like one already in the panel gets the next free number (`Print 2`), and the other sets and the current set stay as they are. An action set file (`.vcactions`) is JSON: - `{"format": "vcactions", "version": 1 or 2, "name", "actions": [{"name", "steps": [{"command", "params", "excluded"?}]}]}`, each step - a command id and its params as `command.batch` takes them. `excluded: true` preserves a step but omits it from playback; - an absent or false flag includes the step. Files with excluded steps use version 2, so older builds refuse them - instead of playing the excluded steps; files without exclusions use version 1. A file with another `format` or a `version` other than - 1 or 2, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are + `{"format": "vcactions", "version": 1, 2 or 3, "name", "actions": [{"name", "steps": [{"command", "params", "excluded"?}]}]}`, each step + a command id and its params as `command.batch` takes them, or a stop: the command `action.stop` with + `{message, allowContinue}`. `excluded: true` preserves a step but omits it from playback; + an absent or false flag includes the step. Files with stops use version 3, and files with excluded steps and no stops + use version 2, so older builds refuse them + instead of playing the excluded steps or failing at a stop; other files use version 1. A file with another `format` or a `version` other than + 1, 2 or 3, a stop whose message is not text or whose `allowContinue` is not true or false, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are 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 @@ -1899,7 +1901,29 @@ already walks Image Trace. Short agent entry points: action has, or that more than one action in the sets looked in has, or whose every step is excluded, is an error, and then nothing plays. So is an action played while another is still playing at Step by Step or Pause For. A `command.batch` step, which an action played or a panel operation run as one batch records, plays as its own steps, - here and in Batch…. + here and in Batch…. An action with an included stop plays up to the stop at any speed and then waits, with its + message box open, until `ui.action.continue` or `ui.action.stop`. At Accelerated the steps between two stops are one + undoable batch each. The result is then `{results, pending, atStop?}`, with `atStop: true` when the action already + waits. Batch… refuses an action with an included stop, and a stop that is excluded does not hold playback. +- Actions: `ui.action.insertStop {set?, action?, step?, message?, allowContinue?}` inserts a stop. With `set` and + `action`, the stop goes into that action, after step `step`, else after its last step, and the command returns + `{set, action, step}`. Without them it goes into the recording in progress, after the steps recorded so far, and + returns `{recording: true}`. `message` is the text playback shows (default: none), and `allowContinue` (default: + false) offers Continue in the message box. An action with an empty message still waits at the stop, which is + VectorCraft's own rule. A stop in an action becomes the panel's selected step, and its action and set become the + selected ones. An index past the last set, action or step, a param of another type, params that are not an + object, or no action and no recording is an error, and then nothing changes. The panel menu's Insert Stop… opens the `actionStopRecord` dialog + (`message`, `allowContinue`, `set`, `action`, `step`), whose OK runs this. +- Actions: `ui.action.continue {}` plays on from the stop the playing action waits at, on the document active now, and + closes the stop's message box (`actionStopMessage`). The next step runs at once, or at Accelerated the steps up to + the next stop as one undo step. It returns `{results, pending, atStop?}`. With no action waiting at a stop it is an + error. The panel's Play Current Selection runs this while an action waits. In the message box, Continue (shown with + Allow Continue) runs this, and Stop closes the box while the action keeps waiting for the manual task. +- Actions: `ui.action.deleteStep {set, action, step}` deletes one step, a stop or a command, of the action at `action` + in set `set` (0 is the top set, action or step), and returns `{set, action, step}`. The steps after it keep whether + they are excluded, and no step of that action stays selected. A Record Stop dialog open on that step closes, and one + open on a later step of the action follows it up. A missing or invalid index is an error, and then nothing changes. + Undo does not restore a deleted step. The panel's Delete Selection runs this for the selected step. - Actions: `ui.action.playbackOptions {speed?, seconds?}` sets how the Actions panel plays actions and returns `{speed, seconds}`. `speed` is `accelerated` (the default: every step at once), `step` (one step at a time, with the window drawn after each step before the next runs) or `pause` (a pause of `seconds` between steps). `seconds` is a @@ -1909,14 +1933,16 @@ already walks Image Trace. Short agent entry points: this. Batch… (`action.batch`) plays every step at once whatever the setting. - 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}`. It also ends an action - playing at Step by Step or Pause For: the steps already played stay, and the rest do not play. With nothing + playing at Step by Step or Pause For, or one waiting at a stop, whose message box closes: the steps already played + stay, and the rest do not play. With nothing recording or playing 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 sets with these ids are deleted wherever they are now and `sets` is not read; when one of them no longer exists, the command returns an error and deletes no set. A recording into a deleted set stops and keeps no steps; a recording into another set - goes on in that set. The panel's Delete Selection (the trash button and the panel menu's Delete) asks before it runs + goes on in that set. The panel's Delete Selection (the trash button and the panel menu's Delete) deletes a selected + step (`ui.action.deleteStep`), else a selected action. Otherwise it asks before it runs this for the selected sets, and Shift-click on a set's name selects the sets from the current one to it. Undo does not restore a deleted set. `ui.actionSet.duplicate {set?: index, action?: index}` copies the action at `action` in set `set` to right after it diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4d397eee3..bb928bea3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -632,6 +632,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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: expand an action to show its recorded steps, then clear a step's checkmark to keep it in the action but skip it during playback. The action and set checkmarks include or exclude all their steps; a mixed checkmark includes all. Alt/Option-clicking a step checkmark keeps that step as it was and applies the opposite inclusion state to the other steps in its action. This follows the public [Exclude commands from an action](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/exclude-commands-from-actions.html) guide; the scope of the mixed-state and Alt-click behavior is VectorCraft's design. The exclusion survives UI restart, action duplication, and `.vcactions` save/load. Existing version 1 action set files load with every step included. - Actions panel: the panel menu's Playback Options… sets how actions play: Accelerated (the default) plays every step at once, Step by Step plays one step at a time with the canvas drawn after each, and Pause For waits the given number of seconds between steps ([Specify playback speed](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/specify-playback-speed.html); `ui.action.playbackOptions`). VectorCraft's own rules, which the page does not cover: the pause is a whole number of seconds from 1 to 60, the setting is kept with the UI preferences, a paced playback ends at Stop Playing/Recording or when a step comes due while another document is active, each step of a paced playback is its own undo step, and Batch… plays at full speed. + - Actions panel: the panel menu's Insert Stop… opens Record Stop, with a Message box and Allow Continue, and adds a stop to the recording in progress, after the selected command, or at the end of the selected action ([Insert stops in actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-stops-in-actions.html); `ui.action.insertStop`). A playing action waits at the stop and shows the message so the user can do a manual task, and Play Current Selection plays the rest ([Illustrator CS5 help](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=459); `ui.action.continue`). With Allow Continue, the message box also has Continue. VectorCraft's own rules, which the pages do not cover: an empty message still waits, Stop Playing/Recording ends the playback and keeps the steps already played, the rest plays on the document active then, a stop waits at every playback speed, at Accelerated the steps between two stops are one undo step, Batch… refuses an action with an included stop, and Delete Selection on a selected step deletes that step, a stop or a command, without asking (`ui.action.deleteStep`). - 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); `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. - Actions panel: the panel menu's Duplicate copies the selected action, else the current action set with all its actions ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.duplicate`). 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. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used.