From cf10a77594f5c09cde23b0115c7b587d8b1b374f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 04:17:59 -0800 Subject: [PATCH 01/20] Actions: Begin Recording adds steps to the selected action, and a drag moves a step With an action selected, Begin Recording records into it: Stop puts the recorded commands after the selected step, or after the action's last step, and the steps after them keep whether they are excluded. With no action selected it still adds a new Action N. - ui.action.record takes {set?, action?, after?}; action needs set. The place stays between the same steps while a step is inserted, deleted or moved in that action during the recording, and the selected step and an open Record Stop dialog follow their step when Stop adds the recorded steps before them. - New ui.action.moveStep {set, action, step, toSet?, toAction?, before} moves a step within its action or into another action, also an empty one or one in another set. The step keeps its params and exclusion, the selected step and an open Record Stop dialog follow their step, and a move past 10,000 steps or 8 MiB is an error that changes nothing. - In the Actions panel a step row drags: a line shows where the step goes, the release moves it once, and a release off the actions' rows leaves it where it was. The list scrolls near its edges. Help page: https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/add-commands-to-actions.html Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/mcp/src/ui_catalog.rs | 10 +- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus/run.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 10 +- crates/ui-egui/src/panels/actions.rs | 601 +++++++++++++++++++++++- crates/ui-egui/src/state.rs | 12 +- docs/mcp.md | 31 +- docs/target-app-parity.md | 1 + 34 files changed, 658 insertions(+), 35 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 342feb568c..77aa3d1129 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1431,13 +1431,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", @@ -1475,6 +1475,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 3828d9ba2d..0b23a2959a 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5989,6 +5989,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 81fee3a079..789c3db469 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6031,6 +6031,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 02ae0f4a09..27a9de6073 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4043,6 +4043,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 7e3794e8a1..fd6cd6efd9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6524,6 +6524,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 f7011e23bf..7482a45ae2 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6006,6 +6006,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 4174537f87..4057dd11f3 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6115,6 +6115,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 bde2fbee78..60b4882536 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6475,6 +6475,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 ff28d9dfc5..7dbec4a486 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6466,6 +6466,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 bb74188ed8..7c369e8bb1 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6554,6 +6554,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 09c9c35b0d..1a0e50ec2b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6519,6 +6519,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 9aa4bd7b99..470e3a4a79 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3472,6 +3472,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 af6f586880..10df15679a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6507,6 +6507,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 e0ba31ecd5..9955c11132 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6006,6 +6006,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 ce0bc58ce5..00a6cc2f30 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6149,6 +6149,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 1ecce4618a..791842b703 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6214,6 +6214,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 3d6425c373..214c6deef9 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6475,6 +6475,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 fb87488d64..734479ba0d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6110,6 +6110,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 b3388fa82a..ab497a4ddd 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6057,6 +6057,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 0729e35c79..e7e07faf00 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6378,6 +6378,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 36f778dbd5..500d079586 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6439,6 +6439,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 442a9612d4..12707d0219 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6135,6 +6135,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 8a8f8453b6..f25f4f19da 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6505,6 +6505,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 ef8773c31b..0e93f26549 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6480,6 +6480,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 0bccbd8cb6..c8bf43bfef 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6129,6 +6129,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 4f084029db..e1e79b8eb0 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6130,6 +6130,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 b04e65a65b..464a97c565 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6362,6 +6362,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 be62fde8a7..9efc886c80 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.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/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 20a2c58314..d252f187e4 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1432,13 +1432,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, @@ -1476,6 +1476,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 8196ad27c2..361f7c42ba 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -847,16 +847,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")?, _ => { @@ -884,8 +895,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 @@ -904,6 +939,10 @@ 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 (the action's [`Action::id`], the step's +/// index). +const DRAGGED_STEP: &str = "actions-dragged-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 @@ -1030,9 +1069,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 { @@ -1054,7 +1093,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)) } @@ -1091,7 +1130,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; @@ -1286,12 +1325,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() @@ -1303,6 +1347,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() { @@ -1342,6 +1387,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) { @@ -1364,6 +1415,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 { @@ -1398,6 +1558,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; } @@ -1424,12 +1585,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 @@ -1447,10 +1611,16 @@ fn stop_recording(app: &mut VectorcraftApp) { app.ui.recording_base = None; app.ui.recording_tail = 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 => { @@ -1461,6 +1631,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). @@ -1598,6 +1790,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(); @@ -1851,6 +2044,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), @@ -1879,6 +2100,14 @@ 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, as (its action's id, the step), from the frame its drag started. + let dragging: Option<(u64, usize)> = pstate(ui.ctx(), DRAGGED_STEP); + 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() { @@ -1964,9 +2193,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)); + } + 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() { @@ -2010,7 +2248,17 @@ 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 move_req = drag_stopped.then_some(dragging.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.")); } @@ -2039,6 +2287,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.")); } @@ -2054,7 +2315,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); } @@ -5384,6 +5645,302 @@ 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 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 f84b8f53b0..28f068fc06 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -617,11 +617,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. @@ -1150,6 +1155,7 @@ impl Default for UiState { action_sets_selected: vec![], recording: None, recording_into: None, + recording_at: None, recording_base: None, recording_tail: None, action_playback: Default::default(), diff --git a/docs/mcp.md b/docs/mcp.md index 274db05a34..112befc778 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3053,10 +3053,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 @@ -3169,6 +3174,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 @@ -3177,8 +3196,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 f04ee3c910..053d952be1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -741,6 +741,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. From 54b4addcebb32b8ba569e56b587dfd98baa7e8e2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 05:12:09 -0800 Subject: [PATCH 02/20] Actions: a command that changes a dragged step's action ends the drag A drag of a step row kept the action's id and the step's index. A command that ran while the pointer was held could put another step at that index, and the release then moved that step. The drag now keeps the action's steps and excluded steps as they were when it started. It ends without a move when a command changes either, or deletes the action. A command on another action leaves the drag as it is. --- crates/ui-egui/src/panels/actions.rs | 71 +++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 361f7c42ba..80f3397696 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -939,10 +939,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 (the action's [`Action::id`], the step's -/// index). +/// 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 @@ -2100,8 +2103,13 @@ 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, as (its action's id, the step), from the frame its drag started. - let dragging: Option<(u64, usize)> = pstate(ui.ctx(), DRAGGED_STEP); + // 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 @@ -2202,7 +2210,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { 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)); + 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)) { @@ -2253,7 +2261,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } }); // A released drag moves the step once, to where the line showed. - let move_req = drag_stopped.then_some(dragging.zip(drop)).flatten().and_then(|((id, step), (si, ai, before))| { + 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})) }); @@ -5938,7 +5947,55 @@ mod tests { } 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"); + 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] From 4c93c04213b4bbd3b5f4d429bebd63f9913fde92 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 05:27:22 -0800 Subject: [PATCH 03/20] Brushes: built-in library panels show brush names in the UI language A built-in brush library's panel now shows each brush's name in the UI language in its list rows and tooltips, and Find matches either the shown name or the English name. User Defined and Other Library... libraries, and the document's Brushes panel, keep names as stored. A click, a drag, Add to Brushes and brush.addFromLibrary still use the English name, so the brush comes into the document under that name. The shared library panel gets a label hook for this, and its rows take the library id. The 16 built-in brush names that had no catalog row (Notched Ribbon, Double Line, Wave, Teardrop, Striped Ribbon, Fine Nib, Broad Nib, Chisel, Round Pen, Pressure Pen, Beads, Dash Dot, Scallops, Stars, Triangles, Sparkles) get a row in each of the 27 catalogs, and a test requires a row for every built-in brush name. --- crates/ui-egui/src/i18n/bg.tsv | 16 +++++++ crates/ui-egui/src/i18n/bs.tsv | 16 +++++++ crates/ui-egui/src/i18n/ca.tsv | 16 +++++++ crates/ui-egui/src/i18n/cs.tsv | 16 +++++++ crates/ui-egui/src/i18n/da.tsv | 16 +++++++ crates/ui-egui/src/i18n/de.tsv | 16 +++++++ crates/ui-egui/src/i18n/el.tsv | 16 +++++++ crates/ui-egui/src/i18n/es.tsv | 16 +++++++ crates/ui-egui/src/i18n/fi.tsv | 16 +++++++ crates/ui-egui/src/i18n/fr.tsv | 16 +++++++ crates/ui-egui/src/i18n/hi.tsv | 16 +++++++ crates/ui-egui/src/i18n/hu.tsv | 16 +++++++ crates/ui-egui/src/i18n/id.tsv | 16 +++++++ crates/ui-egui/src/i18n/it.tsv | 16 +++++++ crates/ui-egui/src/i18n/ja.tsv | 16 +++++++ crates/ui-egui/src/i18n/ka.tsv | 16 +++++++ crates/ui-egui/src/i18n/ko.tsv | 16 +++++++ crates/ui-egui/src/i18n/nl.tsv | 16 +++++++ crates/ui-egui/src/i18n/pl.tsv | 16 +++++++ crates/ui-egui/src/i18n/pt-br.tsv | 16 +++++++ crates/ui-egui/src/i18n/ru.tsv | 16 +++++++ crates/ui-egui/src/i18n/sv.tsv | 16 +++++++ crates/ui-egui/src/i18n/tests.rs | 24 ++++++++++ crates/ui-egui/src/i18n/tr.tsv | 16 +++++++ crates/ui-egui/src/i18n/uk.tsv | 16 +++++++ crates/ui-egui/src/i18n/vi.tsv | 16 +++++++ crates/ui-egui/src/i18n/zh-hans.tsv | 16 +++++++ crates/ui-egui/src/i18n/zh-hant.tsv | 16 +++++++ crates/ui-egui/src/panels/brushes.rs | 10 +++- crates/ui-egui/src/panels/graphic_styles.rs | 2 +- crates/ui-egui/src/panels/library_panel.rs | 18 +++++--- .../ui-egui/src/panels/library_panel/tests.rs | 2 +- .../ui-egui/src/panels/swatches/libraries.rs | 2 +- crates/ui-egui/src/panels/symbols.rs | 2 +- .../src/panels/tests_brush_libraries.rs | 46 ++++++++++++++++++- .../src/panels/tests_style_libraries.rs | 4 +- .../src/panels/tests_symbol_libraries.rs | 2 +- docs/target-app-parity.md | 1 + 38 files changed, 528 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3828d9ba2d..c26330b619 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6148,6 +6148,22 @@ axis Y Axis Y ос Graphite Pencil Графитен молив Wax Crayon Восъчен пастел Charcoal Dust Въглен прах + Notched Ribbon Назъбена лента + Double Line Двойна линия + Wave Вълна + Teardrop Сълза + Striped Ribbon Раирана лента + Fine Nib Тънък писец + Broad Nib Широк писец + Chisel Длето + Round Pen Кръгло перо + Pressure Pen Перо с натиск + Beads Мъниста + Dash Dot Тире-точка + Scallops Фестони + Stars Звезди + Triangles Триъгълници + Sparkles Искри Save Brush Library… Запази библиотека с четки… @msg no empty artboards to delete няма празни работни области за изтриване Scale Presets Пресети за мащаб diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 81fee3a079..89d900ea5b 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6201,6 +6201,22 @@ axis Y Axis Y osa Graphite Pencil Grafitna olovka Wax Crayon Voštana bojica Charcoal Dust Ugljena prašina + Notched Ribbon Zarezana traka + Double Line Dvostruka linija + Wave Talas + Teardrop Suza + Striped Ribbon Prugasta traka + Fine Nib Tanki vrh + Broad Nib Široki vrh + Chisel Dlijeto + Round Pen Okruglo pero + Pressure Pen Pero na pritisak + Beads Perle + Dash Dot Crta-tačka + Scallops Festoni + Stars Zvijezde + Triangles Trouglovi + Sparkles Iskrice Save Brush Library… Sačuvaj biblioteku četkica… @msg `{_1}` has no brushes to apply `{_1}` nema četkica za primjenu @msg missing `library` (see brush.libraries) nedostaje `library` (pogledajte brush.libraries) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 02ae0f4a09..359b2bb46f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3917,6 +3917,22 @@ digits Malayalam Malaiàlam Graphite Pencil Llapis de grafit Wax Crayon Cera de colors Charcoal Dust Pols de carbó + Notched Ribbon Cinta dentada + Double Line Línia doble + Wave Ona + Teardrop Llàgrima + Striped Ribbon Cinta ratllada + Fine Nib Plomí fi + Broad Nib Plomí ample + Chisel Escarpra + Round Pen Ploma rodona + Pressure Pen Ploma de pressió + Beads Perles + Dash Dot Ratlla i punt + Scallops Festons + Stars Estrelles + Triangles Triangles + Sparkles Espurnes Scatter: Dispersió: Scissors Tisores Scissors Tool Eina de tisores diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7e3794e8a1..0eea65f2d9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6694,6 +6694,22 @@ axis Y Axis osa Y Graphite Pencil Grafitová tužka Wax Crayon Vosková pastelka Charcoal Dust Uhlový prach + Notched Ribbon Vroubkovaná stuha + Double Line Dvojitá čára + Wave Vlna + Teardrop Slza + Striped Ribbon Pruhovaná stuha + Fine Nib Jemný hrot + Broad Nib Široký hrot + Chisel Dláto + Round Pen Kulaté pero + Pressure Pen Tlakové pero + Beads Korálky + Dash Dot Čárka-tečka + Scallops Feston + Stars Hvězdy + Triangles Trojúhelníky + Sparkles Jiskry Save Brush Library… Uložit knihovnu štětců… @msg `{_1}` has no brushes to apply `{_1}` nemá žádné štětce k použití @msg missing `library` (see brush.libraries) chybí `library` (viz brush.libraries) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f7011e23bf..987c765dbd 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6165,6 +6165,22 @@ axis Y Axis Y-akse Graphite Pencil Grafitblyant Wax Crayon Vokskridt Charcoal Dust Kulstøv + Notched Ribbon Takket bånd + Double Line Dobbelt linje + Wave Bølge + Teardrop Dråbe + Striped Ribbon Stribet bånd + Fine Nib Fin pen + Broad Nib Bred pen + Chisel Mejsel + Round Pen Rund pen + Pressure Pen Trykpen + Beads Perler + Dash Dot Streg-prik + Scallops Bølgekant + Stars Stjerner + Triangles Trekanter + Sparkles Gnister Save Brush Library… Gem penselbibliotek… @msg no empty artboards to delete ingen tomme tegneflader at slette Scale Presets Skaleringsforudindstillinger diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4174537f87..c95a4e9742 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6285,6 +6285,22 @@ axis Y Axis Y-Achse Graphite Pencil Graphitstift Wax Crayon Wachsmalstift Charcoal Dust Kohlestaub + Notched Ribbon Gekerbtes Band + Double Line Doppellinie + Wave Welle + Teardrop Tropfen + Striped Ribbon Gestreiftes Band + Fine Nib Feine Feder + Broad Nib Breite Feder + Chisel Meißel + Round Pen Runde Feder + Pressure Pen Druckfeder + Beads Perlen + Dash Dot Strich-Punkt + Scallops Bogenkante + Stars Sterne + Triangles Dreiecke + Sparkles Funkeln Save Brush Library… Pinsel-Bibliothek speichern… @msg `{_1}` has no brushes to apply `{_1}` hat keine Pinsel zum Anwenden @msg missing `library` (see brush.libraries) `library` fehlt (siehe brush.libraries) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bde2fbee78..0e78ec9ed6 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6645,6 +6645,22 @@ axis Y Axis Άξονας Y Graphite Pencil Μολύβι γραφίτη Wax Crayon Κηρομπογιά Charcoal Dust Σκόνη κάρβουνου + Notched Ribbon Οδοντωτή κορδέλα + Double Line Διπλή γραμμή + Wave Κύμα + Teardrop Δάκρυ + Striped Ribbon Ριγέ κορδέλα + Fine Nib Λεπτή πένα + Broad Nib Φαρδιά πένα + Chisel Σμίλη + Round Pen Στρογγυλή πένα + Pressure Pen Πένα πίεσης + Beads Χάντρες + Dash Dot Παύλα-τελεία + Scallops Φεστόνια + Stars Αστέρια + Triangles Τρίγωνα + Sparkles Σπίθες Save Brush Library… Αποθήκευση βιβλιοθήκης πινέλων… @msg `{_1}` has no brushes to apply Η `{_1}` δεν έχει πινέλα για εφαρμογή @msg missing `library` (see brush.libraries) λείπει το `library` (δείτε brush.libraries) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ff28d9dfc5..e4a9897be5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6636,6 +6636,22 @@ axis Y Axis Eje Y Graphite Pencil Lápiz de grafito Wax Crayon Cera de colores Charcoal Dust Polvo de carboncillo + Notched Ribbon Cinta dentada + Double Line Línea doble + Wave Onda + Teardrop Lágrima + Striped Ribbon Cinta a rayas + Fine Nib Plumilla fina + Broad Nib Plumilla ancha + Chisel Cincel + Round Pen Pluma redonda + Pressure Pen Pluma de presión + Beads Cuentas + Dash Dot Raya y punto + Scallops Festones + Stars Estrellas + Triangles Triángulos + Sparkles Destellos Save Brush Library… Guardar biblioteca de pinceles… @msg `{_1}` has no brushes to apply `{_1}` no tiene pinceles para aplicar @msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries) diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index bb74188ed8..ba201a9e75 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6724,6 +6724,22 @@ axis Y Axis Y-akseli Graphite Pencil Grafiittikynä Wax Crayon Vahaliitu Charcoal Dust Hiilipöly + Notched Ribbon Lovettu nauha + Double Line Kaksoisviiva + Wave Aalto + Teardrop Pisara + Striped Ribbon Raidallinen nauha + Fine Nib Ohut kärki + Broad Nib Leveä kärki + Chisel Taltta + Round Pen Pyöreä kynä + Pressure Pen Painekynä + Beads Helmet + Dash Dot Viiva-piste + Scallops Kaarireunus + Stars Tähdet + Triangles Kolmiot + Sparkles Kipinät Save Brush Library… Tallenna sivellinkirjasto… @msg `{_1}` has no brushes to apply Kohteessa `{_1}` ei ole käytettäviä siveltimiä @msg missing `library` (see brush.libraries) `library` puuttuu (katso brush.libraries) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 09c9c35b0d..ed33a6f10e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6689,6 +6689,22 @@ axis Y Axis Axe Y Graphite Pencil Crayon graphite Wax Crayon Craie grasse Charcoal Dust Poussière de fusain + Notched Ribbon Ruban cranté + Double Line Double ligne + Wave Vague + Teardrop Goutte + Striped Ribbon Ruban rayé + Fine Nib Plume fine + Broad Nib Plume large + Chisel Ciseau + Round Pen Plume ronde + Pressure Pen Plume à pression + Beads Perles + Dash Dot Tiret-point + Scallops Festons + Stars Étoiles + Triangles Triangles + Sparkles Étincelles Save Brush Library… Enregistrer la bibliothèque de formes… @msg `{_1}` has no brushes to apply `{_1}` n'a aucune forme à appliquer @msg missing `library` (see brush.libraries) `library` manquant (voir brush.libraries) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9aa4bd7b99..05c6d1e574 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -444,6 +444,22 @@ antiAlias Sharp शार्प Graphite Pencil ग्रेफाइट पेंसिल Wax Crayon मोम क्रेयॉन Charcoal Dust चारकोल धूल + Notched Ribbon नोकदार रिबन + Double Line दोहरी लाइन + Wave लहर + Teardrop आंसू की बूंद + Striped Ribbon धारीदार रिबन + Fine Nib बारीक निब + Broad Nib चौड़ी निब + Chisel छेनी + Round Pen गोल पेन + Pressure Pen प्रेशर पेन + Beads मोती + Dash Dot डैश डॉट + Scallops स्कैलप + Stars तारे + Triangles त्रिभुज + Sparkles चमक Art using a deleted global swatch keeps its colour. हटाए गए ग्लोबल स्वॉच वाला आर्टवर्क अपना रंग बनाए रखता है। Artboard आर्टबोर्ड Artboard {n} of {total} {total} में से आर्टबोर्ड {n} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index af6f586880..d2387475df 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6677,6 +6677,22 @@ axis Y Axis Y tengely Graphite Pencil Grafitceruza Wax Crayon Zsírkréta Charcoal Dust Szénpor + Notched Ribbon Csipkézett szalag + Double Line Dupla vonal + Wave Hullám + Teardrop Csepp + Striped Ribbon Csíkos szalag + Fine Nib Finom tollhegy + Broad Nib Széles tollhegy + Chisel Véső + Round Pen Kerek toll + Pressure Pen Nyomásérzékeny toll + Beads Gyöngyök + Dash Dot Pont-vonal + Scallops Hullámos szegély + Stars Csillagok + Triangles Háromszögek + Sparkles Szikrák Save Brush Library… Ecsetkönyvtár mentése… @msg `{_1}` has no brushes to apply A(z) `{_1}` nem tartalmaz alkalmazható ecsetet @msg missing `library` (see brush.libraries) hiányzik a `library` (lásd: brush.libraries) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e0ba31ecd5..a9e3010861 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6165,6 +6165,22 @@ axis Y Axis Sumbu Y Graphite Pencil Pensil grafit Wax Crayon Krayon lilin Charcoal Dust Debu arang + Notched Ribbon Pita bertakik + Double Line Garis ganda + Wave Gelombang + Teardrop Tetesan + Striped Ribbon Pita bergaris + Fine Nib Mata pena halus + Broad Nib Mata pena lebar + Chisel Pahat + Round Pen Pena bulat + Pressure Pen Pena tekanan + Beads Manik-manik + Dash Dot Garis titik + Scallops Lekuk kerang + Stars Bintang + Triangles Segitiga + Sparkles Kilauan Save Brush Library… Simpan pustaka kuas… @msg no empty artboards to delete tidak ada artboard kosong untuk dihapus Scale Presets Preset skala diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ce0bc58ce5..64271b143a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6319,6 +6319,22 @@ axis Y Axis Asse Y Graphite Pencil Matita di grafite Wax Crayon Pastello a cera Charcoal Dust Polvere di carboncino + Notched Ribbon Nastro dentellato + Double Line Doppia linea + Wave Onda + Teardrop Goccia + Striped Ribbon Nastro a righe + Fine Nib Pennino fine + Broad Nib Pennino largo + Chisel Scalpello + Round Pen Penna tonda + Pressure Pen Penna a pressione + Beads Perline + Dash Dot Tratto-punto + Scallops Smerlo + Stars Stelle + Triangles Triangoli + Sparkles Scintille Save Brush Library… Salva libreria pennelli… @msg `{_1}` has no brushes to apply `{_1}` non ha pennelli da applicare @msg missing `library` (see brush.libraries) manca `library` (vedi brush.libraries) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1ecce4618a..89c345e3ad 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6384,6 +6384,22 @@ axis Y Axis Y 軸 Graphite Pencil 黒鉛鉛筆 Wax Crayon ワックスクレヨン Charcoal Dust 木炭の粉 + Notched Ribbon ノッチリボン + Double Line 二重線 + Wave 波 + Teardrop しずく + Striped Ribbon ストライプリボン + Fine Nib 細いペン先 + Broad Nib 太いペン先 + Chisel 平ノミ + Round Pen 丸ペン + Pressure Pen 筆圧ペン + Beads ビーズ + Dash Dot 一点鎖線 + Scallops スカラップ + Stars 星 + Triangles 三角形 + Sparkles きらめき Save Brush Library… ブラシライブラリを保存… @msg `{_1}` has no brushes to apply `{_1}` に適用できるブラシがありません @msg missing `library` (see brush.libraries) `library` がありません (brush.libraries を参照) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 3d6425c373..ac208e54f9 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6645,6 +6645,22 @@ axis Y Axis Y ღერძი Graphite Pencil გრაფიტის ფანქარი Wax Crayon ცვილის ფანქარი Charcoal Dust ნახშირის მტვერი + Notched Ribbon დაკბილული ლენტი + Double Line ორმაგი ხაზი + Wave ტალღა + Teardrop წვეთი + Striped Ribbon ზოლიანი ლენტი + Fine Nib წვრილი წვერი + Broad Nib განიერი წვერი + Chisel საჭრეთელი + Round Pen მრგვალი კალამი + Pressure Pen დაწოლის კალამი + Beads მძივები + Dash Dot ტირე-წერტილი + Scallops ფესტონები + Stars ვარსკვლავები + Triangles სამკუთხედები + Sparkles ნაპერწკლები Save Brush Library… ფუნჯების ბიბლიოთეკის შენახვა… @msg `{_1}` has no brushes to apply `{_1}`-ში გამოსაყენებელი ფუნჯები არ არის @msg missing `library` (see brush.libraries) `library` აკლია (იხ. brush.libraries) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index fb87488d64..f8e452c03e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6280,6 +6280,22 @@ axis Y Axis Y축 Graphite Pencil 흑연 연필 Wax Crayon 왁스 크레용 Charcoal Dust 목탄 가루 + Notched Ribbon 노치 리본 + Double Line 이중선 + Wave 물결 + Teardrop 눈물방울 + Striped Ribbon 줄무늬 리본 + Fine Nib 가는 펜촉 + Broad Nib 넓은 펜촉 + Chisel 끌 + Round Pen 둥근 펜 + Pressure Pen 필압 펜 + Beads 구슬 + Dash Dot 일점쇄선 + Scallops 스캘럽 + Stars 별 + Triangles 삼각형 + Sparkles 반짝임 Save Brush Library… 브러시 라이브러리 저장… @msg `{_1}` has no brushes to apply `{_1}`에 적용할 브러시가 없습니다 @msg missing `library` (see brush.libraries) `library`가 없습니다(brush.libraries 참조) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b3388fa82a..6d42141cdf 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6216,6 +6216,22 @@ axis Y Axis Y-as Graphite Pencil Grafietpotlood Wax Crayon Waskrijt Charcoal Dust Houtskoolstof + Notched Ribbon Gekerfd lint + Double Line Dubbele lijn + Wave Golf + Teardrop Traan + Striped Ribbon Gestreept lint + Fine Nib Fijne penpunt + Broad Nib Brede penpunt + Chisel Beitel + Round Pen Ronde pen + Pressure Pen Drukpen + Beads Kralen + Dash Dot Streep-punt + Scallops Schulprand + Stars Sterren + Triangles Driehoeken + Sparkles Fonkelingen Save Brush Library… Penseelbibliotheek opslaan… @msg no empty artboards to delete geen lege tekengebieden om te verwijderen Scale Presets Schaalvoorinstellingen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0729e35c79..1bab8829d6 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6548,6 +6548,22 @@ axis Y Axis Oś Y Graphite Pencil Ołówek grafitowy Wax Crayon Kredka woskowa Charcoal Dust Pył węglowy + Notched Ribbon Wstęga z nacięciami + Double Line Podwójna linia + Wave Fala + Teardrop Łza + Striped Ribbon Wstęga w paski + Fine Nib Cienka stalówka + Broad Nib Szeroka stalówka + Chisel Dłuto + Round Pen Okrągłe pióro + Pressure Pen Pióro z naciskiem + Beads Koraliki + Dash Dot Kreska-kropka + Scallops Feston + Stars Gwiazdy + Triangles Trójkąty + Sparkles Iskry Save Brush Library… Zapisz bibliotekę pędzli… @msg `{_1}` has no brushes to apply `{_1}` nie ma pędzli do zastosowania @msg missing `library` (see brush.libraries) brak `library` (zobacz brush.libraries) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 36f778dbd5..1b91e4601c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6609,6 +6609,22 @@ axis Y Axis Eixo Y Graphite Pencil Lápis de Grafite Wax Crayon Giz de Cera Charcoal Dust Pó de Carvão + Notched Ribbon Fita Entalhada + Double Line Linha Dupla + Wave Onda + Teardrop Lágrima + Striped Ribbon Fita Listrada + Fine Nib Pena Fina + Broad Nib Pena Larga + Chisel Cinzel + Round Pen Pena Redonda + Pressure Pen Pena de Pressão + Beads Contas + Dash Dot Traço-Ponto + Scallops Festões + Stars Estrelas + Triangles Triângulos + Sparkles Brilhos Save Brush Library… Salvar Biblioteca de Pincéis… @msg `{_1}` has no brushes to apply `{_1}` não tem pincéis para aplicar @msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 442a9612d4..5bb9f3345c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6305,6 +6305,22 @@ axis Y Axis Ось Y Graphite Pencil Графитный карандаш Wax Crayon Восковой мелок Charcoal Dust Угольная пыль + Notched Ribbon Лента с насечками + Double Line Двойная линия + Wave Волна + Teardrop Капля + Striped Ribbon Полосатая лента + Fine Nib Тонкое перо + Broad Nib Широкое перо + Chisel Стамеска + Round Pen Круглое перо + Pressure Pen Перо с нажимом + Beads Бусины + Dash Dot Штрихпунктир + Scallops Фестоны + Stars Звёзды + Triangles Треугольники + Sparkles Искры Save Brush Library… Сохранить библиотеку кистей… @msg `{_1}` has no brushes to apply В `{_1}` нет кистей для применения @msg missing `library` (see brush.libraries) не указан `library` (см. brush.libraries) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 8a8f8453b6..97c9f99ccd 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6675,6 +6675,22 @@ axis Y Axis Y-axel Graphite Pencil Grafitpenna Wax Crayon Vaxkrita Charcoal Dust Koldamm + Notched Ribbon Hackat band + Double Line Dubbellinje + Wave Våg + Teardrop Droppe + Striped Ribbon Randigt band + Fine Nib Fin spets + Broad Nib Bred spets + Chisel Mejsel + Round Pen Rund penna + Pressure Pen Tryckpenna + Beads Pärlor + Dash Dot Streck-punkt + Scallops Bågkant + Stars Stjärnor + Triangles Trianglar + Sparkles Gnistor Save Brush Library… Spara penselbibliotek… @msg `{_1}` has no brushes to apply `{_1}` har inga penslar att använda @msg missing `library` (see brush.libraries) `library` saknas (se brush.libraries) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 66caa1c3a4..fa2c034d09 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -198,6 +198,30 @@ fn built_in_brush_library_names_are_in_complete_catalogs() { assert!(bad.is_empty(), "{}", bad.join("\n")); } +/// A built-in brush library's panel lists its brushes in the UI language, so every brush of every +/// built-in library has a row in each complete catalog. +#[test] +fn built_in_library_brush_names_are_in_complete_catalogs() { + use vectorcraft_engine::cmd::brushlib; + let session = vectorcraft_engine::Session::new(); + let mut names: Vec = brushlib::BUILTIN + .iter() + .filter_map(|b| brushlib::library(&session, b.id)) + .flat_map(|(_, lib)| lib.brushes.iter().map(|b| b.name.clone()).collect::>()) + .collect(); + names.sort(); + names.dedup(); + assert_eq!(names.len(), 43, "{names:?}"); + let mut bad = vec![]; + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + let missing: Vec<&String> = names.iter().filter(|n| lang.catalog().plain(n).is_none()).collect(); + if !missing.is_empty() { + bad.push(format!("{} is missing {missing:?}", lang.code)); + } + } + assert!(bad.is_empty(), "{}", bad.join("\n")); +} + /// Preference choices and the marquee's E hint share the same translated labels. #[test] fn marquee_selection_labels_are_in_complete_catalogs() { diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ef8773c31b..84902b0658 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6650,6 +6650,22 @@ axis Y Axis Y ekseni Graphite Pencil Grafit Kalem Wax Crayon Mum Boya Charcoal Dust Kömür Tozu + Notched Ribbon Çentikli Şerit + Double Line Çift Çizgi + Wave Dalga + Teardrop Gözyaşı + Striped Ribbon Çizgili Şerit + Fine Nib İnce Uç + Broad Nib Geniş Uç + Chisel Keski + Round Pen Yuvarlak Kalem + Pressure Pen Basınçlı Kalem + Beads Boncuklar + Dash Dot Çizgi Nokta + Scallops Fistolar + Stars Yıldızlar + Triangles Üçgenler + Sparkles Parıltılar Save Brush Library… Fırça Kitaplığını Kaydet… @msg `{_1}` has no brushes to apply `{_1}` içinde uygulanacak fırça yok @msg missing `library` (see brush.libraries) `library` eksik (bkz. brush.libraries) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0bccbd8cb6..c27838be3a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6299,6 +6299,22 @@ axis Y Axis Вісь Y Graphite Pencil Графітний олівець Wax Crayon Восковий олівець Charcoal Dust Вугільний пил + Notched Ribbon Стрічка з насічками + Double Line Подвійна лінія + Wave Хвиля + Teardrop Крапля + Striped Ribbon Смугаста стрічка + Fine Nib Тонке перо + Broad Nib Широке перо + Chisel Стамеска + Round Pen Кругле перо + Pressure Pen Перо з натиском + Beads Намистини + Dash Dot Штрихпунктир + Scallops Фестони + Stars Зірки + Triangles Трикутники + Sparkles Іскри Save Brush Library… Зберегти бібліотеку пензлів… @msg `{_1}` has no brushes to apply У `{_1}` немає пензлів для застосування @msg missing `library` (see brush.libraries) не вказано `library` (див. brush.libraries) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4f084029db..d3ca07a4ac 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6300,6 +6300,22 @@ axis Y Axis Trục Y Graphite Pencil Bút chì than chì Wax Crayon Bút sáp màu Charcoal Dust Bụi than + Notched Ribbon Dải băng khía + Double Line Đường đôi + Wave Sóng + Teardrop Giọt lệ + Striped Ribbon Dải băng sọc + Fine Nib Ngòi mảnh + Broad Nib Ngòi rộng + Chisel Đục + Round Pen Bút tròn + Pressure Pen Bút áp lực + Beads Hạt cườm + Dash Dot Gạch chấm + Scallops Viền vỏ sò + Stars Ngôi sao + Triangles Tam giác + Sparkles Lấp lánh Save Brush Library… Lưu thư viện cọ… @msg `{_1}` has no brushes to apply `{_1}` không có cọ nào để áp dụng @msg missing `library` (see brush.libraries) thiếu `library` (xem brush.libraries) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b04e65a65b..f98d150c61 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6532,6 +6532,22 @@ axis Y Axis Y 轴 Graphite Pencil 石墨铅笔 Wax Crayon 蜡笔 Charcoal Dust 炭粉 + Notched Ribbon 凹口丝带 + Double Line 双线 + Wave 波浪 + Teardrop 泪滴 + Striped Ribbon 条纹丝带 + Fine Nib 细笔尖 + Broad Nib 宽笔尖 + Chisel 凿形 + Round Pen 圆头笔 + Pressure Pen 压感笔 + Beads 珠串 + Dash Dot 点划线 + Scallops 扇贝边 + Stars 星形 + Triangles 三角形 + Sparkles 闪光 Save Brush Library… 保存画笔库… @msg `{_1}` has no brushes to apply `{_1}` 中没有可应用的画笔 @msg missing `library` (see brush.libraries) 缺少 `library`(请参阅 brush.libraries) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index be62fde8a7..b8bcc68ab2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6337,6 +6337,22 @@ axis Y Axis Y 軸 Graphite Pencil 石墨鉛筆 Wax Crayon 蠟筆 Charcoal Dust 炭粉 + Notched Ribbon 凹口緞帶 + Double Line 雙線 + Wave 波浪 + Teardrop 淚滴 + Striped Ribbon 條紋緞帶 + Fine Nib 細筆尖 + Broad Nib 寬筆尖 + Chisel 鑿形 + Round Pen 圓頭筆 + Pressure Pen 壓感筆 + Beads 珠串 + Dash Dot 點劃線 + Scallops 扇貝邊 + Stars 星形 + Triangles 三角形 + Sparkles 閃光 Save Brush Library… 儲存筆刷資料庫… @msg `{_1}` has no brushes to apply `{_1}` 中沒有可套用的筆刷 @msg missing `library` (see brush.libraries) 未提供 `library`(請參閱 brush.libraries) diff --git a/crates/ui-egui/src/panels/brushes.rs b/crates/ui-egui/src/panels/brushes.rs index 8f29c70475..82991005ab 100644 --- a/crates/ui-egui/src/panels/brushes.rs +++ b/crates/ui-egui/src/panels/brushes.rs @@ -637,10 +637,16 @@ impl LibraryKind for BrushLibraries { fn path(app: &VectorcraftApp, id: &str) -> Option { app.session.brush_libraries.path(id).map(str::to_string) } - fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { - let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); + /// Find matches a brush's name as shown and as stored. + fn rows<'a>(lib: &'a Self::Lib, id: &str, query: &str) -> Vec> { + let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query) || Self::label(id, n).to_lowercase().contains(query); lib.iter().filter(|b| found(b.name())).map(|b| Row { name: b.name(), item: Some(b) }).collect() } + /// A built-in library's brush in the UI language; a User Defined or loaded library's brush as + /// it is named. + fn label<'a>(id: &str, name: &'a str) -> &'a str { + super::label_or_name(name, brushlib::BUILTIN.iter().any(|b| b.id == id)) + } /// The brush's stroke preview, drawn with the library's resources. fn draw(_: &VectorcraftApp, ui: &Ui, r: egui::Rect, b: &LibBrush, selected: bool, hovered: bool) { chip(ui, r, &b.lib.doc, &b.shown.def, b.shown.key); diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index fe38851b44..b87f291e67 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -726,7 +726,7 @@ impl LibraryKind for GraphicStyleLibraries { fn path(app: &VectorcraftApp, id: &str) -> Option { app.session.style_libraries.path(id).map(str::to_string) } - fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { + fn rows<'a>(lib: &'a Self::Lib, _: &str, query: &str) -> Vec> { let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); lib.iter().filter(|s| found(&s.style().name)).map(|s| Row { name: &s.style().name, item: Some(s) }).collect() } diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 948651ef80..76455a63f5 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -104,8 +104,13 @@ pub(crate) trait LibraryKind { fn find(app: &VectorcraftApp, key: &str) -> Found; /// The file library `id` was read from, if any. fn path(app: &VectorcraftApp, id: &str) -> Option; - /// The rows matching the find field's `query` (lower case; empty: all). - fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec>; + /// The rows of library `id` matching the find field's `query` (lower case; empty: all). + fn rows<'a>(lib: &'a Self::Lib, id: &str, query: &str) -> Vec>; + /// Row `name` of library `id` as its list row and tooltips show it (by default as it is). The + /// row keeps `name` for its tile, selection, clicks, drags and the commands they run. + fn label<'a>(_id: &str, name: &'a str) -> &'a str { + name + } /// An item's thumbnail in `r`. fn draw(app: &VectorcraftApp, ui: &Ui, r: Rect, item: &Self::Item, selected: bool, hovered: bool); /// The icons at the right of list row `r`. @@ -626,7 +631,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { let (view, hide_find) = settings(&app.ui, K::KIND, id).map_or((K::VIEW, false), |s| (s.view.unwrap_or(K::VIEW), s.hide_find)); let query = if hide_find { String::new() } else { widgets::search_field(ui, egui::Id::unique(key(&panel, "find")), tl!("Find")) }; ui.add_space(4.0); - let rows = K::rows(&lib, &query.trim().to_lowercase()); + let rows = K::rows(&lib, id, &query.trim().to_lowercase()); let selected = selection(ui.ctx(), &panel); let is_sel = |n: &str| selected.iter().any(|s| s == n); let mut clicked: Option<(String, bool, egui::Modifiers)> = None; @@ -659,9 +664,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { } None => super::swatches::draw_folder(ui, chip), } - list_row_name(ui, r, chip, row.name); + let label = K::label(id, row.name); + list_row_name(ui, r, chip, label); drag_out::(ui, &resp, id, row); - let tip = || row.item.map_or_else(|| row.name.to_string(), |i| format!("{} ({})", row.name, K::describe(i))); + let tip = || row.item.map_or_else(|| label.to_string(), |i| format!("{label} ({})", K::describe(i))); click(ui, resp, row, &tip); } } else { @@ -672,7 +678,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { None => folder_tile(ui, cell, is_sel(row.name)), } drag_out::(ui, &resp, id, row); - click(ui, resp, row, &|| row.name.to_string()); + click(ui, resp, row, &|| K::label(id, row.name).to_string()); } } }); diff --git a/crates/ui-egui/src/panels/library_panel/tests.rs b/crates/ui-egui/src/panels/library_panel/tests.rs index 034a90e5b0..5d6fef049b 100644 --- a/crates/ui-egui/src/panels/library_panel/tests.rs +++ b/crates/ui-egui/src/panels/library_panel/tests.rs @@ -127,7 +127,7 @@ fn a_plain_click_sets_where_a_shift_click_range_starts() { let ctx = context(); app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); let (_, lib) = SwatchLibraries::get(&app, "pastels").unwrap(); - let order: Vec = SwatchLibraries::rows(&lib, "").iter().map(|r| r.name.to_string()).collect(); + let order: Vec = SwatchLibraries::rows(&lib, "pastels", "").iter().map(|r| r.name.to_string()).collect(); click(&mut app, &ctx, &order[1], Modifiers::NONE, 0.0); click(&mut app, &ctx, &order[4], Modifiers::SHIFT, 2.0); assert_eq!(selection(&ctx, "library:swatches:pastels"), order[1..=4], "the range starts at the plain click"); diff --git a/crates/ui-egui/src/panels/swatches/libraries.rs b/crates/ui-egui/src/panels/swatches/libraries.rs index 772bc8e28d..864f6ae7e0 100644 --- a/crates/ui-egui/src/panels/swatches/libraries.rs +++ b/crates/ui-egui/src/panels/swatches/libraries.rs @@ -83,7 +83,7 @@ impl LibraryKind for SwatchLibraries { } /// Ungrouped swatches, then each colour group's folder and swatches (a group whose name /// matches shows all of them), as in the Swatches panel. - fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { + fn rows<'a>(lib: &'a Self::Lib, _: &str, query: &str) -> Vec> { let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); let row = |w: &'a Swatch| Row { name: &w.name, item: Some(w) }; let mut out: Vec> = lib.swatches.iter().filter(|w| found(&w.name)).map(row).collect(); diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index 04f99c3c1f..ad081ddfcd 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -496,7 +496,7 @@ impl LibraryKind for SymbolLibraries { fn path(app: &VectorcraftApp, id: &str) -> Option { app.session.symbol_libraries.path(id).map(str::to_string) } - fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { + fn rows<'a>(lib: &'a Self::Lib, _: &str, query: &str) -> Vec> { let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); lib.iter().filter(|s| found(s.name())).map(|s| Row { name: s.name(), item: Some(s) }).collect() } diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 263342741a..6da376c7ca 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -103,7 +103,7 @@ fn clicking_a_library_brush_adds_it_to_the_documents_brushes_in_one_step() { assert!(has(&app, "Teardrop") && has(&app, "Double Line")); // Find filters by name; list rows give the type. let lib = BrushLibraries::get(&app, "art-ribbons").unwrap().1; - let rows = BrushLibraries::rows(&lib, "tear"); + let rows = BrushLibraries::rows(&lib, "art-ribbons", "tear"); assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Teardrop"]); assert_eq!(BrushLibraries::describe(rows[0].item.unwrap()), "Art"); // Dragging a brush out of the panel carries it. @@ -127,7 +127,7 @@ fn the_ink_and_wash_library_opens_and_a_click_adds_and_applies_its_brush() { let r = app.run("window.brushLibrary", json!({"library": "ink-and-wash"})).unwrap(); assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("ink-and-wash"), Some(4))); let lib = BrushLibraries::get(&app, "ink-and-wash").unwrap().1; - let rows = BrushLibraries::rows(&lib, ""); + let rows = BrushLibraries::rows(&lib, "ink-and-wash", ""); assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Tapered Ink", "Brush Pen", "Soft Wash", "Pooling Wash"]); let path = app.run("shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60})).unwrap()["id"].as_u64().unwrap(); let (undo, brushes) = (undo_len(&app), count(&app)); @@ -140,6 +140,48 @@ fn the_ink_and_wash_library_opens_and_a_click_adds_and_applies_its_brush() { assert_eq!(app.run("brush.list", json!({})).unwrap()["current"], "Brush Pen"); } +/// A built-in library lists its brushes in the UI language, and Find matches the name shown and +/// the brush's own name. The brush keeps its own name for its tile, a click and the document's +/// Brushes. A library loaded from a document lists its brushes as they are named. +#[test] +fn built_in_library_brushes_are_listed_in_the_ui_language() { + let dir = std::env::temp_dir().join(format!("vc-brush-names-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut app = app(); + let ctx = context(); + crate::i18n::set_current(crate::i18n::Lang::from_code("fr").expect("fr registered")); + let list = |app: &mut VectorcraftApp, t: f64| { + let panel = last_open(&app.ui).map(LibraryPanel::panel).unwrap_or_default(); + app.run("ui.libraryPanel", json!({"panel": panel, "view": "smallList"})).unwrap(); + frame(&ctx, vec![], t, |ui| library_panel::show_window(app, ui.ctx())); + frame(&ctx, vec![], t + 0.25, |ui| library_panel::show_window(app, ui.ctx())) + }; + app.run("window.brushLibrary", json!({"library": "ink-and-wash"})).unwrap(); + let texts = list(&mut app, 0.0); + assert!(texts.iter().any(|t| t == "Encre effilée"), "{texts:?}"); + assert!(!texts.iter().any(|t| t == "Tapered Ink"), "{texts:?}"); + let lib = BrushLibraries::get(&app, "ink-and-wash").unwrap().1; + for query in ["effil", "tapered"] { + let rows = BrushLibraries::rows(&lib, "ink-and-wash", query); + assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Tapered Ink"], "{query}"); + } + assert!(BrushLibraries::rows(&lib, "loaded/1", "effil").is_empty(), "another library's brush is matched by its own name only"); + click(&mut app, &ctx, "Tapered Ink", Modifiers::NONE, 1.0); + assert!(has(&app, "Tapered Ink")); + assert_eq!(app.run("brush.list", json!({})).unwrap()["current"], "Tapered Ink"); + // The document's brushes, opened as a library, keep their names. + let doc = dir.join("Inks.vectorcraft"); + let b64 = app.run("document.serialize", json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string(); + std::fs::write(&doc, vectorcraft_format::base64_decode(&b64).unwrap()).unwrap(); + app.run("window.brushLibrary.other", json!({"path": doc.to_string_lossy()})).unwrap(); + let texts = list(&mut app, 2.0); + assert!(texts.iter().any(|t| t == "Tapered Ink"), "{texts:?}"); + assert!(!texts.iter().any(|t| t == "Encre effilée"), "{texts:?}"); + crate::i18n::set_current(crate::i18n::Lang::EN); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/tests_style_libraries.rs b/crates/ui-egui/src/panels/tests_style_libraries.rs index 46c274dee6..2adeda9812 100644 --- a/crates/ui-egui/src/panels/tests_style_libraries.rs +++ b/crates/ui-egui/src/panels/tests_style_libraries.rs @@ -92,7 +92,7 @@ fn clicking_a_library_style_adds_and_applies_it_in_one_step() { assert!(d.graphic_style("Feathered").is_some() && d.graphic_style("Haze").is_some()); // Find filters by name; list rows describe the styles; looks are computed once per library. let lib = GraphicStyleLibraries::get(&app, "shadows-glows").unwrap().1; - let rows = GraphicStyleLibraries::rows(&lib, "ember"); + let rows = GraphicStyleLibraries::rows(&lib, "shadows-glows", "ember"); assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Ember"]); assert_eq!(GraphicStyleLibraries::describe(rows[0].item.unwrap()), "1 fill, Inner Glow, Outer Glow"); assert!(std::sync::Arc::ptr_eq(&lib, &GraphicStyleLibraries::get(&app, "shadows-glows").unwrap().1)); @@ -121,7 +121,7 @@ fn painted_and_sketched_opens_from_the_window_menu_and_a_click_applies_its_style let node = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(rect)).unwrap().appearance.effects.clone(); assert_eq!(node.iter().map(|e| e.id.as_str()).collect::>(), ["sketch.charcoal"]); let lib = GraphicStyleLibraries::get(&app, "painted-sketched").unwrap().1; - let rows = GraphicStyleLibraries::rows(&lib, "ink"); + let rows = GraphicStyleLibraries::rows(&lib, "painted-sketched", "ink"); assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Ink Contour", "Wet Ink"]); } diff --git a/crates/ui-egui/src/panels/tests_symbol_libraries.rs b/crates/ui-egui/src/panels/tests_symbol_libraries.rs index 1e71583a4b..d61c03eaec 100644 --- a/crates/ui-egui/src/panels/tests_symbol_libraries.rs +++ b/crates/ui-egui/src/panels/tests_symbol_libraries.rs @@ -89,7 +89,7 @@ fn clicking_a_library_symbol_adds_it_to_the_documents_symbols_in_one_step() { assert_eq!(symbols(&app), ["Star", "Hexagon", "Arrow"]); // Find filters by name; list rows give the natural size. let lib = SymbolLibraries::get(&app, "basic-shapes").unwrap().1; - let rows = SymbolLibraries::rows(&lib, "arr"); + let rows = SymbolLibraries::rows(&lib, "basic-shapes", "arr"); assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Arrow"]); assert_eq!(SymbolLibraries::describe(rows[0].item.unwrap()), "48 × 32 pt"); // Dragging a symbol out of the panel carries it to the canvas. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..4981b8643a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -895,6 +895,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Ink and Wash adds ink and wash brushes (the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) lists an Ink brush library of strokes with varied thickness and texture; the names, shapes and opacities are VectorCraft's own): Tapered Ink, an art brush broad near its start that thins to a fine tip; Brush Pen, a calligraphic brush whose size follows the pen pressure; and Soft Wash and Pooling Wash, art brushes of translucent gray layers that take the stroke color as tints; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Dry Media adds grainy art brushes (Chalk Grain, Graphite Pencil, Wax Crayon, Charcoal Dust). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. - **Bristle Brushes library** ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html), [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Bristle Brushes holds ten bristle brushes, one for each tip shape (Fine Liner, Pointed Flat, Stubby Round, Dry Scrub, Soft Dome, Filbert, Angled Round, Angled Flat, Fan Blender, Grass Fan). The brushes, their names and their values are VectorCraft's own, and every value lies inside the range the bristle brush keeps (`brush.libraries`, `brush.addFromLibrary`; `the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges`). - **Dry Media library** ([help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Dry Media holds four art brushes with textured strokes like dry drawing materials, each 100 pt long: Chalk Grain (about 8 pt high), Graphite Pencil (about 2.3 pt), Wax Crayon (about 5.5 pt) and Charcoal Dust (about 7 pt). The brushes, their names, sizes and art are VectorCraft's own: each is filled vector paths from a fixed seed, at most 80 paths and 500 anchors, stretched along the path with Tints colorization, and the chalk and crayon art has even-odd gaps where the paper shows (`brush.libraries`, `brush.addFromLibrary`; `the_dry_media_library_holds_grainy_art_brushes_with_bounded_art`). + - **Brush names in library panels**: a built-in brush library's panel shows its brushes' names in the UI language, in its list rows and tooltips, and its Find field matches the name shown or the English name. This is VectorCraft's own rule. A User Defined or Other Library… library's brushes, and the document's Brushes panel, show names as they are stored. A click, a drag, Add to Brushes and `brush.addFromLibrary` still use the English name, so the brush comes into the document under that name (`built_in_library_brushes_are_listed_in_the_ui_language`, `built_in_library_brush_names_are_in_complete_catalogs`). - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. From f7ba1d045ae9a39efb7e76a45bdb09847e45a306 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 05:50:00 -0800 Subject: [PATCH 04/20] Contextual Task Bar: the Type tools show font family, style, size and Fill While a Type tool (Type, Area Type, Type on a Path and their vertical versions) adds or edits type, the task bar shows the font family and style menus, the Font Size combo and the Fill chip. Type selected with a selection tool shows the same font controls beside its Create Outlines, conversion and Type on a Path items. The controls change what the Character panel's would: the selected characters, else every selected type object. The Touch Type tool still shows no bar. While a Type tool edits type, the Fill chip shows the selected characters' fill, with "?" where their fills differ. On a canvas too narrow for one row, the font controls go on a second row and narrow to fit. Screen readers name each control. --- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/canvas/task_bar.rs | 143 ++++++++++--- crates/ui-egui/src/font_menu.rs | 20 +- crates/ui-egui/src/panels/character.rs | 45 +++- crates/ui-egui/src/tests/taskbar.rs | 260 +++++++++++++++++++++++- crates/ui-egui/src/widgets.rs | 2 +- crates/ui-egui/src/widgets/dropdowns.rs | 14 +- docs/target-app-parity.md | 3 +- 8 files changed, 452 insertions(+), 39 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d75eae537f..85412cb53d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -67,8 +67,10 @@ pub(crate) use panes::{consolidate, pane, tile, tiled_area}; use raster::{changed_pixels, pixel_region, upload}; pub(crate) use raster::{color_image, color_image_owned, render_job}; pub use selection_marks::SelectionMarks; +#[cfg(test)] +pub(crate) use task_bar::chip_paints; use task_bar::task_bar; -pub(crate) use task_bar::{has_colors, task_bar_rect}; +pub(crate) use task_bar::{has_colors, task_bar_rect, task_bar_shows_type}; #[cfg(test)] use tool_overlays::{DIAL_RADIUS, HiddenMarks}; pub use tool_overlays::{HiddenChars, SpellingMarks}; diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index 5372cc083d..c0422e0347 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -30,21 +30,59 @@ fn picked_artboards_bounds(app: &VectorcraftApp) -> Option { picked.iter().filter_map(|i| st.doc.artboards.get(*i)).map(|a| a.rect).reduce(|a, b| a.union(b)) } +/// The Type tools whose Contextual Task Bar shows the text formatting controls. The Touch Type +/// tool has no bar: its own handles take the place of one. +const TYPE_TOOLS: [&str; 6] = ["type", "areaType", "typeOnPath", "verticalType", "verticalAreaType", "verticalTypeOnPath"]; + +/// Whether every selected object is a type object, with no anchors picked out. +fn only_type_selected(app: &VectorcraftApp) -> bool { + app.session.active().is_some_and(|st| { + !st.selection.is_empty() + && st.selection.anchors.is_empty() + && st.selection.objects.iter().all(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) + }) +} + +/// Whether a selection tool is in use with something selected. +fn selection_tool_with_selection(app: &VectorcraftApp) -> bool { + matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") + && app.session.active().is_some_and(|st| !st.selection.is_empty()) +} + +/// Whether a Type tool edits type, or has type selected: its bar then shows only the text +/// formatting controls. +fn type_tool_on_type(app: &VectorcraftApp) -> bool { + TYPE_TOOLS.contains(&app.session.tool_id()) && (crate::panels::character::text_editing(app).is_some() || only_type_selected(app)) +} + +/// Whether the Contextual Task Bar shows the font family, font style and Font Size controls: a +/// Type tool edits or has selected type, or a selection tool has only type selected. +pub(crate) fn task_bar_shows_type(app: &VectorcraftApp) -> bool { + type_tool_on_type(app) || (selection_tool_with_selection(app) && only_type_selected(app)) +} + /// The Contextual Task Bar: a floating pill under the selection with the most likely next actions. /// With the Artboard tool it sits under the selected artboards and offers Resize (Artboard /// Options), Duplicate, Rearrange All (with two or more artboards) and Export (Export for Screens). /// A basic shape selected with the Selection tool shows a Shape Properties button in place of Lock. +/// Selected type also shows the font family, font style and Font Size controls before its Fill. +/// While a Type tool edits type, or has type selected, the bar shows only those controls and the +/// Fill. The font controls take a second row when one row would be wider than the canvas, and +/// narrow there to fit it. /// Its handle drags it anywhere on the canvas; unpinned it keeps that offset as it follows the /// selection, pinned it stays put ([`crate::state::TaskBarPlace`]). pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { let t = Tokens::get(ui.ctx()); let on_artboards = app.session.tool_id() == "artboard"; - if !on_artboards - && (app.session.active().is_none_or(|st| st.selection.is_empty()) - || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection")) - { + // Text added with a Type tool shows its formatting controls, as the help page describes + // (). + let typing = type_tool_on_type(app); + if !(on_artboards || typing || selection_tool_with_selection(app)) { return; } + // Selected type has the font controls too, as the help page describes + // (). + let fonts = task_bar_shows_type(app); let Some(b) = (if on_artboards { picked_artboards_bounds(app) } else { app.selection_bounds() }) else { return }; let Some(st) = app.session.active() else { return }; let n = st.selection.len(); @@ -93,7 +131,9 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS, none())); } items.push((tl!("Export"), "share-2", "file.exportForScreens", none())); - } else { + } else if !typing { + // The selection tools' object actions. A Type tool's bar leaves them out, Create Outlines + // and the type conversions included. // Edit Path: the Direct Selection tool, to edit the path's anchors. let edit_path = || (tl!("Edit Path"), "dc-direct", "tool.select", json!({"tool": "directSelection"})); // As in the reference app, an image offers Image Trace, Crop Image and Remove Background, and @@ -185,22 +225,30 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } items.push((tl!("Duplicate"), "copy", "edit.duplicate", none())); } - // The paints the Fill and Stroke proxies show, "?" when the selected objects' paints differ. - let (fill, stroke) = crate::panels::current_paints(app); - let (fill_mixed, stroke_mixed) = crate::panels::mixed_paints(app, ui.ctx()); + let (fill, stroke, fill_mixed, stroke_mixed) = chip_paints(app, ui.ctx()); let colored = has_colors(&st.doc, &st.selection.objects); let doc = st.uid; // Under the selection's lowest corner on screen, centered on the selection (the view may be // rotated). let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); let anchor = pos2(xf.to_screen(b.center()).x, lowest); - let est_w = 150.0 + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); + let bounds = xf.rect.shrink(8.0); + // On a canvas too narrow for the type controls at their narrowest, the bar leaves them out. + let fonts = fonts && bounds.width() - 18.0 >= crate::panels::character::task_bar_min_room(); + // The width of the bar's frame (margins and stroke), its handle, Fill and Stroke chips, + // Recolor Artwork, Lock or Shape Properties, and More Options, each after a 6-point gap. + let chips: f32 = + [(!on_artboards, 26.0), (!on_artboards && !typing, 26.0), (!on_artboards && !typing && colored, 30.0), (!on_artboards && !typing, 30.0)] + .iter() + .filter(|(shown, _)| *shown) + .map(|(_, w)| w + 6.0) + .sum(); + let (font_row, est) = size_estimate(&items, fonts, 18.0 + 4.0 + chips + 36.0, bounds.width()); // Its place under the selection, then where the handle moved it, kept on the canvas (with the // bar's size last frame, the estimate before it first shows). - let under = pos2(anchor.x - est_w / 2.0, anchor.y + 28.0); + let under = pos2(anchor.x - est.x / 2.0, anchor.y + 28.0); let id = task_bar_id(); - let size = egui::AreaState::load(ui.ctx(), id).and_then(|s| s.size).unwrap_or(vec2(est_w, 44.0)); - let bounds = xf.rect.shrink(8.0); + let size = egui::AreaState::load(ui.ctx(), id).and_then(|s| s.size).unwrap_or(est); let keep_in = |p: Pos2| p.clamp(bounds.min, (bounds.max - size).max(bounds.min)); let place = &mut app.ui.task_bar_place; // Just unpinned: follow the selection from the pinned spot. @@ -255,23 +303,21 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { run = Some((*cmd, params.clone())); } } + // Type's font family, font style and Font Size, which act as the Character + // panel's do: on the characters a Type tool selects, else on the selected type. + if fonts && !font_row { + crate::panels::character::task_bar(app, ui, f32::INFINITY); + } // The selected objects' Fill, Stroke, Recolor Artwork and Lock (a basic shape's - // properties instead), left out with the Artboard tool. - if !on_artboards { - let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); - let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)); - widgets::proxy_chip(ui, chip, &fill, fill_mixed, None); - ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside); - if resp.on_hover_text(tl!("Fill")).clicked() { - app.session.fill_active = true; - show_swatches = true; - } + // properties instead), left out with the Artboard tool. A Type tool's bar has + // the Fill alone. + if !on_artboards && paint_chip(ui, &t, &fill, fill_mixed, None, tl!("Fill")) { + app.session.fill_active = true; + show_swatches = true; + } + if !on_artboards && !typing { // The Stroke chip, drawn as a ring as the Control bar's is. - let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); - let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)); - widgets::proxy_chip(ui, chip, &stroke, stroke_mixed, Some(4.0)); - ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside); - if resp.on_hover_text(tl!("Stroke")).clicked() { + if paint_chip(ui, &t, &stroke, stroke_mixed, Some(4.0), tl!("Stroke")) { app.session.fill_active = false; show_swatches = true; } @@ -316,6 +362,13 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } }); }); + if font_row { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + // The canvas's width less the frame's margins and stroke. + crate::panels::character::task_bar(app, ui, bounds.width() - 18.0); + }); + } }); }); if moved != Vec2::ZERO { @@ -353,6 +406,42 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } } +/// Whether the type controls go on a second row, and the bar's estimated size before it first +/// shows: `items` and, when `fonts`, the font family, font style and Font Size controls, which take +/// a second row when one row, with the bar's other controls `fixed` points wide, would be wider than +/// `room` (and narrow to fit it there). +fn size_estimate(items: &[(&str, &str, &str, serde_json::Value)], fonts: bool, fixed: f32, room: f32) -> (bool, Vec2) { + let font_w: f32 = crate::panels::character::TASK_BAR_WIDTHS.iter().filter(|_| fonts).map(|w| w + 6.0).sum(); + let items_w = if fonts { fixed } else { 150.0 } + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); + if fonts && items_w + font_w > room { (true, vec2(items_w.max((font_w + 16.0).min(room)), 80.0)) } else { (false, vec2(items_w + font_w, 44.0)) } +} + +/// The paints the Fill and Stroke chips show, and whether each is mixed ("?"): the proxies', or +/// while a Type tool edits type, the edited characters' fill, which a swatch picked then paints. +pub(crate) fn chip_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (vectorcraft_color::Paint, vectorcraft_color::Paint, bool, bool) { + let (fill, stroke) = crate::panels::current_paints(app); + let (fill_mixed, stroke_mixed) = crate::panels::mixed_paints(app, ctx); + if crate::panels::character::text_editing(app).is_some() + && let Some((chars, _)) = crate::panels::character::text_style(app) + { + let mixed = crate::panels::character::shared(app, |s| s.fill.clone()).is_none(); + return (chars.fill, stroke, mixed, stroke_mixed); + } + (fill, stroke, fill_mixed, stroke_mixed) +} + +/// One of the Contextual Task Bar's Fill and Stroke chips, showing `paint` ("?" when `mixed`, a +/// ring of `ring` points for the stroke), named `tip` for screen readers. Returns whether it was +/// clicked. +fn paint_chip(ui: &mut Ui, t: &Tokens, paint: &vectorcraft_color::Paint, mixed: bool, ring: Option, tip: &str) -> bool { + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); + let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)); + widgets::proxy_chip(ui, chip, paint, mixed, ring); + ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); + resp.on_hover_text(tip).clicked() +} + /// The Contextual Task Bar's Center on Top and Center on Bottom, as in the Type menu, for type on a /// closed path. fn center_on_path_items(app: &VectorcraftApp, items: &mut Vec<(&'static str, &'static str, &'static str, serde_json::Value)>) { diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 4fbb7a7ffc..01e4d9e524 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -55,6 +55,8 @@ pub enum WheelOwner { Control, /// The Character popover attached to this Control bar link. ControlPopover(egui::Id), + /// The Contextual Task Bar's type controls. + TaskBar, } impl WheelOwner { @@ -68,6 +70,7 @@ impl WheelOwner { Self::ControlPopover(anchor) => { return app.ui.control_bar && app.ui.screen_mode < 2 && widgets::popover_is_open(ctx, anchor); } + Self::TaskBar => return crate::canvas::task_bar_shows_type(app) && crate::canvas::task_bar_rect(ctx).is_some(), Self::Character => "character", Self::Properties => { let selected = app.session.active().and_then(|document| document.selection.objects.first().and_then(|id| document.doc.node(*id))); @@ -106,12 +109,24 @@ pub struct MenuLook<'a> { pub wheel: bool, /// Its owner, so a field hidden before the next layout cannot capture wheel input. pub wheel_owner: Option, + /// The name screen readers give the closed field when no label stands beside it. + pub name: Option<&'a str>, } impl Default for MenuLook<'static> { /// Samples on in the theme's colors, English names, medium rows, no favorites, no wheel. fn default() -> Self { - Self { samples: true, on_white: false, english_names: true, row: ROW, favorites: &[], recent: &[], wheel: false, wheel_owner: None } + Self { + samples: true, + on_white: false, + english_names: true, + row: ROW, + favorites: &[], + recent: &[], + wheel: false, + wheel_owner: None, + name: None, + } } } @@ -131,6 +146,7 @@ impl<'a> MenuLook<'a> { recent: app.recent_fonts(), wheel: false, wheel_owner: None, + name: None, } } } @@ -378,7 +394,7 @@ pub fn font_menu( // The search field and filters stay in place over the rows, which scroll. The rows are all // one height (a family's styles too), so only those in view are laid out: there are thousands // of families. - let (field, shown) = widgets::combo_menu_field(ui, &id, current, width, |menu| { + let (field, shown) = widgets::combo_menu_field_named(ui, &id, look.name, current, width, |menu| { menu.height(height).show_rows_with_header( |ui| { // Wider than a narrow field, so the filters keep their rows and long names show. diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 2ad678bf62..571cae66a3 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -922,8 +922,13 @@ fn set_anti_alias(app: &mut VectorcraftApp, m: TypeAntiAlias) { /// The Font Size combo (Character and Properties panels, Control bar): typed, stepped, scrubbed or /// picked from the presets, in the type unit, blank where the selected text's sizes differ. pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32) { + size_field_named(app, ui, id, None, width); +} + +/// [`size_field`] named `name` for screen readers, where no label stands beside it. +fn size_field_named(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, name: Option<&str>, width: f32) { let size = shared_effective(app, |s| s.size); - if let Some(v) = widgets::spin_field(ui, id, size, app.session.type_unit(), width, 1.0, 0.1, &SIZE_PRESETS) { + if let Some(v) = widgets::spin_field_named(ui, id, name, size, app.session.type_unit(), width, 1.0, 0.1, &SIZE_PRESETS) { style(app, json!({"size": v})); } } @@ -962,7 +967,7 @@ pub(crate) fn font_pickers(app: &mut VectorcraftApp, ui: &mut Ui, s: &CharStyle, "cb-font" => Some(crate::font_menu::WheelOwner::Control), _ => None, }; - font_pickers_owned(app, ui, s, ids, widths, wheel_owner); + font_pickers_owned(app, ui, s, ids, widths, wheel_owner, (None, None)); } fn font_pickers_owned( @@ -972,16 +977,17 @@ fn font_pickers_owned( ids: (&str, &str), widths: (f32, f32), wheel_owner: Option, + a11y: (Option<&str>, Option<&str>), ) { crate::font_menu::sync_wheel_target(app, ui.ctx(), ui.ctx().viewport_id()); let sample = crate::font_menu::sample_text(app); - let look = crate::font_menu::MenuLook { wheel: true, wheel_owner, ..crate::font_menu::MenuLook::of(app) }; + let look = crate::font_menu::MenuLook { wheel: true, wheel_owner, name: a11y.0, ..crate::font_menu::MenuLook::of(app) }; if let Some(pick) = crate::font_menu::font_menu(ui, ids.0, &s.font_family, widths.0, sample.as_deref(), look) { crate::font_menu::apply(app, ui.ctx(), pick); } let styles = vectorcraft_text::FontDb::global().styles(&s.font_family); let names: Vec<&str> = styles.iter().map(String::as_str).collect(); - if let Some(name) = widgets::dropdown_names(ui, ids.1, &s.font_style, &names, widths.1).and_then(|i| names.get(i)) { + if let Some(name) = widgets::dropdown_names_named(ui, ids.1, a11y.1, &s.font_style, &names, widths.1).and_then(|i| names.get(i)) { style(app, json!({"style": name})); } } @@ -1076,6 +1082,35 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { ui.separator(); } +/// The Contextual Task Bar's widths of the font family menu, the font style dropdown and the +/// Font Size combo. +pub(crate) const TASK_BAR_WIDTHS: [f32; 3] = [140.0, 90.0, 76.0]; + +/// The smallest share of [`TASK_BAR_WIDTHS`] the Contextual Task Bar's type controls narrow to. +const TASK_BAR_MIN_SCALE: f32 = 0.4; + +/// The gaps between the Contextual Task Bar's type controls. +fn task_bar_gaps() -> f32 { + 6.0 * (TASK_BAR_WIDTHS.len() - 1) as f32 +} + +/// The narrowest room the Contextual Task Bar's type controls fit in, with their spacing. +pub(crate) fn task_bar_min_room() -> f32 { + TASK_BAR_MIN_SCALE * TASK_BAR_WIDTHS.iter().sum::() + task_bar_gaps() +} + +/// The Contextual Task Bar's type controls while type is selected or edited: the font family and +/// style menus and the Font Size combo, as the Control bar has them, named for screen readers. +/// They narrow, down to 40% of their widths, to fit in `room` points with their spacing. +pub(crate) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, room: f32) { + let Some((s, _)) = text_style(app) else { return }; + let k = ((room - task_bar_gaps()) / TASK_BAR_WIDTHS.iter().sum::()).clamp(TASK_BAR_MIN_SCALE, 1.0); + let [family, style, size] = TASK_BAR_WIDTHS.map(|w| w * k); + let owner = Some(crate::font_menu::WheelOwner::TaskBar); + font_pickers_owned(app, ui, &s, ("tb-font", "tb-font-style"), (family, style), owner, (Some(tl!("Font Family")), Some(tl!("Style")))); + size_field_named(app, ui, "tb-font-size", Some(tl!("Font Size")), size); +} + /// The panel state key of the panel menu's Show Snap to Glyph Options. const SNAP_TO_GLYPH_OPTIONS: &str = "ch-snap-to-glyph-options"; /// Set when Show Snap to Glyph Options is chosen: the next frame scrolls the panel to the options, @@ -1146,7 +1181,7 @@ fn attributes(app: &mut VectorcraftApp, ui: &mut Ui, owner: crate::font_menu::Wh if let Some(f) = app.character_focus { widgets::arm_field_focus(ui.ctx(), f.salt()); } - font_pickers_owned(app, ui, &s, ("ch-font", "ch-style"), (w - 4.0, w - 4.0), Some(owner)); + font_pickers_owned(app, ui, &s, ("ch-font", "ch-style"), (w - 4.0, w - 4.0), Some(owner), (None, None)); variable_font(app, ui, &s, w - 4.0); ui.add_space(2.0); let hidden = pstate::(ui.ctx(), "ch-hide-options"); diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index 4cece18050..202d6eb4c1 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -1,7 +1,8 @@ //! The Contextual Task Bar's actions for each kind of selection: open paths offer Join and Edit //! Path, compound paths and clipping masks their Release, images selected with vector art Make //! Clipping Mask, type converts between point and area type, type on a path opens its options, and -//! a basic shape shows its properties in place of Lock. +//! a basic shape shows its properties in place of Lock. Type, and the Type tools while they edit it, +//! show the font family, font style and Font Size controls. use egui::{Event, PointerButton, Pos2, Rect, vec2}; use serde_json::json; @@ -537,6 +538,263 @@ fn the_task_bar_follows_the_active_document() { assert!(!locked(&app, rect), "the button doesn't lock the shape"); } +/// The Type tools whose bar shows the font and Fill controls. +const TYPE_TOOLS: [&str; 6] = ["type", "areaType", "typeOnPath", "verticalType", "verticalAreaType", "verticalTypeOnPath"]; + +/// The labels drawn inside the task bar (none while it isn't shown), with the context. +fn bar_texts(app: &mut VectorcraftApp) -> (egui::Context, Vec<(String, Rect)>) { + let (ctx, texts) = bar(app); + let Some(r) = canvas::task_bar_rect(&ctx) else { return (ctx, vec![]) }; + (ctx, texts.into_iter().filter(|(_, t)| r.contains(t.center())).collect()) +} + +/// Point type "Hello" at 20 pt, made with `tool` and edited by it as after a click with it. +fn edited_type(app: &mut VectorcraftApp, tool: &str) -> u64 { + app.run("tool.select", json!({ "tool": tool })).unwrap(); + let t = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Hello", "size": 20})).unwrap()); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(crate::panels::character::text_editing(app).is_some(), "{tool} edits the type"); + t +} + +/// The font family and style of type `t`'s first character. +fn font_of(app: &VectorcraftApp, t: u64) -> (String, String) { + match kind(app, t) { + NodeKind::Text(t) => (t.first_style().font_family, t.first_style().font_style), + k => panic!("{k:?}"), + } +} + +/// The size of each run of type `t`, with its text. +fn sizes(app: &VectorcraftApp, t: u64) -> Vec<(String, f64)> { + match kind(app, t) { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.size)).collect(), + k => panic!("{k:?}"), + } +} + +/// Each Type tool that adds or edits type shows the font family, font style and Font Size controls +/// and the Fill, without the selection tools' object actions, the Stroke chip or Lock. +#[test] +fn the_type_tools_show_font_and_fill_controls() { + for tool in TYPE_TOOLS { + let mut app = app(); + let t = edited_type(&mut app, tool); + let (family, style) = font_of(&app, t); + let (_, texts) = bar_texts(&mut app); + for label in [family.as_str(), style.as_str(), "20 pt"] { + assert!(has(&texts, label), "{tool}: {label} in {texts:?}"); + } + for label in ["Create Outlines", "Convert To Area Type", "Dynamic Text", "Duplicate"] { + assert!(!has(&texts, label), "{tool}: {label} in {texts:?}"); + } + let tree = crate::widgets::accessibility_tests::tree(|ui| canvas::show(&mut app, ui)); + let named = |label: &str| tree.iter().any(|n| n.label().is_some_and(|l| l.starts_with(label))); + assert!(named("Fill"), "{tool}: the Fill shows"); + for label in ["Stroke", "Recolor Artwork", "Lock"] { + assert!(!named(label), "{tool}: {label} is left out"); + } + } +} + +/// A Type tool shows no bar before it adds type, nor with other art selected; the Touch Type tool +/// shows none with type selected. +#[test] +fn the_type_tools_show_no_bar_without_type() { + let mut app = app(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_none(), "nothing to format"); + let rect = id(app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 80, "height": 60})).unwrap()); + select(&mut app, &[rect]); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_none(), "a rectangle is not type"); + let t = id(app.run("text.create", json!({"x": 60, "y": 180, "text": "Hello", "size": 20})).unwrap()); + app.run("tool.select", json!({"tool": "touchType"})).unwrap(); + select(&mut app, &[t]); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_none(), "Touch Type has its own handles"); +} + +/// Selected type keeps its actions and gains the font controls, for one type object or several; +/// type selected with other art does not. +#[test] +fn selected_type_shows_font_controls_beside_its_actions() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let a = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Hello", "size": 20})).unwrap()); + let b = id(app.run("text.create", json!({"x": 60, "y": 160, "text": "World", "size": 20})).unwrap()); + let rect = id(app.run("shape.rectangle", json!({"x": 200, "y": 40, "width": 80, "height": 60})).unwrap()); + let (family, _) = font_of(&app, a); + select(&mut app, &[a]); + let (_, texts) = bar_texts(&mut app); + for label in ["Create Outlines", "Convert To Area Type", "Duplicate", family.as_str(), "20 pt"] { + assert!(has(&texts, label), "{label} in {texts:?}"); + } + select(&mut app, &[a, b]); + let (_, texts) = bar_texts(&mut app); + assert!(has(&texts, family.as_str()) && has(&texts, "20 pt"), "{texts:?}"); + select(&mut app, &[a, rect]); + let (_, texts) = bar_texts(&mut app); + assert!(has(&texts, "Group") && !has(&texts, family.as_str()) && !has(&texts, "20 pt"), "{texts:?}"); + select(&mut app, &[rect]); + let (_, texts) = bar_texts(&mut app); + assert!(!has(&texts, family.as_str()) && !has(&texts, "20 pt"), "{texts:?}"); +} + +/// The bar's Font Size sets the characters a Type tool selects, one undo step; with type selected +/// it sets every selected type object, as the Character panel's does. +#[test] +fn the_task_bars_font_size_sets_what_the_character_panel_would() { + let mut app = app(); + let t = edited_type(&mut app, "type"); + app.session.set_tool_option("select", &json!({"start": 0, "end": 2})); + let (ctx, texts) = bar_texts(&mut app); + type_into(&mut app, &ctx, &texts, "20 pt", "30"); + assert_eq!(sizes(&app, t), [("He".to_string(), 30.0), ("llo".to_string(), 20.0)]); + // Mixed sizes leave the field blank, as in the Character panel. + app.session.set_tool_option("select", &json!({"start": 0, "end": 5})); + let (_, texts) = bar_texts(&mut app); + assert!(!has(&texts, "20 pt") && !has(&texts, "30 pt"), "{texts:?}"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(sizes(&app, t), [("Hello".to_string(), 20.0)]); + + let mut app = self::app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let a = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Hello", "size": 20})).unwrap()); + let b = id(app.run("text.create", json!({"x": 60, "y": 160, "text": "World", "size": 20})).unwrap()); + select(&mut app, &[a, b]); + let (ctx, texts) = bar_texts(&mut app); + type_into(&mut app, &ctx, &texts, "20 pt", "14"); + assert_eq!(sizes(&app, a), [("Hello".to_string(), 14.0)]); + assert_eq!(sizes(&app, b), [("World".to_string(), 14.0)]); +} + +/// At a caret, with no characters selected, the bar's Font Size sets the edited type as the +/// Character panel's does, and the characters typed next take it. +#[test] +fn the_task_bars_font_size_at_a_caret_sets_what_is_typed_next() { + let mut app = app(); + let t = edited_type(&mut app, "type"); + app.session.set_tool_option("select", &json!({"start": 5, "end": 5})); + assert_eq!(crate::panels::character::text_editing(&app), Some((NodeId(t), 5, 5)), "a caret after the type"); + let (ctx, texts) = bar_texts(&mut app); + type_into(&mut app, &ctx, &texts, "20 pt", "30"); + app.session.tool_typed_text("!", app.view_info()).unwrap(); + assert_eq!(sizes(&app, t), [("Hello!".to_string(), 30.0)]); +} + +/// The Type tool's Fill chip makes the Fill active and brings up the Swatches panel. +#[test] +fn the_type_tools_fill_chip_activates_the_fill() { + let mut app = app(); + edited_type(&mut app, "type"); + app.session.fill_active = false; + let (ctx, _) = bar_texts(&mut app); + let r = canvas::task_bar_rect(&ctx).expect("the task bar shows"); + // Right to left: the frame's margin, More Options, then the Fill chip. + let p = Pos2::new(r.right() - 8.0 - 30.0 - 6.0 - 13.0, r.center().y); + let press = |pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![Event::PointerMoved(p)]); + frame(&mut app, &ctx, vec![press(true)]); + frame(&mut app, &ctx, vec![press(false)]); + assert!(app.session.fill_active, "the Fill is active"); + assert!(crate::panels::character::text_editing(&app).is_some(), "the type is still edited"); +} + +/// Screen readers name the Type tool bar's controls, and the bar stays on the canvas for type at +/// its lower right corner. +#[test] +fn the_type_tools_bar_is_named_and_stays_on_the_canvas() { + use crate::widgets::accessibility_tests::{named, tree}; + let mut app = app(); + edited_type(&mut app, "type"); + let tree = tree(|ui| canvas::show(&mut app, ui)); + for label in ["Font Family", "Style", "Font Size", "Fill"] { + named(&tree, label); + } + let mut app = self::app(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + app.run("text.create", json!({"x": 380, "y": 290, "text": "Corner", "size": 20})).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + // The type near the canvas's lower edge, so the bar's place under it is off the canvas. + let view = app.view_mut().unwrap(); + (view.center, view.zoom) = (vectorcraft_geom::Point::new(200.0, 20.0), 1.0); + let (ctx, _) = bar(&mut app); + let r = canvas::task_bar_rect(&ctx).expect("the task bar shows"); + let canvas = app.canvas_rect.expect("the canvas is laid out"); + assert!(canvas.contains_rect(r), "{r:?} in {canvas:?}"); +} + +/// The task bar's rect after a few frames on a `width` by 700 screen, and the canvas's rect. +fn bar_on_screen(app: &mut VectorcraftApp, width: f32) -> (Rect, Rect) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(width, 700.0)); + for _ in 0..3 { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| canvas::show(app, ui)); + out.textures_delta.clear(); + } + (canvas::task_bar_rect(&ctx).expect("the task bar shows"), app.canvas_rect.expect("the canvas is laid out")) +} + +/// On a canvas narrower than one row of the bar, the font controls go on a second row, narrowed to +/// fit, and the bar stays whole on the canvas: selected type's actions, and a Type tool's Fill. +#[test] +fn selected_types_font_controls_take_a_second_row_on_a_narrow_canvas() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let t = id(app.run("text.create", json!({"x": 20, "y": 60, "text": "Hello", "size": 20})).unwrap()); + select(&mut app, &[t]); + let (r, canvas) = bar_on_screen(&mut app, 760.0); + assert!(canvas.contains_rect(r), "{r:?} in {canvas:?}"); + assert!(r.height() > 60.0, "the font controls take a second row: {r:?}"); + + let mut app = self::app(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 60, "text": "Hello", "size": 20})).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + // Narrower than the font controls at their full widths too. + let (r, canvas) = bar_on_screen(&mut app, 300.0); + assert!(canvas.contains_rect(r), "{r:?} in {canvas:?}"); + assert!(r.height() > 60.0, "the font controls take a second row: {r:?}"); + let (r, _) = bar_on_screen(&mut app, 1000.0); + assert!(r.height() < 60.0, "one row where it fits: {r:?}"); + // The Type tool's bar has only the Fill and More Options beside the controls: one row fits. + let (r, canvas) = bar_on_screen(&mut app, 460.0); + assert!(canvas.contains_rect(r) && r.height() < 60.0, "one row on a 460 px screen: {r:?} in {canvas:?}"); + // Too narrow for the controls at their narrowest: the bar leaves them out. + let (r, canvas) = bar_on_screen(&mut app, 160.0); + assert!(canvas.contains_rect(r) && r.height() < 60.0, "no font controls on a 160 px screen: {r:?} in {canvas:?}"); +} + +/// While a Type tool edits type, the Fill chip shows the selected characters' fill, and "?" where +/// their fills differ. +#[test] +fn the_type_tools_fill_chip_shows_the_edited_characters_fill() { + let mut app = app(); + let t = edited_type(&mut app, "type"); + app.session.set_tool_option("select", &json!({"start": 2, "end": 5})); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + let fills: Vec<_> = match kind(&app, t) { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.fill.clone())).collect(), + k => panic!("{k:?}"), + }; + let [(he, he_fill), (llo, llo_fill)] = &fills[..] else { panic!("{fills:?}") }; + assert_eq!((he.as_str(), llo.as_str()), ("He", "llo")); + assert_ne!(he_fill, llo_fill); + let ctx = egui::Context::default(); + let (fill, _, mixed, _) = canvas::chip_paints(&app, &ctx); + assert!(fill == *llo_fill && !mixed, "{fill:?} {mixed}"); + app.session.set_tool_option("select", &json!({"start": 0, "end": 2})); + let (fill, _, mixed, _) = canvas::chip_paints(&app, &ctx); + assert!(fill == *he_fill && !mixed, "{fill:?} {mixed}"); + app.session.set_tool_option("select", &json!({"start": 0, "end": 5})); + let (_, _, mixed, _) = canvas::chip_paints(&app, &ctx); + assert!(mixed, "the fills differ"); +} + /// The task bar shows the selection as it is after an edit and after Undo: Join leaves once the /// open path is closed and comes back with Undo, and Shape Properties shows a rectangle's new /// corner radius and then its old one. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 826e7e02f6..efb14d7ab6 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -45,7 +45,7 @@ pub use drag::{ }; #[cfg(test)] use dropdowns::combo_button_id; -pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field, combo_named, field_focused_in, popover_is_open}; +pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field_named, combo_named, field_focused_in, popover_is_open}; pub use dropdowns::{ dropdown, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll, panel_scroll, popover, set_menu_floor, strip_scroll, diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs index 44903d6613..cc0988c480 100644 --- a/crates/ui-egui/src/widgets/dropdowns.rs +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -146,6 +146,18 @@ pub(crate) fn combo_menu_field( current: &str, width: f32, menu: impl FnOnce(craft_ui::combo::Menu<'_>) -> Option, +) -> (Response, Option) { + combo_menu_field_named(ui, id, None, current, width, menu) +} + +/// [`combo_menu_field`] for a field whose visible label is not an adjacent label widget. +pub(crate) fn combo_menu_field_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + current: &str, + width: f32, + menu: impl FnOnce(craft_ui::combo::Menu<'_>) -> Option, ) -> (Response, Option) { let t = Tokens::get(ui.ctx()); let label = take_previous_label(ui); @@ -155,7 +167,7 @@ pub(crate) fn combo_menu_field( .corner_radius(CornerRadius::same(3)) .show(ui, |ui| { let armed = take_armed_focus(ui, &id); - let resp = combo_field(ui, id_with(ui.scope_id(), id), label, None, current, width - 4.0); + let resp = combo_field(ui, id_with(ui.scope_id(), id), label, name, current, width - 4.0); if armed { egui::Popup::open_id(ui.ctx(), egui::Popup::default_response_id(&resp)); ui.memory_mut(|m| m.stop_text_input()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..df42147214 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -817,11 +817,12 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Window › SVG Interactivity attaches JavaScript to the selected objects' events (`onclick`, `onmouseover` and 17 more) and links JavaScript files to the document (Link JavaScript Files…); SVG export writes the events as event attributes and the files as `