diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index df2627dfc2..8836b72ac2 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1477,13 +1477,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.action.record", "Begin Recording", "", - "{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}", + "{set?: index, action?: index, after?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. With action, record into the action at action in set instead: ui.action.stop puts the recorded steps after step after of that action, else after its last step, and the steps after them keep whether they are excluded. The panel's Begin Recording button passes the selected action and its selected step. action without set, after without action, an index past the last set, action or step, an explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}, or with action {set, action, name}", ), ( "ui.action.stop", "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, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. 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 or the action ui.action.record named, at the place it named, 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. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. With nothing recording or playing it changes nothing → {stopped}", ), ( "ui.action.insertStop", @@ -1521,6 +1521,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{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.action.moveStep", + "Move Action Step", + "", + "{set: index, action: index, step: index, toSet?: index, toAction?: index, before: index} move 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) into the action at toAction in toSet (default: the same set and action), before the step at index before there, or after its last step when before is its number of steps; before counts the steps as they are before the move. The step keeps its params and whether it is excluded, and the other steps keep theirs. A selected step stays selected, and a moved selected step makes its new action the selected one and its set the current one. A Record Stop dialog open on a step follows the step, and the steps that the recording in progress adds to either action stay after the steps that were before them. A step put back just before or just after itself does not move. A missing or invalid index, or a move that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. Undo does not move the step back. Dragging a step row in the panel runs this → {set, action, step, moved}", + ), ( "ui.batchDialog", "Batch…", diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 5d4fcc8299..63793b2a14 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6018,6 +6018,7 @@ axis Y Axis Y ос Continue Продължаване Continue Action Продължаване на действието Delete Action Step Изтриване на стъпка от действието + Move Action Step Преместване на стъпка от действието Press Play Current Selection in the Actions panel to go on. За да продължите, натиснете „Изпълнение на текущия избор“ в панела „Действия“. Stop: {message} Спиране: {message} @msg no action is waiting at a stop няма действие, което чака при спиране diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index e19cab9861..7cdc13ef04 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6060,6 +6060,7 @@ axis Y Axis Y osa Continue Nastavi Continue Action Nastavi akciju Delete Action Step Izbriši korak akcije + Move Action Step Premjesti 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 diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cc5a9950c5..a8a7a935c4 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4056,6 +4056,7 @@ digits Malayalam Malaiàlam Continue Continua Continue Action Continua l’acció Delete Action Step Suprimeix el pas de l’acció + Move Action Step Mou 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 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6f5021064a..99a8cc206e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6556,6 +6556,7 @@ axis Y Axis osa Y Continue Pokračovat Continue Action Pokračovat v akci Delete Action Step Odstranit krok akce + Move Action Step Přesunout 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í diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 14f36c81d0..2491617e05 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6034,6 +6034,7 @@ axis Y Axis Y-akse Continue Fortsæt Continue Action Fortsæt handling Delete Action Step Slet handlingstrin + Move Action Step Flyt 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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f232378cd7..0cfbef6877 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6148,6 +6148,7 @@ axis Y Axis Y-Achse Continue Fortsetzen Continue Action Aktion fortsetzen Delete Action Step Aktionsschritt löschen + Move Action Step Aktionsschritt verschieben 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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 45e7fa3148..380c38eba5 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6508,6 +6508,7 @@ axis Y Axis Άξονας Y Continue Συνέχεια Continue Action Συνέχεια ενέργειας Delete Action Step Διαγραφή βήματος ενέργειας + Move Action Step Μετακίνηση βήματος ενέργειας Press Play Current Selection in the Actions panel to go on. Για να συνεχίσετε, πατήστε «Αναπαραγωγή τρέχουσας επιλογής» στο πάνελ Ενέργειες. Stop: {message} Διακοπή: {message} @msg no action is waiting at a stop καμία ενέργεια δεν περιμένει σε διακοπή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1e90ef37ec..06774c405f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6499,6 +6499,7 @@ axis Y Axis Eje Y Continue Continuar Continue Action Continuar acción Delete Action Step Eliminar paso de la acción + Move Action Step Mover 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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 8d174a2996..29790f3678 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6586,6 +6586,7 @@ axis Y Axis Y-akseli Continue Jatka Continue Action Jatka toimintoa Delete Action Step Poista toiminnon vaihe + Move Action Step Siirrä 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ä diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 18098aa72b..9301fb20a5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6551,6 +6551,7 @@ axis Y Axis Axe Y Continue Continuer Continue Action Continuer le script Delete Action Step Supprimer l’étape du script + Move Action Step Déplacer 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 diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 42c01b2e04..71b0db9ad9 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3482,6 +3482,7 @@ antiAlias Sharp शार्प Continue जारी रखें Continue Action एक्शन जारी रखें Delete Action Step एक्शन का चरण हटाएं + Move Action Step एक्शन का चरण खिसकाएं Press Play Current Selection in the Actions panel to go on. आगे बढ़ने के लिए एक्शन्स पैनल में वर्तमान सिलेक्शन प्ले करें दबाएँ। Stop: {message} स्टॉप: {message} @msg no action is waiting at a stop कोई एक्शन स्टॉप पर प्रतीक्षा नहीं कर रहा है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e584c9979d..7f973648a0 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6539,6 +6539,7 @@ axis Y Axis Y tengely Continue Folytatás Continue Action Művelet folytatása Delete Action Step Műveletlépés törlése + Move Action Step Műveletlépés áthelyezé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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 70d498a939..22c21ae13b 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6034,6 +6034,7 @@ axis Y Axis Sumbu Y Continue Lanjutkan Continue Action Lanjutkan tindakan Delete Action Step Hapus langkah tindakan + Move Action Step Pindahkan 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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 2ed65a03b6..7334de3f72 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6182,6 +6182,7 @@ axis Y Axis Asse Y Continue Continua Continue Action Continua azione Delete Action Step Elimina passaggio dell’azione + Move Action Step Sposta 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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 08615b5cc2..88bf2260c9 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6248,6 +6248,7 @@ axis Y Axis Y 軸 Continue 続行 Continue Action アクションを続行 Delete Action Step アクションステップを削除 + Move Action Step アクションステップを移動 Press Play Current Selection in the Actions panel to go on. 続行するには、アクションパネルの「選択項目を再生」を押します。 Stop: {message} 停止: {message} @msg no action is waiting at a stop 停止で待機中のアクションはありません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 89a138cf76..d00f547c85 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6507,6 +6507,7 @@ axis Y Axis Y ღერძი Continue გაგრძელება Continue Action მოქმედების გაგრძელება Delete Action Step მოქმედების ნაბიჯის წაშლა + Move Action Step მოქმედების ნაბიჯის გადატანა Press Play Current Selection in the Actions panel to go on. გასაგრძელებლად მოქმედებების პანელზე დააჭირეთ „მიმდინარე მონიშნულის შესრულებას“. Stop: {message} გაჩერება: {message} @msg no action is waiting at a stop გაჩერებაზე არცერთი მოქმედება არ ელოდება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 661e1c5f21..bf7ea07259 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6143,6 +6143,7 @@ axis Y Axis Y축 Continue 계속 Continue Action 액션 계속 Delete Action Step 액션 단계 삭제 + Move Action Step 액션 단계 이동 Press Play Current Selection in the Actions panel to go on. 계속하려면 액션 패널에서 현재 선택 항목 재생을 누르세요. Stop: {message} 중지: {message} @msg no action is waiting at a stop 중지에서 대기 중인 액션이 없습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 41a969e6e1..c0661c0cba 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6651,6 +6651,7 @@ axis Y Axis Y-as Continue Doorgaan Continue Action Actie voortzetten Delete Action Step Actiestap verwijderen + Move Action Step Actiestap verplaatsen 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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a76691d29b..2e6b36347f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6411,6 +6411,7 @@ axis Y Axis Oś Y Continue Kontynuuj Continue Action Kontynuuj operację Delete Action Step Usuń krok operacji + Move Action Step Przenieś 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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0d429d3b72..80a8e0c0a7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6471,6 +6471,7 @@ axis Y Axis Eixo Y Continue Continuar Continue Action Continuar Ação Delete Action Step Excluir Etapa da Ação + Move Action Step Mover 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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4c45cf5a88..e181e5e06e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6167,6 +6167,7 @@ axis Y Axis Ось Y Continue Продолжить Continue Action Продолжить действие Delete Action Step Удалить шаг действия + Move Action Step Переместить шаг действия Press Play Current Selection in the Actions panel to go on. Чтобы продолжить, нажмите «Воспроизвести текущее выделение» на панели «Действия». Stop: {message} Остановка: {message} @msg no action is waiting at a stop ни одно действие не ожидает на остановке diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index cd8d438051..82714338c9 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6537,6 +6537,7 @@ axis Y Axis Y-axel Continue Fortsätt Continue Action Fortsätt åtgärd Delete Action Step Radera åtgärdssteg + Move Action Step Flytta å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 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index cc4e20310f..ce7ca98480 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6512,6 +6512,7 @@ axis Y Axis Y ekseni Continue Devam Et Continue Action Eyleme Devam Et Delete Action Step Eylem Adımını Sil + Move Action Step Eylem Adımını Taşı 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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9e30180bcf..c18ef65906 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6162,6 +6162,7 @@ axis Y Axis Вісь Y Continue Продовжити Continue Action Продовжити дію Delete Action Step Видалити крок дії + Move Action Step Перемістити крок дії Press Play Current Selection in the Actions panel to go on. Щоб продовжити, натисніть «Відтворити поточне виділення» на панелі «Дії». Stop: {message} Зупинка: {message} @msg no action is waiting at a stop жодна дія не очікує на зупинці diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 194f709509..75a7fcd1b1 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6163,6 +6163,7 @@ axis Y Axis Trục Y Continue Tiếp tục Continue Action Tiếp tục hành động Delete Action Step Xóa bước hành động + Move Action Step Di chuyển 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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 886d2129a8..187f66ac49 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6395,6 +6395,7 @@ axis Y Axis Y 轴 Continue 继续 Continue Action 继续动作 Delete Action Step 删除动作步骤 + Move Action Step 移动动作步骤 Press Play Current Selection in the Actions panel to go on. 要继续,请按“动作”面板中的“播放当前选择项目”。 Stop: {message} 停止:{message} @msg no action is waiting at a stop 没有动作在停止处等待 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 64a62d0725..ac443e978f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6201,6 +6201,7 @@ axis Y Axis Y 軸 Continue 繼續 Continue Action 繼續動作 Delete Action Step 刪除動作步驟 + Move Action Step 移動動作步驟 Press Play Current Selection in the Actions panel to go on. 若要繼續,請按「動作」面板中的「播放目前選取項目」。 Stop: {message} 停止:{message} @msg no action is waiting at a stop 沒有動作在停止處等待 diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index efa4e95ac4..fa6b17a61b 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -989,6 +989,7 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::actions::insert_select_object(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), + crate::panels::actions::MOVE_STEP => crate::panels::actions::move_step(app, p), "ui.batchDialog" => { let index = |k: &str| opt_index(p, k).map(|i| i.unwrap_or(0)); index("set").and_then(|set| Ok((set, index("action")?))).and_then(|(set, action)| crate::dialogs::action_batch::open(app, set, action)) diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 5f6da1ca36..37cdcb2d56 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1478,13 +1478,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::START_RECORDING, "Begin Recording", "", - "{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}", + "{set?: index, action?: index, after?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. With action, record into the action at action in set instead: ui.action.stop puts the recorded steps after step after of that action, else after its last step, and the steps after them keep whether they are excluded. The panel's Begin Recording button passes the selected action and its selected step. action without set, after without action, an index past the last set, action or step, an explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}, or with action {set, action, name}", ), ( 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, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. 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 or the action ui.action.record named, at the place it named, 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. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. With nothing recording or playing it changes nothing → {stopped}", ), ( crate::panels::actions::INSERT_STOP, @@ -1522,6 +1522,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{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}", ), + ( + crate::panels::actions::MOVE_STEP, + "Move Action Step", + "", + "{set: index, action: index, step: index, toSet?: index, toAction?: index, before: index} move 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) into the action at toAction in toSet (default: the same set and action), before the step at index before there, or after its last step when before is its number of steps; before counts the steps as they are before the move. The step keeps its params and whether it is excluded, and the other steps keep theirs. A selected step stays selected, and a moved selected step makes its new action the selected one and its set the current one. A Record Stop dialog open on a step follows the step, and the steps that the recording in progress adds to either action stay after the steps that were before them. A step put back just before or just after itself does not move. A missing or invalid index, or a move that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. Undo does not move the step back. Dragging a step row in the panel runs this → {set, action, step, moved}", + ), ( "ui.batchDialog", "Batch…", diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 4f42eea008..1971834a07 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -894,16 +894,27 @@ pub const STOP_RECORDING: &str = "ui.action.stop"; /// The command that begins a plain recording ([`record`]). pub const START_RECORDING: &str = "ui.action.record"; -/// `ui.action.record {set?}`: begins recording an automatically named Action N in `set`, or in -/// the current set (the first if none is current). With no sets, makes Set 1. Stop adds the action -/// after the set's existing actions. An explicit invalid set, an existing recording, or a set whose -/// new action would pass a limit of Load Actions… ([`edit_keeps_set_loadable`]: 1,000 actions, -/// 8 MiB) is an error and leaves the panel unchanged. While it records, a command whose step does not fit in the action -/// ([`recording_room`]) does not run. → {set, name} +/// `ui.action.record {set?, action?, after?}`: begins recording an automatically named Action N in +/// `set`, or in the current set (the first if none is current). With no sets, makes Set 1. Stop +/// adds the action after the set's existing actions. With `action`, it records into the action at +/// that index of set `set` instead: Stop puts the recorded steps after step `after` of that action, +/// else after its last step, and the steps after them keep whether they are excluded. The Begin +/// Recording button passes the selected action and step. `action` without `set`, `after` without +/// `action`, an index past the last set, action or step, an explicit invalid set, an existing +/// recording, or a set whose new action would pass a limit of Load Actions… +/// ([`edit_keeps_set_loadable`]: 1,000 actions, 8 MiB) is an error and leaves the panel unchanged. +/// While it records, a command whose step does not fit in the action ([`recording_room`]) does not +/// run. → {set, name}, and with `action` {set, action, name} pub fn record(app: &mut VectorcraftApp, p: &Value) -> Result { if app.ui.recording.is_some() { return Err("an action is already recording".into()); } + if p.get("action").is_some() { + return record_into(app, p); + } + if p.get("after").is_some() { + return Err("no such action".into()); + } let set = match p.get("set") { Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|&i| i < app.ui.action_sets.len()).ok_or("no such action set")?, _ => { @@ -931,8 +942,32 @@ pub fn record(app: &mut VectorcraftApp, p: &Value) -> Result { Ok(json!({ "set": set, "name": name })) } +/// [`record`] with `action`: records into the action at index `action` of set `set`, after step +/// `after` or after its last step. The set becomes current and the only selected set, and the +/// action stays selected. → {set, action, name} +fn record_into(app: &mut VectorcraftApp, p: &Value) -> Result { + let index = |k: &str| p.get(k).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + let (set, at) = + index("set").zip(index("action")).filter(|&(s, a)| app.ui.action_sets.get(s).is_some_and(|s| a < s.actions.len())).ok_or("no such action")?; + let action = &app.ui.action_sets[set].actions[at]; + let slot = match p.get("after") { + None => None, + Some(_) => Some(index("after").filter(|&s| s < action.steps.len()).ok_or("no such action step")? + 1), + }; + let (id, name) = (action.id, action.name.clone()); + app.ui.action_set = Some(set); + app.ui.action_sets_selected = vec![set]; + start_recording(app, set, name.clone(), Some(id)); + app.ui.recording_at = slot; + if let Some(ctx) = app.ui_ctx.clone() { + set_pstate::>(&ctx, "actions-selected", Some((set, at))); + } + Ok(json!({ "set": set, "action": at, "name": name })) +} + /// `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 +/// Action made for it or the action [`record`] named, at the place it named, 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, 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. Recorded steps past the limits of an @@ -951,6 +986,13 @@ 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"; +/// The panel state that holds the step being dragged as a [`DraggedStep`]. +const DRAGGED_STEP: &str = "actions-dragged-step"; + +/// A step being dragged: the action's [`Action::id`], the step's index, and the action's steps and +/// excluded steps as they were when the drag started. +type DraggedStep = (u64, usize, std::sync::Arc<(Vec<(String, Value)>, BTreeSet)>); + /// `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 @@ -1083,9 +1125,9 @@ fn recording_start(app: &VectorcraftApp) -> Option { } /// Where Stop Playing/Recording puts the steps of the recording in progress: the index of the set, -/// and of the action New Action made for it, or None for the new action Stop adds after the set's -/// last action. None when there is no recording, no set to put them in, or the action New Action -/// made for it was deleted, so that Stop keeps none of them. +/// and of the action it records into ([`crate::UiState::recording_into`]), or None for the new +/// action Stop adds after the set's last action. None when there is no recording, no set to put +/// them in, or the action it records into was deleted, so that Stop keeps none of them. fn recording_target(app: &VectorcraftApp) -> Option<(usize, Option)> { let (set, ..) = app.ui.recording.as_ref()?; let Some(id) = app.ui.recording_into else { @@ -1107,7 +1149,7 @@ fn set_with_recorded(app: &VectorcraftApp, steps: Vec<(String, Value)>) -> Optio s.actions.len() - 1 } }; - extend_recorded(s.actions.get_mut(at)?, steps); + put_recorded(s.actions.get_mut(at)?, steps, app.ui.recording_at); Some((s, at)) } @@ -1144,7 +1186,7 @@ fn set_sizes(app: &VectorcraftApp, at: Option, set: &ActionSet) -> [usize let mut s = set.clone(); let steps = app.session.journal_for_action(start); match app.ui.recording_into.and_then(|id| s.actions.iter().position(|a| a.id == id)) { - Some(i) => extend_recorded(&mut s.actions[i], steps), + Some(i) => put_recorded(&mut s.actions[i], steps, app.ui.recording_at), None => s.actions.push(Action::new(name, steps)), } with = s; @@ -1359,12 +1401,17 @@ fn insert(app: &mut VectorcraftApp, p: &Value, step: (String, Value)) -> Result< let excluded = action.excluded_steps.clone(); action.insert_step(after, step); let id = action.id; + // The recording's steps stay after the steps that were before them. + let slot = match (&recorded, app.ui.recording_at) { + (Some(r), Some(slot)) if r.into == Some(at) && after <= slot => Some(slot + 1), + (_, slot) => slot, + }; let file_len = match recorded { None => files::write_set(s).len(), Some(Recorded { into, name, steps }) => { let mut kept = s.clone(); match into { - Some(into) => kept.actions[into].steps.extend(steps), + Some(into) => put_recorded(&mut kept.actions[into], steps, slot), None => kept.actions.push(Action::new(name, steps)), } files::write_set(&kept).len() @@ -1376,6 +1423,7 @@ fn insert(app: &mut VectorcraftApp, p: &Value, step: (String, Value)) -> Result< action.excluded_steps = excluded; return Err("the action set would be larger than 8 MiB".into()); } + app.ui.recording_at = slot; app.ui.action_set = Some(set); app.ui.action_sets_selected = vec![set]; if let Some(ctx) = app.ui_ctx.clone() { @@ -1415,6 +1463,12 @@ pub fn delete_step(app: &mut VectorcraftApp, p: &Value) -> Result let step = index("step").filter(|&s| s < action.steps.len()).ok_or("no such action step")?; action.remove_step(step); let id = action.id; + // The recording's steps stay after the steps that were before them. + if app.ui.recording_into == Some(id) + && let Some(slot) = app.ui.recording_at.filter(|&slot| step < slot) + { + app.ui.recording_at = Some(slot - 1); + } if let Some(ctx) = app.ui_ctx.clone() && pstate::>(&ctx, SELECTED_STEP).is_some_and(|(selected, _)| selected == id) { @@ -1437,6 +1491,115 @@ pub fn delete_step(app: &mut VectorcraftApp, p: &Value) -> Result Ok(json!({"set": set, "action": at, "step": step})) } +/// The command that moves one step of an action ([`move_step`]). +pub const MOVE_STEP: &str = "ui.action.moveStep"; + +/// `ui.action.moveStep {set, action, step, toSet?, toAction?, before}`: moves step `step`, a stop or +/// a command, of the action at index `action` of set `set` into the action at index `toAction` of +/// set `toSet` (default: the same set and action), before the step at index `before` there, or +/// after its last step when `before` is its number of steps. `before` counts the steps as they are +/// before the move. The step keeps its params and whether it is excluded, and the other steps keep +/// theirs. A selected step stays selected: a moved selected step makes its new action the selected +/// one and its set the current one. A Record Stop, Insert Menu Item or Set Selection dialog open on +/// a step follows the step, and the steps a recording adds to either action stay after the steps +/// that were before them. A step put back just before or just after itself does not move. A missing +/// or invalid index, or a move that would give a set a file that Load Actions… refuses +/// ([`edit_keeps_set_loadable`]: 10,000 steps in an action, 8 MiB), is an error, and then nothing +/// changes. Undo does not move the step back. A drag of a step row in the panel runs this. +/// → {set, action, step, moved}: where the step is now +pub fn move_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 source = app.ui.action_sets.get(set).and_then(|s| s.actions.get(at)).ok_or("no such action")?; + let step = index("step").filter(|&s| s < source.steps.len()).ok_or("no such action step")?; + let source_id = source.id; + let to_set = match p.get("toSet") { + None => set, + Some(v) => number(v).filter(|&s| s < app.ui.action_sets.len()).ok_or("no such action set")?, + }; + let to_action = match p.get("toAction") { + None => at, + Some(v) => number(v).ok_or("no such action")?, + }; + let dest = app.ui.action_sets[to_set].actions.get(to_action).ok_or("no such action")?; + let dest_id = dest.id; + let before = index("before").filter(|&b| b <= dest.steps.len()).ok_or("no such action step")?; + let same = (set, at) == (to_set, to_action); + if same && (before == step || before == step + 1) { + return Ok(json!({"set": set, "action": at, "step": step, "moved": false})); + } + // The step's index once it is in place, counted without it in its own action. + let to = if same && before > step { before - 1 } else { before }; + let mut from_edit = app.ui.action_sets[set].clone(); + let moved = from_edit.actions[at].steps[step].clone(); + let excluded = from_edit.actions[at].excluded_steps.contains(&step); + from_edit.actions[at].remove_step(step); + let put = |a: &mut Action| { + a.insert_step(to, moved); + if excluded { + a.excluded_steps.insert(to); + } + }; + let to_edit = if to_set == set { + put(&mut from_edit.actions[to_action]); + None + } else { + let mut edit = app.ui.action_sets[to_set].clone(); + put(&mut edit.actions[to_action]); + Some(edit) + }; + edit_keeps_set_loadable(app, Some(set), &from_edit)?; + if let Some(edit) = &to_edit { + edit_keeps_set_loadable(app, Some(to_set), edit)?; + } + app.ui.action_sets[set] = from_edit; + if let Some(edit) = to_edit { + app.ui.action_sets[to_set] = edit; + } + // A step index in `source_id` or `dest_id` as (the action's id, the step) after the move. + let follow = |(id, s): (u64, usize)| -> (u64, usize) { + if (id, s) == (source_id, step) { + return (dest_id, to); + } + let s = if id == source_id && s > step { s - 1 } else { s }; + (id, if id == dest_id && s >= to { s + 1 } else { s }) + }; + // The recording's steps stay after the steps that were before them. + if let Some(slot) = app.ui.recording_at { + let slot = if app.ui.recording_into == Some(source_id) && step < slot { slot - 1 } else { slot }; + app.ui.recording_at = Some(if app.ui.recording_into == Some(dest_id) && to <= slot { slot + 1 } else { slot }); + } + if let Some(ctx) = app.ui_ctx.clone() + && let Some(selected) = pstate::>(&ctx, SELECTED_STEP) + { + set_pstate::>(&ctx, SELECTED_STEP, Some(follow(selected))); + if selected == (source_id, step) { + set_pstate::>(&ctx, "actions-selected", Some((to_set, to_action))); + let mut expanded: BTreeSet = pstate(&ctx, "actions-expanded"); + expanded.insert(dest_id); + set_pstate(&ctx, "actions-expanded", expanded); + app.ui.action_set = Some(to_set); + app.ui.action_sets_selected = vec![to_set]; + } + } + // A Record Stop, Insert Menu Item or Set Selection dialog open on a step of either action follows it. + let either = |s: usize, a: usize| (s, a) == (set, at) || (s, a) == (to_set, to_action); + if let Some(d) = app.ui.dialog.as_mut().filter(|d| holds_step(d)) + && let (Some(ds), Some(da), Some(ds_step)) = + (d.fields.get("set").and_then(number), d.fields.get("action").and_then(number), d.fields.get("step").and_then(number)) + && either(ds, da) + { + let id = if (ds, da) == (set, at) { source_id } else { dest_id }; + let (id, s) = follow((id, ds_step)); + let (ds, da) = if id == dest_id { (to_set, to_action) } else { (set, at) }; + d.fields.insert("set".into(), json!(ds)); + d.fields.insert("action".into(), json!(da)); + d.fields.insert("step".into(), json!(s)); + } + Ok(json!({"set": to_set, "action": to_action, "step": to, "moved": true})) +} + /// 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 { @@ -1471,6 +1634,7 @@ fn start_recording(app: &mut VectorcraftApp, set: usize, name: String, into: Opt app.session.journal.hold(Some(start)); app.ui.recording = Some((set, name, start)); app.ui.recording_into = into; + app.ui.recording_at = None; app.ui.recording_base = None; app.ui.recording_tail = None; app.ui.recording_checked = Some((app.session.journal.rewrites(), start)); @@ -1498,12 +1662,15 @@ fn steps_that_fit(app: &VectorcraftApp, steps: &[(String, Value)]) -> (usize, Op (keep, (keep < steps.len()).then_some("the action already has 10,000 steps")) } -/// Adds the recorded `steps` after the steps of `action`, included in playback. An exclusion past -/// the action's last step, which saved preferences can hold, goes. -fn extend_recorded(action: &mut Action, steps: Vec<(String, Value)>) { +/// Puts the recorded `steps` into `action` before its step at index `at`, or after its last step +/// when `at` is None or past it, included in playback. The steps after them keep whether they are +/// excluded. An exclusion past the action's last step, which saved preferences can hold, goes. +fn put_recorded(action: &mut Action, steps: Vec<(String, Value)>, at: Option) { let len = action.steps.len(); - action.excluded_steps.retain(|&i| i < len); - action.steps.extend(steps); + let at = at.map_or(len, |at| at.min(len)); + let added = steps.len(); + action.excluded_steps = action.excluded_steps.iter().filter(|&&i| i < len).map(|&i| if i >= at { i + added } else { i }).collect(); + action.steps.splice(at..at, steps); } /// Stop Playing/Recording: ends the recording and keeps its steps (see [`start_recording`]). An @@ -1522,10 +1689,16 @@ fn stop_recording(app: &mut VectorcraftApp) { app.ui.recording_tail = None; app.ui.recording_checked = None; app.session.journal.hold(None); + let at = app.ui.recording_at.take(); match app.ui.recording_into.take() { Some(id) => { - if let Some(a) = app.ui.action_sets.iter_mut().flat_map(|s| s.actions.iter_mut()).find(|a| a.id == id) { - extend_recorded(a, steps); + let found = app.ui.action_sets.iter().enumerate().find_map(|(s, set)| Some((s, set.actions.iter().position(|a| a.id == id)?))); + if let Some((s, a)) = found { + let action = &mut app.ui.action_sets[s].actions[a]; + let len = action.steps.len(); + let (from, added) = (at.map_or(len, |at| at.min(len)), steps.len()); + put_recorded(action, steps, at); + follow_recorded(app, (s, a, id), from, added); } } None => { @@ -1536,6 +1709,28 @@ fn stop_recording(app: &mut VectorcraftApp) { } } +/// After Stop Playing/Recording put `added` steps into the action at `place` (set, action, id) +/// before its step at index `from`: the selected step and a Record Stop, Insert Menu Item or Set +/// Selection dialog open on a later step of the action follow their step down. +fn follow_recorded(app: &mut VectorcraftApp, (set, at, id): (usize, usize, u64), from: usize, added: usize) { + if added == 0 { + return; + } + if let Some(ctx) = app.ui_ctx.clone() + && let Some((step_id, step)) = pstate::>(&ctx, SELECTED_STEP).filter(|&(step_id, step)| step_id == id && step >= from) + { + set_pstate::>(&ctx, SELECTED_STEP, Some((step_id, step + added))); + } + let number = |v: &Value| v.as_u64().and_then(|i| usize::try_from(i).ok()); + if let Some(d) = app.ui.dialog.as_mut().filter(|d| holds_step(d)) + && d.fields.get("set").and_then(number) == Some(set) + && d.fields.get("action").and_then(number) == Some(at) + && let Some(step) = d.fields.get("step").and_then(number).filter(|&step| step >= from) + { + d.fields.insert("step".into(), json!(step + added)); + } +} + crate::shortcut_editor::ui_mirror!(fn function_key_chords() -> Vec); /// Mirrors the actions' function keys for code without app access (cheap when unchanged). @@ -1673,6 +1868,7 @@ pub fn delete_sets(app: &mut VectorcraftApp, p: &Value) -> Result if sets.contains(rec) { app.ui.recording = None; app.ui.recording_into = None; + app.ui.recording_at = None; app.session.journal.hold(None); } else { *rec -= sets.iter().filter(|&&s| s < *rec).count(); @@ -1926,6 +2122,34 @@ fn inclusion_box( (response.on_hover_text(if included { tl!("Exclude from playback") } else { tl!("Include in playback") }).clicked(), hit) } +/// While a step is dragged over the list: where it goes as (set, action, `before` of [`MOVE_STEP`]), +/// shown by a line in `color` on the row under the pointer, or None off every action's rows +/// (`slots`, as [`show`] lists them). Near the list's top or bottom edge, the list scrolls. +fn drop_target(ui: &mut egui::Ui, slots: &[(egui::Rect, usize, usize, usize, usize)], color: egui::Color32) -> Option<(usize, usize, usize)> { + let p = ui.ctx().pointer_interact_pos()?; + let clip = ui.clip_rect(); + if !clip.contains(p) { + return None; + } + let edge = 12.0; + let scroll = if p.y < clip.top() + edge { + 6.0 + } else if p.y > clip.bottom() - edge { + -6.0 + } else { + 0.0 + }; + if scroll != 0.0 && ui.input(|i| i.pointer.any_down()) { + ui.scroll_with_delta(egui::vec2(0.0, scroll)); + ui.ctx().request_repaint(); + } + let half_gap = ui.spacing().item_spacing.y / 2.0; + let &(r, si, ai, upper, lower) = slots.iter().find(|(r, ..)| (r.top() - half_gap..r.bottom() + half_gap).contains(&p.y))?; + let (before, y) = if upper != lower && p.y < r.center().y { (upper, r.top()) } else { (lower, r.bottom()) }; + ui.painter().line_segment([egui::pos2(r.left() + 42.0, y), egui::pos2(r.right(), y)], egui::Stroke::new(2.0, color)); + Some((si, ai, before)) +} + enum InclusionChange { Set(usize), Action(usize, usize), @@ -1954,6 +2178,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let mut expanded: BTreeSet = pstate(ui.ctx(), "actions-expanded"); let mut inclusion_changes = Vec::new(); let mut play_req = None; + // The step being dragged, from the frame its drag started. The drag ends without a move when a + // command since then changed its action's steps or which of them are excluded, or deleted the + // action. + let dragging = pstate::>(ui.ctx(), DRAGGED_STEP).filter(|(id, _, was)| { + let action = app.ui.action_sets.iter().flat_map(|s| &s.actions).find(|a| a.id == *id); + action.is_some_and(|a| (&a.steps, &a.excluded_steps) == (&was.0, &was.1)) + }); + let mut drag_started = None; + let mut drag_stopped = false; + // The rows a dragged step can be dropped on: (row, set, action, where it goes on the row's upper + // half, where on its lower half), as `before` of [`MOVE_STEP`]. + let mut slots: Vec<(egui::Rect, usize, usize, usize, usize)> = Vec::new(); + let mut drop = None; let list_height = widgets::panel_list_height(ui, 260.0, 0.0).min(260.0); egui::ScrollArea::vertical().max_height(list_height).min_scrolled_height(list_height.min(64.0)).show(ui, |ui| { for (si, set) in app.ui.action_sets.iter().enumerate() { @@ -2039,9 +2276,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { if resp.double_clicked() && !resp.interact_pointer_pos().is_some_and(|p| check_rect.contains(p) || disclosure.contains(p)) { play_req = Some((si, ai)); } + // A step dropped on an action's row goes first when the action shows its steps, else + // after its last step. + let first = if open && !a.steps.is_empty() { 0 } else { a.steps.len() }; + slots.push((r, si, ai, first, first)); 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::click()); + let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 20.0), egui::Sense::click_and_drag()); + slots.push((r, si, ai, step, step + 1)); + if resp.drag_started() { + drag_started = Some((a.id, step, std::sync::Arc::new((a.steps.clone(), a.excluded_steps.clone())))); + } + drag_stopped |= resp.drag_stopped(); if step_selected == Some((a.id, step)) { ui.painter().rect_filled(r, 0.0, t.row_selected); } else if resp.hovered() { @@ -2085,7 +2331,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } } } + if dragging.is_some() { + drop = drop_target(ui, &slots, t.accent); + } }); + // A released drag moves the step once, to where the line showed. + let dragged = dragging.as_ref().map(|(id, step, _)| (*id, *step)); + let move_req = drag_stopped.then_some(dragged.zip(drop)).flatten().and_then(|((id, step), (si, ai, before))| { + let (fs, fa) = app.ui.action_sets.iter().enumerate().find_map(|(fs, s)| Some((fs, s.actions.iter().position(|a| a.id == id)?)))?; + Some(json!({"set": fs, "action": fa, "step": step, "toSet": si, "toAction": ai, "before": before})) + }); + let still_dragging = !drag_stopped && ui.input(|i| i.pointer.any_down()); + set_pstate(ui.ctx(), DRAGGED_STEP, drag_started.or(dragging.filter(|_| still_dragging))); if waiting_at_stop(app) { dim_label(ui, tl!("Press Play Current Selection in the Actions panel to go on.")); } @@ -2114,6 +2371,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { set_pstate(ui.ctx(), SELECTED_STEP, step_selected); app.ui.action_set = current; app.ui.action_sets_selected = sets_selected; + if let Some(params) = move_req + && let Err(e) = app.run(MOVE_STEP, params) + { + app.status(e); + } + // Begin Recording records into the selected action, after its selected step. + let record_params = match selected.and_then(|(s, a)| Some((s, a, app.ui.action_sets.get(s)?.actions.get(a)?.id))) { + Some((s, a, id)) => match step_selected.filter(|&(step_id, _)| step_id == id) { + Some((_, step)) => json!({"set": s, "action": a, "after": step}), + None => json!({"set": s, "action": a}), + }, + None => json!({}), + }; if app.ui.action_sets.is_empty() { dim_label(ui, tl!("No actions. Press ● to record.")); } @@ -2129,7 +2399,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { ui.painter().circle_filled(r.center(), 6.0, if recording { egui::Color32::from_rgb(0xe0, 0x30, 0x30) } else { t.icon }); if resp.on_hover_text(tl!("Begin Recording")).clicked() && !recording - && let Err(e) = app.run(START_RECORDING, json!({})) + && let Err(e) = app.run(START_RECORDING, record_params.clone()) { app.status(e); } @@ -5870,6 +6140,350 @@ mod tests { assert_eq!(open_kind(&app), None, "deleting its step closes it"); } + /// The steps of action `action` in set `set`: each command, with its `dx` when it has one. + fn step_names(app: &VectorcraftApp, set: usize, action: usize) -> Vec { + app.ui.action_sets[set].actions[action] + .steps + .iter() + .map(|(c, p)| match p.get("dx") { + Some(dx) => format!("{c} {dx}"), + None => c.clone(), + }) + .collect() + } + + /// The bottom bar's Stop and Begin Recording buttons, as drawn last. + fn stop_and_record(ctx: &egui::Context, app: &mut VectorcraftApp) -> (egui::Pos2, egui::Pos2) { + super::super::tests_appearance::frame_events(ctx, app, vec![], show); + let mut bar = crate::toolbar::tests::widget_rects(ctx, egui::vec2(24.0, 24.0)); + bar.sort_by(|a, b| a.left().total_cmp(&b.left())); + let [stop, record, ..] = bar[..] else { panic!("the bottom bar: {bar:?}") }; + (stop.center(), record.center()) + } + + #[test] + fn begin_recording_selected_action_adds_steps_without_new_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); + click(&ctx, &mut app, text_rect(&texts, "Three Moves").center(), show); + let (stop, record) = stop_and_record(&ctx, &mut app); + click(&ctx, &mut app, record, show); + app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap(); + click(&ctx, &mut app, stop, show); + assert_eq!(app.ui.action_sets[0].actions.len(), 1, "no new action"); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 2", "object.move 4", "object.move 8"], "added at the end"); + + // With a step selected, the recorded steps go after it. + click_first_step(&ctx, &mut app); + // The steps shown move the bottom bar down. + let (stop, record) = stop_and_record(&ctx, &mut app); + click(&ctx, &mut app, record, show); + app.run("object.move", json!({"dx": 16, "dy": 0})).unwrap(); + click(&ctx, &mut app, stop, show); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 16", "object.move 2", "object.move 4", "object.move 8"]); + + // With no action selected, Begin Recording still adds a new action. + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "▾ Moves").center(), show); + let (stop, record) = stop_and_record(&ctx, &mut app); + click(&ctx, &mut app, record, show); + app.run("object.move", json!({"dx": 32, "dy": 0})).unwrap(); + click(&ctx, &mut app, stop, show); + let names: Vec<&str> = app.ui.action_sets[0].actions.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(names, ["Three Moves", "Action 2"]); + assert_eq!(step_names(&app, 0, 1), ["object.move 32"]); + } + + #[test] + fn begin_recording_after_selected_step_preserves_order_and_exclusions() { + let (mut app, _) = app_with_three_moves(); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([0, 2]); + for (params, want) in [ + (json!({"action": 0}), "no such action"), + (json!({"set": 0, "after": 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": 0, "after": 3}), "no such action step"), + (json!({"set": 0, "action": 0, "after": null}), "no such action step"), + (json!({"set": 0, "action": 0, "after": -1}), "no such action step"), + ] { + assert_eq!(app.run(START_RECORDING, params.clone()).unwrap_err(), want, "{params}"); + assert!(app.ui.recording.is_none(), "{params}"); + } + assert_eq!( + app.run(START_RECORDING, json!({"set": 0, "action": 0, "after": 0})).unwrap(), + json!({"set": 0, "action": 0, "name": "Three Moves"}) + ); + app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap(); + app.run("object.move", json!({"dx": 16, "dy": 0})).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions.len(), 1, "no new action"); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 8", "object.move 16", "object.move 2", "object.move 4"]); + assert_eq!(app.ui.action_sets[0].actions[0].excluded_steps, BTreeSet::from([0, 4]), "the steps keep whether they are excluded"); + } + + #[test] + fn steps_recorded_into_the_middle_of_an_action_keep_the_selected_step_and_dialog_on_their_steps() { + let (mut app, _) = app_with_three_moves(); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + let id = app.ui.action_sets[0].actions[0].id; + app.run(START_RECORDING, json!({"set": 0, "action": 0, "after": 0})).unwrap(); + app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap(); + app.run("object.move", json!({"dx": 16, "dy": 0})).unwrap(); + set_pstate::>(&ctx, SELECTED_STEP, Some((id, 2))); + crate::dialogs::action_stop::open_record(&mut app, Some((0, 0)), Some(1)); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 8", "object.move 16", "object.move 2", "object.move 4"]); + assert_eq!(pstate::>(&ctx, SELECTED_STEP), Some((id, 4)), "the move by 4 stays selected"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["step"], json!(3), "Record Stop stays after the move by 2"); + } + + #[test] + fn step_edits_while_recording_into_an_action_keep_the_recording_in_place() { + let (mut app, _) = app_with_three_moves(); + // The recording goes between the moves by 2 and 4. + app.run(START_RECORDING, json!({"set": 0, "action": 0, "after": 1})).unwrap(); + app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap(); + app.run(DELETE_STEP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + app.run(INSERT_STOP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 2, "before": 0})).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(step_names(&app, 0, 0), ["object.move 4", "object.move 2", STOP_STEP, "object.move 8"]); + + let (mut app, _) = app_with_three_moves(); + app.run(START_RECORDING, json!({"set": 0, "action": 0, "after": 1})).unwrap(); + app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap(); + app.run(DELETE_STEP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(step_names(&app, 0, 0), ["object.move 2", "object.move 8", "object.move 4"], "a step deleted before the place"); + } + + #[test] + fn move_step_within_action_updates_order_exclusions_and_selection() { + let (mut app, _) = app_with_three_moves(); + app.ui.action_sets[0].actions[0].steps.push(("object.move".into(), json!({"dx": 8, "dy": 0}))); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([1]); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + let id = app.ui.action_sets[0].actions[0].id; + set_pstate::>(&ctx, SELECTED_STEP, Some((id, 3))); + let selected_step = |ctx: &egui::Context| pstate::>(ctx, SELECTED_STEP); + assert_eq!( + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 1, "before": 4})).unwrap(), + json!({"set": 0, "action": 0, "step": 3, "moved": true}) + ); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 4", "object.move 8", "object.move 2"]); + assert_eq!(app.ui.action_sets[0].actions[0].excluded_steps, BTreeSet::from([3]), "the moved step stays excluded"); + assert_eq!(selected_step(&ctx), Some((id, 2)), "the selected step stays selected"); + assert_eq!( + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 2, "before": 0})).unwrap(), + json!({"set": 0, "action": 0, "step": 0, "moved": true}) + ); + assert_eq!(step_names(&app, 0, 0), ["object.move 8", "object.move 1", "object.move 4", "object.move 2"]); + assert_eq!(app.ui.action_sets[0].actions[0].excluded_steps, BTreeSet::from([3])); + assert_eq!(selected_step(&ctx), Some((id, 0)), "the moved selected step is selected where it went"); + for before in [1, 2] { + assert_eq!( + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 1, "before": before})).unwrap(), + json!({"set": 0, "action": 0, "step": 1, "moved": false}) + ); + } + assert_eq!(step_names(&app, 0, 0), ["object.move 8", "object.move 1", "object.move 4", "object.move 2"], "a drop on itself moves nothing"); + crate::dialogs::action_stop::open_record(&mut app, Some((0, 0)), Some(3)); + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 3, "before": 0})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["step"], json!(0), "an open Record Stop follows its step"); + } + + #[test] + fn move_step_between_actions_and_sets_keeps_file_roundtrip() { + let (mut app, _) = app_with_three_moves(); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([2]); + let rotate = ("object.rotate".to_string(), json!({"angle": 5})); + app.ui.action_sets.push(ActionSet::new("Other", vec![Action::new("Empty", vec![]), Action::new("One", vec![rotate])])); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + let (id, empty) = (app.ui.action_sets[0].actions[0].id, app.ui.action_sets[1].actions[0].id); + set_pstate::>(&ctx, "actions-selected", Some((0, 0))); + set_pstate::>(&ctx, SELECTED_STEP, Some((id, 2))); + assert_eq!( + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 2, "toSet": 1, "toAction": 0, "before": 0})).unwrap(), + json!({"set": 1, "action": 0, "step": 0, "moved": true}) + ); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 2"]); + assert!(app.ui.action_sets[0].actions[0].excluded_steps.is_empty()); + assert_eq!(step_names(&app, 1, 0), ["object.move 4"], "an empty action takes a step"); + assert_eq!(app.ui.action_sets[1].actions[0].excluded_steps, BTreeSet::from([0]), "the moved step stays excluded"); + assert_eq!(pstate::>(&ctx, SELECTED_STEP), Some((empty, 0))); + assert_eq!(pstate::>(&ctx, "actions-selected"), Some((1, 0))); + assert_eq!((app.ui.action_set, app.ui.action_sets_selected.as_slice()), (Some(1), &[1][..])); + assert!(pstate::>(&ctx, "actions-expanded").contains(&empty), "the step's new action shows its steps"); + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 0, "toSet": 1, "toAction": 1, "before": 0})).unwrap(); + assert_eq!(step_names(&app, 1, 1), ["object.move 1", "object.rotate"]); + assert_eq!(pstate::>(&ctx, SELECTED_STEP), Some((empty, 0)), "an unmoved selected step stays"); + let loaded = files::read_set(&files::write_set(&app.ui.action_sets[1]), "Other").unwrap(); + for (a, b) in loaded.actions.iter().zip(&app.ui.action_sets[1].actions) { + assert_eq!((&a.name, &a.steps, &a.excluded_steps), (&b.name, &b.steps, &b.excluded_steps)); + } + } + + #[test] + fn move_step_rejects_invalid_and_oversize_destinations_without_changes() { + let (mut app, _) = app_with_three_moves(); + let full = Action::new("Full", vec![("object.move".to_string(), json!({"dx": 0})); files::MAX_ACTION_STEPS]); + let big = |text: String| ActionSet::new("Big", vec![Action::new("Big", vec![("document.note".to_string(), json!({ "s": text }))])]); + let room = files::MAX_SET_FILE - files::write_set(&big(String::new())).len() - 4; + app.ui.action_sets.push(ActionSet::new("Full", vec![full])); + app.ui.action_sets.push(big("x".repeat(room))); + assert!(files::write_set(&app.ui.action_sets[2]).len() <= files::MAX_SET_FILE); + let before: Vec = app.ui.action_sets.iter().map(files::write_set).collect(); + for (params, want) in [ + (json!({}), "no such action"), + (json!({"set": 0, "action": 1, "step": 0, "before": 0}), "no such action"), + (json!({"set": 3, "action": 0, "step": 0, "before": 0}), "no such action"), + (json!({"set": 0, "action": 0, "step": 3, "before": 0}), "no such action step"), + (json!({"set": 0, "action": 0, "before": 0}), "no such action step"), + (json!({"set": 0, "action": 0, "step": 0}), "no such action step"), + (json!({"set": 0, "action": 0, "step": 0, "before": 4}), "no such action step"), + (json!({"set": 0, "action": 0, "step": 0, "before": "1"}), "no such action step"), + (json!({"set": 0, "action": 0, "step": 0, "toSet": 3, "before": 0}), "no such action set"), + (json!({"set": 0, "action": 0, "step": 0, "toSet": null, "before": 0}), "no such action set"), + (json!({"set": 0, "action": 0, "step": 0, "toSet": 1, "toAction": 1, "before": 0}), "no such action"), + (json!({"set": 0, "action": 0, "step": 0, "toSet": 1, "toAction": 0, "before": 0}), "the action already has 10,000 steps"), + (json!({"set": 0, "action": 0, "step": 0, "toSet": 2, "toAction": 0, "before": 1}), "the action set would be larger than 8 MiB"), + ] { + assert_eq!(app.run(MOVE_STEP, params.clone()).unwrap_err(), want, "{params}"); + assert_eq!(app.ui.action_sets.iter().map(files::write_set).collect::>(), before, "{params}"); + } + } + + #[test] + fn drag_step_shows_insertion_line_and_moves_once() { + use egui::{Event, PointerButton}; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + set_pstate(&ctx, "actions-expanded", BTreeSet::from([app.ui.action_sets[0].actions[0].id])); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); + let mut rows: Vec = texts.iter().filter(|(s, _)| s == "object.move").map(|(_, r)| *r).collect(); + rows.sort_by(|a, b| a.top().total_cmp(&b.top())); + let (from, to) = (rows[0].center(), egui::pos2(rows[0].center().x, rows[2].center().y + 4.0)); + let accent = Tokens::get(&ctx).accent; + // Whether a frame with `events` draws the insertion line. + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + fn line(s: &egui::Shape, accent: egui::Color32) -> bool { + match s { + egui::Shape::LineSegment { stroke, .. } => stroke.width == 2.0 && stroke.color == accent, + egui::Shape::Vec(v) => v.iter().any(|s| line(s, accent)), + _ => false, + } + } + out.shapes.iter().any(|c| line(&c.shape, accent)) + }; + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + assert!(!frame(&mut app, vec![Event::PointerMoved(from)]), "no line before a drag"); + frame(&mut app, vec![button(from, true)]); + frame(&mut app, vec![Event::PointerMoved(from + egui::vec2(0.0, 12.0))]); + assert!(frame(&mut app, vec![Event::PointerMoved(to)]), "the line shows where the step goes"); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 2", "object.move 4"], "nothing moves while dragging"); + frame(&mut app, vec![button(to, false)]); + assert_eq!(step_names(&app, 0, 0), ["object.move 2", "object.move 4", "object.move 1"], "the release moves the step"); + for _ in 0..3 { + assert!(!frame(&mut app, vec![]), "no line after the drop"); + } + assert_eq!(step_names(&app, 0, 0), ["object.move 2", "object.move 4", "object.move 1"], "the step moves once"); + } + + #[test] + fn drag_step_released_off_the_rows_leaves_it_in_place_without_an_undo_step() { + use egui::{Event, PointerButton}; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + set_pstate(&ctx, "actions-expanded", BTreeSet::from([app.ui.action_sets[0].actions[0].id])); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); + let mut rows: Vec = texts.iter().filter(|(s, _)| s == "object.move").map(|(_, r)| *r).collect(); + rows.sort_by(|a, b| a.top().total_cmp(&b.top())); + let from = rows[0].center(); + let over = egui::pos2(from.x, rows[2].center().y + 4.0); + let off = egui::pos2(from.x, rows[2].bottom() + 2_000.0); + let undo = undo_steps(&app); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![button(from, true)]); + frame(&mut app, vec![Event::PointerMoved(from + egui::vec2(0.0, 12.0))]); + frame(&mut app, vec![Event::PointerMoved(over)]); + frame(&mut app, vec![Event::PointerMoved(off)]); + frame(&mut app, vec![button(off, false)]); + for _ in 0..3 { + frame(&mut app, vec![]); + } + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 2", "object.move 4"], "the step stays where it was"); + assert_eq!(undo_steps(&app), undo, "no undo step"); + assert_eq!(pstate::>(&ctx, DRAGGED_STEP), None, "the drag ends"); + } + + #[test] + fn drag_step_ends_without_a_move_when_a_command_changes_its_action() { + use egui::{Event, PointerButton}; + let (mut app, _) = app_with_three_moves(); + let rotate = |angle: u32| ("object.rotate".to_string(), json!({"angle": angle})); + app.ui.action_sets[0].actions.push(Action::new("Turns", vec![rotate(5), rotate(6)])); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + set_pstate(&ctx, "actions-expanded", BTreeSet::from([app.ui.action_sets[0].actions[0].id])); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); + let mut rows: Vec = texts.iter().filter(|(s, _)| s == "object.move").map(|(_, r)| *r).collect(); + rows.sort_by(|a, b| a.top().total_cmp(&b.top())); + let (from, to) = (rows[0].center(), egui::pos2(rows[0].center().x, rows[1].center().y + 4.0)); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + let start_drag = |app: &mut VectorcraftApp| { + frame(app, vec![button(from, true)]); + frame(app, vec![Event::PointerMoved(from + egui::vec2(0.0, 12.0))]); + }; + // A command on another action leaves the drag as it is. + start_drag(&mut app); + app.run(DELETE_STEP, json!({"set": 0, "action": 1, "step": 0})).unwrap(); + frame(&mut app, vec![Event::PointerMoved(to)]); + frame(&mut app, vec![button(to, false)]); + assert_eq!(step_names(&app, 0, 0), ["object.move 2", "object.move 1", "object.move 4"], "the dragged step moves"); + // A command that deletes the dragged step's action's first step puts another step at the + // dragged index. + start_drag(&mut app); + app.run(DELETE_STEP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + frame(&mut app, vec![Event::PointerMoved(to)]); + assert_eq!(pstate::>(&ctx, DRAGGED_STEP), None, "the drag ends"); + frame(&mut app, vec![button(to, false)]); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 4"], "no other step moves"); + // Two equal steps, one excluded, that change places put the other one at the dragged index. + let same = ("object.move".to_string(), json!({"dx": 1, "dy": 0})); + app.ui.action_sets[0].actions[0].steps = vec![same.clone(), same]; + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([1]); + start_drag(&mut app); + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 1, "before": 0})).unwrap(); + frame(&mut app, vec![Event::PointerMoved(to)]); + frame(&mut app, vec![button(to, false)]); + assert_eq!(app.ui.action_sets[0].actions[0].excluded_steps, BTreeSet::from([0]), "the excluded step stays first"); + } + #[test] fn step_and_set_edits_keep_an_open_insert_menu_item_on_its_place() { let (mut app, _) = app_with_three_moves(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index be804f0347..a06dc2f1df 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -621,11 +621,16 @@ pub struct UiState { /// Recording in progress: (set index, action name, journal length when recording started). #[serde(skip)] pub recording: Option<(usize, String, usize)>, - /// The id of the action New Action made for the recording in progress - /// ([`crate::panels::actions::Action::id`]): Stop gives it the recorded steps. None when Stop - /// adds a new action, as after Begin Recording. + /// The id of the action the recording in progress records into, the one New Action made or the + /// one selected when Begin Recording started it ([`crate::panels::actions::Action::id`]): Stop + /// gives it the recorded steps. None when Stop adds a new action, as after Begin Recording with + /// no action selected. #[serde(skip)] pub recording_into: Option, + /// Where in the action [`UiState::recording_into`] names Stop puts the recorded steps: before + /// the step at this index, or None after its last step. + #[serde(skip)] + pub recording_at: Option, /// The length of the action set file that Stop Playing/Recording would leave without the steps /// recorded so far, once measured; None until then, and again after an app command, which may /// change the sets. @@ -1168,6 +1173,7 @@ impl Default for UiState { action_sets_selected: vec![], recording: None, recording_into: None, + recording_at: None, recording_base: None, recording_tail: None, recording_checked: None, diff --git a/docs/mcp.md b/docs/mcp.md index 4baea397e8..2b20cfc588 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3196,10 +3196,15 @@ already walks Image Trace. Short agent entry points: would give the set more than 1,000 actions (`the action set file holds more than 1,000 actions`) or a file larger than 8 MiB (`the action set would be larger than 8 MiB`), and then no recording starts. The panel's Create New Action button and menu's New Action… open the `actionNew` dialog, whose Record runs this. -- Actions: `ui.action.record {set?}` starts recording a new action in the chosen set, or the current set when `set` - is left out, and returns `{set, name}`. With no sets, it creates Set 1. The action gets the next available - `Action N` name in that set. The Actions panel's Begin Recording button runs this command. An invalid set or an - existing recording is an error and leaves the sets and recording unchanged, and so is a set where the new action +- Actions: `ui.action.record {set?, action?, after?}` starts recording a new action in the chosen set, or the current + set when `set` is left out, and returns `{set, name}`. With no sets, it creates Set 1. The action gets the next + available `Action N` name in that set. With `action`, it records into the action at `action` in set `set` instead + and returns `{set, action, name}`: `ui.action.stop` puts the recorded steps after step `after` of that action, or + after its last step when `after` is left out, and the steps after them keep whether they are excluded. The Actions + panel's Begin Recording button runs this command with the selected action and its selected step, and with neither + when no action is selected. `action` without `set`, `after` without `action`, an index past the last set, action or + step, an invalid set or an existing recording is an error and leaves the sets and recording unchanged, and so is a + set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB. While a recording (this one or `ui.action.new`'s) is in progress, a recorded command whose step would give the action more than 10,000 steps, or its set's action set file more than 8 MiB, is an error with the reason (`the action already has 10,000 steps`, `the @@ -3325,6 +3330,20 @@ already walks Image Trace. Short agent entry points: 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.moveStep {set, action, step, toSet?, toAction?, before}` moves one step, a stop or a command, of + the action at `action` in set `set` into the action at `toAction` in set `toSet` (by default the same set and + action), before the step at index `before` there, or after its last step when `before` is its number of steps. + `before` counts the steps as they are before the move. It returns `{set, action, step, moved}` with the step's new + place. The step keeps its params and whether it is excluded, and the other steps keep theirs. A selected step stays + selected; a moved selected step makes its new action the selected one and its set the current one. A Record Stop + dialog open on a step follows the step, and the steps that the recording in progress adds to either action stay + after the steps that were before them. A step put back just before or just after itself does not move, and + `moved` is `false`. A missing or invalid index, or a move that would give the action more than 10,000 steps or its + set's action set file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an + error, and then nothing changes. Undo does not move the step back. In the Actions panel, dragging a step row moves + the step: a line shows where it goes, before or after the row under the pointer, or first in an action whose name + row it is over (after the last step when the action's steps are hidden), and releasing the button off those rows + leaves the step where it was. - 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 @@ -3333,8 +3352,8 @@ already walks Image Trace. Short agent entry points: preferences. The panel menu's Playback Options… opens the `actionPlayback` dialog (`speed`, `seconds`), whose OK runs 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, or one waiting at a stop, whose message box closes: the steps already played + for it or the action `ui.action.record` named, at the place it named, 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, 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 27f3836683..8a5d49fcec 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -750,6 +750,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel menu: Load Actions… also reads action sets saved as `.aia` files ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `actionSet.read`, `ui.actionSet.load`). VectorCraft's own rules: the set comes in with all its actions. A step with a label and no recorded values comes in as the one menu command with the step's label, when that command is directly in a top menu, is what its menu bar item runs with no values and opens no dialog. A step that is off in the file stays excluded from playback. The other steps, recorded menu items included, are left out, and the status bar says how many. - Actions panel: the panel menu's Action Options… renames the selected action and keeps its steps ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): the menu item opens a dialog that renames the action or gives it a function key and color; `ui.action.options`). It also sets the action's function key and color. - Actions panel: Create New Action (bottom bar) and the panel menu's New Action… ask for the new action's name, set, function key and color, and Record adds the action and starts recording into it ([Create new actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html): the New Action dialog takes the action's name and the set it goes in; `ui.action.new`, `ui.action.play`). The function key (F1 to F15, with Ctrl or Command and Shift) plays the action. An action's function key plays the action even when a command has the same shortcut, and the command keeps it. VectorCraft's own rules, which the page does not cover: when several actions have the same key the first in panel order plays; a copied action has no key; New Action does not open while an action is recording. The color is kept with the action for Button Mode, which VectorCraft does not have yet. + - Actions panel: with an action selected, Begin Recording adds the commands recorded to that action, after its selected step or at its end, and a step dragged within its action or to another action goes where a highlighted line shows when the button is released ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/add-commands-to-actions.html); `ui.action.record`, `ui.action.moveStep`). VectorCraft's own rules, which the page does not cover: with no action selected Begin Recording adds a new action; a step can go into an empty action or an action in another set; a release off the actions' rows leaves the step where it was; a moved step keeps whether it is excluded; Undo does not move it back. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Actions panel: the current set is kept with the UI preferences, so after a restart a recording goes into the set that was current, not into the first set. With no set current, Begin Recording records into the first set and makes it current. - Actions panel: 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.