Merge pull request #7360 from dotCooCoo/feat/learn-progress-rings

Learn: steps get check boxes, and skill rings fill as tutorials are finished
This commit is contained in:
BFlat
2026-11-14 21:55:49 +07:00
committed by GitHub
36 changed files with 809 additions and 72 deletions
+14 -2
View File
@@ -666,7 +666,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"learn.list",
"List Tutorials",
"",
"{} the Learn panel's skills in the order its Skills tab lists them: {skills: [{id, title, tutorials: [{id, title, steps: number of steps}]}]}",
"{} the Learn panel's skills in the order its Skills tab lists them, with the progress its rings, counts and check marks show: {skills: [{id, title, done: number of its tutorials finished, total: number of its tutorials, tutorials: [{id, title, steps: number of steps, finished: bool, completedSteps: [zero-based indexes of the steps marked done, every step once finished]}]}]}",
),
(
"learn.skills",
@@ -692,11 +692,17 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{tutorial: id (see learn.list)} open that tutorial's practice sample as a new untitled document, which becomes the active one, and move the Learn view to the tutorial, as its View more button does; each call opens another copy and open documents stay as they are; a missing, non-string or unknown id is an error and changes nothing; returns {tutorial, index, title} of the new document",
),
(
"learn.markStep",
"Mark Tutorial Step",
"",
"{tutorial: id (see learn.list), step: zero-based step number, complete?: bool} mark a step of a tutorial whose sample learn.openSample opened as done (true, omitted or null) or not done (false), as the check box before the step does; marking does not finish the tutorial; a missing, non-string or unknown id, a missing step or one the tutorial does not have, a complete that is not true or false, a tutorial whose sample was not opened, or a finished tutorial is an error and changes nothing; the preferences keep the marks; returns {tutorial, step, complete}",
),
(
"learn.finish",
"Finish Tutorial",
"",
"{tutorial: id (see learn.list)} mark a tutorial whose sample learn.openSample opened as finished, as its Finish tutorial button does; finishing it again keeps one entry; a missing, non-string or unknown id, or a tutorial whose sample was not opened, is an error and changes nothing; returns {tutorial, finished: true, next: id of the next tutorial in its skill, or null after the skill's last one}",
"{tutorial: id (see learn.list)} mark a tutorial whose sample learn.openSample opened as finished, with all its steps done, as its Finish tutorial button does; finishing it again keeps one entry, and needs no sample after a restart; a missing, non-string or unknown id, or a tutorial not finished whose sample was not opened, is an error and changes nothing; the preferences keep the tutorials finished; returns {tutorial, finished: true, next: id of the next tutorial in its skill, or null after the skill's last one}",
),
(
"learn.next",
@@ -704,6 +710,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{tutorial: id (see learn.list)} show the Learn panel on the tutorial after a finished one in the same skill, as its Next Up button does, without opening a sample; a missing, non-string or unknown id, a tutorial not finished, or a skill's last tutorial is an error and changes nothing; returns {skill, tutorial, title} of the next tutorial",
),
(
"learn.resetProgress",
"Reset Learn Progress",
"",
"{} clear the Learn panel's progress, every tutorial finished and every step marked done, as Reset progress under the list of skills does; open documents, opened practice samples and the Learn view stay as they are; returns what learn.list returns",
),
("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"),
(
"window.workspace",
+6
View File
@@ -6865,6 +6865,12 @@ breakText Glyphs Глифи
@msg open the practice sample of `{id}` first (see learn.openSample) първо отворете примерния файл за упражнение на `{id}` (вижте learn.openSample)
@msg finish `{id}` first (see learn.finish) първо завършете `{id}` (вижте learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` е последният урок в своето умение
Reset progress Нулиране на напредъка
Mark Tutorial Step Отбелязване на стъпка от урока
Reset Learn Progress Нулиране на напредъка в обучението
@msg missing `step` (see learn.show) липсва `step` (вижте learn.show)
@msg `step` must be a step number from 0 to {last} `step` трябва да е номер на стъпка от 0 до {last}
@msg `{id}` is finished (see learn.resetProgress) `{id}` е завършен (вижте learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Обновяване на отложените ефекти
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -6918,6 +6918,12 @@ shape Open Otvoren
@msg open the practice sample of `{id}` first (see learn.openSample) prvo otvorite uzorak za vježbu tutorijala `{id}` (vidi learn.openSample)
@msg finish `{id}` first (see learn.finish) prvo završite `{id}` (vidi learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` je posljednji tutorijal u svojoj vještini
Reset progress Vrati napredak na početno
Mark Tutorial Step Označi korak tutorijala
Reset Learn Progress Vrati napredak učenja na početno
@msg missing `step` (see learn.show) nedostaje `step` (vidi learn.show)
@msg `step` must be a step number from 0 to {last} `step` mora biti broj koraka od 0 do {last}
@msg `{id}` is finished (see learn.resetProgress) `{id}` je završen (vidi learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Osvježi odgođene efekte
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -7453,6 +7453,12 @@ breakText Glyphs Glifs
@msg open the practice sample of `{id}` first (see learn.openSample) obriu primer la mostra de pràctica de `{id}` (vegeu learn.openSample)
@msg finish `{id}` first (see learn.finish) acabeu primer `{id}` (vegeu learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` és l’últim tutorial de la seva habilitat
Reset progress Restableix el progrés
Mark Tutorial Step Marca el pas del tutorial
Reset Learn Progress Restableix el progrés d’aprenentatge
@msg missing `step` (see learn.show) falta `step` (vegeu learn.show)
@msg `step` must be a step number from 0 to {last} `step` ha de ser un número de pas de 0 a {last}
@msg `{id}` is finished (see learn.resetProgress) `{id}` està acabat (vegeu learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Actualitza els efectes ajornats
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -7588,6 +7588,12 @@ breakText Glyphs Glyfy
@msg open the practice sample of `{id}` first (see learn.openSample) nejprve otevřete cvičnou ukázku kurzu `{id}` (viz learn.openSample)
@msg finish `{id}` first (see learn.finish) nejprve dokončete `{id}` (viz learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` je poslední výukový kurz své dovednosti
Reset progress Obnovit postup
Mark Tutorial Step Označit krok výukového kurzu
Reset Learn Progress Obnovit postup učení
@msg missing `step` (see learn.show) chybí `step` (viz learn.show)
@msg `step` must be a step number from 0 to {last} `step` musí být číslo kroku od 0 do {last}
@msg `{id}` is finished (see learn.resetProgress) `{id}` je dokončen (viz learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Obnovit odložené efekty
Can't render this file because it is too large.
+6
View File
@@ -6880,6 +6880,12 @@ breakText Glyphs Glyffer
@msg open the practice sample of `{id}` first (see learn.openSample) åbn først øvelseseksemplet til `{id}` (se learn.openSample)
@msg finish `{id}` first (see learn.finish) afslut først `{id}` (se learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` er den sidste vejledning i sin færdighed
Reset progress Nulstil fremskridt
Mark Tutorial Step Markér trin i vejledning
Reset Learn Progress Nulstil læringsfremskridt
@msg missing `step` (see learn.show) `step` mangler (se learn.show)
@msg `step` must be a step number from 0 to {last} `step` skal være et trinnummer fra 0 til {last}
@msg `{id}` is finished (see learn.resetProgress) `{id}` er afsluttet (se learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Opdatér udskudte effekter
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -7068,6 +7068,12 @@ breakText Glyphs Glyphen
@msg open the practice sample of `{id}` first (see learn.openSample) öffnen Sie zuerst die Übungsdatei von `{id}` (siehe learn.openSample)
@msg finish `{id}` first (see learn.finish) schließen Sie zuerst `{id}` ab (siehe learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` ist das letzte Lernprogramm seiner Fertigkeit
Reset progress Fortschritt zurücksetzen
Mark Tutorial Step Schritt des Lernprogramms markieren
Reset Learn Progress Lernfortschritt zurücksetzen
@msg missing `step` (see learn.show) `step` fehlt (siehe learn.show)
@msg `step` must be a step number from 0 to {last} `step` muss eine Schrittnummer von 0 bis {last} sein
@msg `{id}` is finished (see learn.resetProgress) `{id}` ist abgeschlossen (siehe learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Zurückgestellte Effekte aktualisieren
Can't render this file because it is too large.
+6
View File
@@ -7425,6 +7425,12 @@ breakText Glyphs Γλύφοι
@msg open the practice sample of `{id}` first (see learn.openSample) ανοίξτε πρώτα το δείγμα εξάσκησης του `{id}` (δείτε learn.openSample)
@msg finish `{id}` first (see learn.finish) ολοκληρώστε πρώτα το `{id}` (δείτε learn.finish)
@msg `{id}` is the last tutorial in its skill το `{id}` είναι ο τελευταίος οδηγός εκμάθησης της δεξιότητάς του
Reset progress Επαναφορά προόδου
Mark Tutorial Step Σήμανση βήματος οδηγού εκμάθησης
Reset Learn Progress Επαναφορά προόδου εκμάθησης
@msg missing `step` (see learn.show) λείπει το `step` (δείτε learn.show)
@msg `step` must be a step number from 0 to {last} το `step` πρέπει να είναι αριθμός βήματος από 0 έως {last}
@msg `{id}` is finished (see learn.resetProgress) το `{id}` έχει ολοκληρωθεί (δείτε learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Ανανέωση εφέ σε αναμονή
Can't render this file because it is too large.
+6
View File
@@ -7416,6 +7416,12 @@ breakText Glyphs Glifos
@msg open the practice sample of `{id}` first (see learn.openSample) abre primero la muestra de práctica de `{id}` (consulta learn.openSample)
@msg finish `{id}` first (see learn.finish) termina primero `{id}` (consulta learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` es el último tutorial de su habilidad
Reset progress Restablecer progreso
Mark Tutorial Step Marcar paso del tutorial
Reset Learn Progress Restablecer progreso de aprendizaje
@msg missing `step` (see learn.show) falta `step` (consulta learn.show)
@msg `step` must be a step number from 0 to {last} `step` debe ser un número de paso de 0 a {last}
@msg `{id}` is finished (see learn.resetProgress) `{id}` está terminado (consulta learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Actualizar efectos aplazados
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -7507,6 +7507,12 @@ breakText Glyphs Merkit
@msg open the practice sample of `{id}` first (see learn.openSample) avaa ensin opetusohjelman `{id}` harjoitustiedosto (ks. learn.openSample)
@msg finish `{id}` first (see learn.finish) viimeistele ensin `{id}` (ks. learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` on taitonsa viimeinen opetusohjelma
Reset progress Palauta edistyminen
Mark Tutorial Step Merkitse opetusohjelman vaihe
Reset Learn Progress Palauta oppimisen edistyminen
@msg missing `step` (see learn.show) `step` puuttuu (ks. learn.show)
@msg `step` must be a step number from 0 to {last} `step` on oltava vaiheen numero, vähintään 0 ja enintään {last}
@msg `{id}` is finished (see learn.resetProgress) `{id}` on viimeistelty (ks. learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Päivitä lykätyt tehosteet
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -7467,6 +7467,12 @@ breakText Glyphs Glyphes
@msg open the practice sample of `{id}` first (see learn.openSample) ouvrez d’abord le fichier d’exercice de `{id}` (voir learn.openSample)
@msg finish `{id}` first (see learn.finish) terminez d’abord `{id}` (voir learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` est le dernier tutoriel de sa compétence
Reset progress Réinitialiser la progression
Mark Tutorial Step Marquer une étape du tutoriel
Reset Learn Progress Réinitialiser la progression d’apprentissage
@msg missing `step` (see learn.show) `step` manquant (voir learn.show)
@msg `step` must be a step number from 0 to {last} `step` doit être un numéro d’étape de 0 à {last}
@msg `{id}` is finished (see learn.resetProgress) `{id}` est terminé (voir learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Actualiser les effets différés
Can't render this file because it is too large.
+6
View File
@@ -8863,6 +8863,12 @@ theme Appearance अपीयरेंस
@msg open the practice sample of `{id}` first (see learn.openSample) पहले `{id}` का अभ्यास नमूना खोलें (learn.openSample देखें)
@msg finish `{id}` first (see learn.finish) पहले `{id}` पूरा करें (learn.finish देखें)
@msg `{id}` is the last tutorial in its skill `{id}` अपने कौशल का अंतिम ट्यूटोरियल है
Reset progress प्रगति रीसेट करें
Mark Tutorial Step ट्यूटोरियल चरण चिह्नित करें
Reset Learn Progress सीखने की प्रगति रीसेट करें
@msg missing `step` (see learn.show) लापता `step` (learn.show देखें)
@msg `step` must be a step number from 0 to {last} `step` 0 से {last} तक की चरण संख्या होनी चाहिए
@msg `{id}` is finished (see learn.resetProgress) `{id}` पूरा हो चुका है (learn.resetProgress देखें)
# Deferred plug-in effects
Refresh Deferred Effects स्थगित इफ़ेक्ट्स रिफ्रेश करें
Can't render this file because it is too large.
+6
View File
@@ -7456,6 +7456,12 @@ breakText Glyphs Glifák
@msg open the practice sample of `{id}` first (see learn.openSample) előbb nyissa meg a gyakorló mintafájlt ehhez: `{id}` (lásd: learn.openSample)
@msg finish `{id}` first (see learn.finish) előbb fejezze be ezt: `{id}` (lásd: learn.finish)
@msg `{id}` is the last tutorial in its skill a készsége utolsó oktatóanyaga: `{id}`
Reset progress Előrehaladás visszaállítása
Mark Tutorial Step Oktatóanyag lépésének megjelölése
Reset Learn Progress Tanulási előrehaladás visszaállítása
@msg missing `step` (see learn.show) hiányzik: `step` (lásd: learn.show)
@msg `step` must be a step number from 0 to {last} a `step` értéke 0 és {last} közötti lépésszám legyen
@msg `{id}` is finished (see learn.resetProgress) befejezve: `{id}` (lásd: learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Elhalasztott effektusok frissítése
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -6880,6 +6880,12 @@ breakText Glyphs Glif
@msg open the practice sample of `{id}` first (see learn.openSample) buka contoh latihan `{id}` terlebih dahulu (lihat learn.openSample)
@msg finish `{id}` first (see learn.finish) selesaikan `{id}` terlebih dahulu (lihat learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` adalah tutorial terakhir dalam keterampilannya
Reset progress Atur ulang kemajuan
Mark Tutorial Step Tandai langkah tutorial
Reset Learn Progress Atur ulang kemajuan belajar
@msg missing `step` (see learn.show) `step` tidak ada (lihat learn.show)
@msg `step` must be a step number from 0 to {last} `step` harus berupa nomor langkah dari 0 sampai {last}
@msg `{id}` is finished (see learn.resetProgress) `{id}` sudah selesai (lihat learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Segarkan efek yang ditunda
Can't render this file because it contains an unexpected character in line 2433 and column 5.
+6
View File
@@ -7102,6 +7102,12 @@ breakText Glyphs Glifi
@msg open the practice sample of `{id}` first (see learn.openSample) apri prima l’esempio di esercitazione di `{id}` (vedi learn.openSample)
@msg finish `{id}` first (see learn.finish) termina prima `{id}` (vedi learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` è l’ultimo tutorial della sua competenza
Reset progress Reimposta i progressi
Mark Tutorial Step Segna il passaggio del tutorial
Reset Learn Progress Reimposta i progressi di apprendimento
@msg missing `step` (see learn.show) manca `step` (vedi learn.show)
@msg `step` must be a step number from 0 to {last} `step` deve essere un numero di passaggio da 0 a {last}
@msg `{id}` is finished (see learn.resetProgress) `{id}` è terminato (vedi learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Aggiorna effetti rimandati
Can't render this file because it is too large.
+6
View File
@@ -7168,6 +7168,12 @@ breakText Glyphs 字形
@msg open the practice sample of `{id}` first (see learn.openSample) 先に `{id}` の練習用サンプルを開いてください (learn.openSample を参照)
@msg finish `{id}` first (see learn.finish) 先に `{id}` を完了してください (learn.finish を参照)
@msg `{id}` is the last tutorial in its skill `{id}` はこのスキルの最後のチュートリアルです
Reset progress 進行状況をリセット
Mark Tutorial Step チュートリアルの手順をマーク
Reset Learn Progress 学習の進行状況をリセット
@msg missing `step` (see learn.show) `step` が指定されていません (learn.show を参照)
@msg `step` must be a step number from 0 to {last} `step` は 0 から {last} までの手順番号にしてください
@msg `{id}` is finished (see learn.resetProgress) `{id}` は完了しています (learn.resetProgress を参照)
# Deferred plug-in effects
Refresh Deferred Effects 保留中の効果を更新
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -7586,6 +7586,12 @@ font-script Japanese იაპონური
@msg open the practice sample of `{id}` first (see learn.openSample) ჯერ გახსენით `{id}` გაკვეთილის სავარჯიშო ნიმუში (იხ. learn.openSample)
@msg finish `{id}` first (see learn.finish) ჯერ დაასრულეთ `{id}` (იხ. learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` თავისი უნარის ბოლო გაკვეთილია
Reset progress პროგრესის ჩამოყრა
Mark Tutorial Step გაკვეთილის ნაბიჯის მონიშვნა
Reset Learn Progress სწავლის პროგრესის ჩამოყრა
@msg missing `step` (see learn.show) აკლია `step` (იხ. learn.show)
@msg `step` must be a step number from 0 to {last} `step` უნდა იყოს ნაბიჯის ნომერი 0-დან {last}-მდე
@msg `{id}` is finished (see learn.resetProgress) `{id}` დასრულებულია (იხ. learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects გადადებული ეფექტების განახლება
Can't render this file because it is too large.
+6
View File
@@ -7063,6 +7063,12 @@ breakText Glyphs 글리프
@msg open the practice sample of `{id}` first (see learn.openSample) 먼저 `{id}`의 연습용 샘플을 여세요(learn.openSample 참조)
@msg finish `{id}` first (see learn.finish) 먼저 `{id}`을(를) 완료하세요(learn.finish 참조)
@msg `{id}` is the last tutorial in its skill `{id}`은(는) 해당 기술의 마지막 튜토리얼입니다
Reset progress 진행률 재설정
Mark Tutorial Step 튜토리얼 단계 표시
Reset Learn Progress 학습 진행률 재설정
@msg missing `step` (see learn.show) `step`이 없습니다(learn.show 참조)
@msg `step` must be a step number from 0 to {last} `step`은 0에서 {last} 사이의 단계 번호여야 합니다
@msg `{id}` is finished (see learn.resetProgress) `{id}`은(는) 완료되었습니다(learn.resetProgress 참조)
# Deferred plug-in effects
Refresh Deferred Effects 보류된 효과 새로 고침
Can't render this file because it is too large.
+6
View File
@@ -9323,6 +9323,12 @@ breakText Glyphs Glyphs
@msg open the practice sample of `{id}` first (see learn.openSample) open eerst het oefenbestand van `{id}` (zie learn.openSample)
@msg finish `{id}` first (see learn.finish) voltooi eerst `{id}` (zie learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` is de laatste tutorial van zijn vaardigheid
Reset progress Voortgang opnieuw instellen
Mark Tutorial Step Stap van tutorial markeren
Reset Learn Progress Leervoortgang opnieuw instellen
@msg missing `step` (see learn.show) `step` ontbreekt (zie learn.show)
@msg `step` must be a step number from 0 to {last} `step` moet een stapnummer van 0 tot en met {last} zijn
@msg `{id}` is finished (see learn.resetProgress) `{id}` is voltooid (zie learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Uitgestelde effecten vernieuwen
Can't render this file because it is too large.
+6
View File
@@ -7329,6 +7329,12 @@ breakText Glyphs Glify
@msg open the practice sample of `{id}` first (see learn.openSample) najpierw otwórz przykład do ćwiczeń samouczka `{id}` (patrz learn.openSample)
@msg finish `{id}` first (see learn.finish) najpierw zakończ `{id}` (patrz learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` to ostatni samouczek swojej umiejętności
Reset progress Resetuj postępy
Mark Tutorial Step Oznacz krok samouczka
Reset Learn Progress Resetuj postępy nauki
@msg missing `step` (see learn.show) brak `step` (patrz learn.show)
@msg `step` must be a step number from 0 to {last} `step` musi być numerem kroku od 0 do {last}
@msg `{id}` is finished (see learn.resetProgress) `{id}` jest zakończony (patrz learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Odśwież odroczone efekty
Can't render this file because it is too large.
+6
View File
@@ -7661,6 +7661,12 @@ swatch Yellow Green Verde-amarelado
@msg open the practice sample of `{id}` first (see learn.openSample) abra primeiro a amostra de prática de `{id}` (veja learn.openSample)
@msg finish `{id}` first (see learn.finish) conclua primeiro `{id}` (veja learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` é o último tutorial da sua habilidade
Reset progress Redefinir progresso
Mark Tutorial Step Marcar Etapa do Tutorial
Reset Learn Progress Redefinir Progresso de Aprendizagem
@msg missing `step` (see learn.show) falta `step` (veja learn.show)
@msg `step` must be a step number from 0 to {last} `step` deve ser um número de etapa de 0 a {last}
@msg `{id}` is finished (see learn.resetProgress) `{id}` está concluído (veja learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Atualizar Efeitos Adiados
Can't render this file because it is too large.
+6
View File
@@ -7561,6 +7561,12 @@ shape Open Открытый
@msg open the practice sample of `{id}` first (see learn.openSample) сначала откройте учебный пример урока `{id}` (см. learn.openSample)
@msg finish `{id}` first (see learn.finish) сначала завершите `{id}` (см. learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` является последним уроком своего навыка
Reset progress Сбросить прогресс
Mark Tutorial Step Отметить шаг урока
Reset Learn Progress Сбросить прогресс обучения
@msg missing `step` (see learn.show) отсутствует `step` (см. learn.show)
@msg `step` must be a step number from 0 to {last} `step` должен быть номером шага от 0 до {last}
@msg `{id}` is finished (see learn.resetProgress) урок `{id}` завершён (см. learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Обновить отложенные эффекты
Can't render this file because it is too large.
+6
View File
@@ -8910,6 +8910,12 @@ digits Hindi Hindi
@msg open the practice sample of `{id}` first (see learn.openSample) öppna först övningsexemplet för `{id}` (se learn.openSample)
@msg finish `{id}` first (see learn.finish) slutför först `{id}` (se learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` är den sista självstudien i sin färdighet
Reset progress Återställ framsteg
Mark Tutorial Step Markera steg i självstudie
Reset Learn Progress Återställ inlärningsframsteg
@msg missing `step` (see learn.show) `step` saknas (se learn.show)
@msg `step` must be a step number from 0 to {last} `step` måste vara ett stegnummer från 0 till {last}
@msg `{id}` is finished (see learn.resetProgress) `{id}` är slutförd (se learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Uppdatera uppskjutna effekter
Can't render this file because it is too large.
+6
View File
@@ -7545,6 +7545,12 @@ breakText Glyphs Glifler
@msg open the practice sample of `{id}` first (see learn.openSample) önce `{id}` öğreticisinin alıştırma örneğini açın (bkz. learn.openSample)
@msg finish `{id}` first (see learn.finish) önce `{id}` öğreticisini bitirin (bkz. learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}`, becerisinin son öğreticisidir
Reset progress İlerlemeyi sıfırla
Mark Tutorial Step Öğretici Adımını İşaretle
Reset Learn Progress Öğrenme İlerlemesini Sıfırla
@msg missing `step` (see learn.show) `step` eksik (bkz. learn.show)
@msg `step` must be a step number from 0 to {last} `step`, 0 ile {last} arasında bir adım numarası olmalıdır
@msg `{id}` is finished (see learn.resetProgress) `{id}` bitirildi (bkz. learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Ertelenen Efektleri Yenile
Can't render this file because it contains an unexpected character in line 5 and column 25.
+6
View File
@@ -7082,6 +7082,12 @@ breakText Glyphs Гліфи
@msg open the practice sample of `{id}` first (see learn.openSample) спочатку відкрийте навчальний приклад уроку `{id}` (див. learn.openSample)
@msg finish `{id}` first (see learn.finish) спочатку завершіть `{id}` (див. learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` є останнім уроком своєї навички
Reset progress Скинути прогрес
Mark Tutorial Step Позначити крок уроку
Reset Learn Progress Скинути прогрес навчання
@msg missing `step` (see learn.show) відсутній `step` (див. learn.show)
@msg `step` must be a step number from 0 to {last} `step` має бути номером кроку від 0 до {last}
@msg `{id}` is finished (see learn.resetProgress) урок `{id}` завершено (див. learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Оновити відкладені ефекти
Can't render this file because it is too large.
+6
View File
@@ -7083,6 +7083,12 @@ breakText Glyphs Glyph
@msg open the practice sample of `{id}` first (see learn.openSample) hãy mở tệp mẫu thực hành của `{id}` trước (xem learn.openSample)
@msg finish `{id}` first (see learn.finish) hãy hoàn tất `{id}` trước (xem learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` là hướng dẫn cuối cùng trong kỹ năng của nó
Reset progress Đặt lại tiến độ
Mark Tutorial Step Đánh dấu bước hướng dẫn
Reset Learn Progress Đặt lại tiến độ học
@msg missing `step` (see learn.show) thiếu `step` (xem learn.show)
@msg `step` must be a step number from 0 to {last} `step` phải là số bước từ 0 đến {last}
@msg `{id}` is finished (see learn.resetProgress) `{id}` đã hoàn tất (xem learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects Làm mới hiệu ứng bị hoãn
Can't render this file because it contains an unexpected character in line 204 and column 5.
+6
View File
@@ -7311,6 +7311,12 @@ breakText Glyphs 字形
@msg open the practice sample of `{id}` first (see learn.openSample) 请先打开 `{id}` 的练习示例 (请参阅 learn.openSample)
@msg finish `{id}` first (see learn.finish) 请先完成 `{id}` (请参阅 learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` 是其技能的最后一个教程
Reset progress 重置进度
Mark Tutorial Step 标记教程步骤
Reset Learn Progress 重置学习进度
@msg missing `step` (see learn.show) 缺少 `step` (请参阅 learn.show)
@msg `step` must be a step number from 0 to {last} `step` 必须是 0 到 {last} 之间的步骤编号
@msg `{id}` is finished (see learn.resetProgress) `{id}` 已完成 (请参阅 learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects 刷新延迟的效果
Can't render this file because it is too large.
+6
View File
@@ -7119,6 +7119,12 @@ breakText Glyphs 字符
@msg open the practice sample of `{id}` first (see learn.openSample) 請先開啟 `{id}` 的練習範例 (請參閱 learn.openSample)
@msg finish `{id}` first (see learn.finish) 請先完成 `{id}` (請參閱 learn.finish)
@msg `{id}` is the last tutorial in its skill `{id}` 是其技能的最後一個教學課程
Reset progress 重設進度
Mark Tutorial Step 標記教學課程步驟
Reset Learn Progress 重設學習進度
@msg missing `step` (see learn.show) 未提供 `step` (請參閱 learn.show)
@msg `step` must be a step number from 0 to {last} `step` 必須是 0 到 {last} 之間的步驟編號
@msg `{id}` is finished (see learn.resetProgress) `{id}` 已完成 (請參閱 learn.resetProgress)
# Deferred plug-in effects
Refresh Deferred Effects 重新整理延後的效果
Can't render this file because it is too large.
+14 -2
View File
@@ -667,7 +667,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"learn.list",
"List Tutorials",
"",
"{} the Learn panel's skills in the order its Skills tab lists them: {skills: [{id, title, tutorials: [{id, title, steps: number of steps}]}]}",
"{} the Learn panel's skills in the order its Skills tab lists them, with the progress its rings, counts and check marks show: {skills: [{id, title, done: number of its tutorials finished, total: number of its tutorials, tutorials: [{id, title, steps: number of steps, finished: bool, completedSteps: [zero-based indexes of the steps marked done, every step once finished]}]}]}",
),
(
"learn.skills",
@@ -693,11 +693,17 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{tutorial: id (see learn.list)} open that tutorial's practice sample as a new untitled document, which becomes the active one, and move the Learn view to the tutorial, as its View more button does; each call opens another copy and open documents stay as they are; a missing, non-string or unknown id is an error and changes nothing; returns {tutorial, index, title} of the new document",
),
(
"learn.markStep",
"Mark Tutorial Step",
"",
"{tutorial: id (see learn.list), step: zero-based step number, complete?: bool} mark a step of a tutorial whose sample learn.openSample opened as done (true, omitted or null) or not done (false), as the check box before the step does; marking does not finish the tutorial; a missing, non-string or unknown id, a missing step or one the tutorial does not have, a complete that is not true or false, a tutorial whose sample was not opened, or a finished tutorial is an error and changes nothing; the preferences keep the marks; returns {tutorial, step, complete}",
),
(
"learn.finish",
"Finish Tutorial",
"",
"{tutorial: id (see learn.list)} mark a tutorial whose sample learn.openSample opened as finished, as its Finish tutorial button does; finishing it again keeps one entry; a missing, non-string or unknown id, or a tutorial whose sample was not opened, is an error and changes nothing; returns {tutorial, finished: true, next: id of the next tutorial in its skill, or null after the skill's last one}",
"{tutorial: id (see learn.list)} mark a tutorial whose sample learn.openSample opened as finished, with all its steps done, as its Finish tutorial button does; finishing it again keeps one entry, and needs no sample after a restart; a missing, non-string or unknown id, or a tutorial not finished whose sample was not opened, is an error and changes nothing; the preferences keep the tutorials finished; returns {tutorial, finished: true, next: id of the next tutorial in its skill, or null after the skill's last one}",
),
(
"learn.next",
@@ -705,6 +711,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{tutorial: id (see learn.list)} show the Learn panel on the tutorial after a finished one in the same skill, as its Next Up button does, without opening a sample; a missing, non-string or unknown id, a tutorial not finished, or a skill's last tutorial is an error and changes nothing; returns {skill, tutorial, title} of the next tutorial",
),
(
"learn.resetProgress",
"Reset Learn Progress",
"",
"{} clear the Learn panel's progress, every tutorial finished and every step marked done, as Reset progress under the list of skills does; open documents, opened practice samples and the Learn view stay as they are; returns what learn.list returns",
),
("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"),
(
"window.workspace",
+178 -48
View File
@@ -1,18 +1,23 @@
//! Learn panel (Window › Learn, and Explore tutorials at the right end of the app bar): short
//! hands-on tutorials grouped by skill ([`catalog::SKILLS`]). The For you tab holds the Get started
//! section with the Quick skill guide card, which opens the Skills tab. The Skills tab lists each
//! skill with an empty progress ring and its count of tutorials; a skill lists its tutorials, and a
//! skill with a progress ring that fills as its tutorials are finished and the count of tutorials
//! finished of all; a skill lists its tutorials, each finished one with a check mark, and a
//! tutorial previews its numbered steps. A tool's name in a step is a link that shows where the tool
//! is ([`locate`]). Back goes up one level. Under the steps, View more opens the tutorial's practice
//! sample as a new untitled document; Finish tutorial then marks it finished, and Next Up shows the
//! next tutorial in the same skill. `learn.list` lists the tutorials, `learn.skills` shows the list
//! of skills, `learn.show` opens one tutorial, `learn.locateTool` shows where a tool is,
//! `learn.openSample`, `learn.finish` and `learn.next` run the three practice controls, and
//! `window.learn` shows or hides the panel. The Home screen's Learn section shows the same card
//! ([`guide_card`]) and the tutorials, which open the panel.
//! sample as a new untitled document; the check box before each step then marks it done, Finish
//! tutorial marks the tutorial finished with all its steps, and Next Up shows the next tutorial in
//! the same skill. The preferences keep the progress ([`progress`]); Reset progress under the
//! skills clears it. `learn.list` lists the tutorials with their progress, `learn.skills` shows the
//! list of skills, `learn.show` opens one tutorial, `learn.locateTool` shows where a tool is,
//! `learn.openSample`, `learn.markStep`, `learn.finish` and `learn.next` run the practice controls,
//! `learn.resetProgress` clears the progress, and `window.learn` shows or hides the panel. The Home
//! screen's Learn section shows the same card ([`guide_card`]) and the tutorials, which open the
//! panel.
pub mod catalog;
pub mod locate;
pub mod progress;
pub mod samples;
use egui::{Sense, Stroke, Ui, vec2};
@@ -41,12 +46,10 @@ pub struct View {
pub tutorial: Option<&'static str>,
}
/// The tutorials whose samples View more opened, and the tutorials finished, in the order they
/// were finished. Each id is listed once.
/// The tutorials whose samples View more opened while the app runs. Each id is listed once.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Practice {
pub opened: Vec<&'static str>,
pub finished: Vec<&'static str>,
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
@@ -126,17 +129,26 @@ fn skills(app: &mut VectorcraftApp, ui: &mut Ui) {
}
crate::widgets::subheader(ui, skill.title);
for tutorial in skill.tutorials {
if row(ui, tutorial.title, None).clicked() {
if row(ui, tutorial.title, Mark::Check(app.ui.learn_progress.is_finished(tutorial.id))).clicked() {
app.ui.learn.tutorial = Some(tutorial.id);
}
}
return;
}
for skill in catalog::SKILLS {
if row(ui, skill.title, Some(skill.tutorials.len())).clicked() {
let ring = Mark::Ring { done: app.ui.learn_progress.done(skill), total: skill.tutorials.len() };
if row(ui, skill.title, ring).clicked() {
app.ui.learn.skill = Some(skill.id);
}
}
if !app.ui.learn_progress.is_empty() {
ui.add_space(6.0);
if ui.button(tl!("Reset progress")).clicked()
&& let Err(e) = app.run("learn.resetProgress", json!({}))
{
app.status(e);
}
}
}
/// The Back button above a skill's tutorials and a tutorial's steps; true when it was clicked.
@@ -144,15 +156,26 @@ fn back(ui: &mut Ui) -> bool {
crate::widgets::icon_button(ui, "chevron-left", tl!("Back"), false, 22.0).clicked()
}
/// A tutorial's title and its numbered steps. The tool's name in a step is a blue link: a click, or
/// Enter or Space while it has the keyboard focus, shows where the tool is (`learn.locateTool`).
fn steps(app: &mut VectorcraftApp, ui: &mut Ui, tutorial: &catalog::Tutorial) {
/// A tutorial's title and its numbered steps, each with a check box that shows whether it is done.
/// The check boxes can be changed once the tutorial's sample is open and until it is finished
/// (`learn.markStep`). The tool's name in a step is a blue link: a click, or Enter or Space while it
/// has the keyboard focus, shows where the tool is (`learn.locateTool`).
fn steps(app: &mut VectorcraftApp, ui: &mut Ui, tutorial: &'static catalog::Tutorial) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(tutorial.title).size(13.0).color(t.text_strong));
ui.add_space(4.0);
let markable = app.ui.learn_practice.opened.contains(&tutorial.id) && !app.ui.learn_progress.is_finished(tutorial.id);
let mut locate = None;
let mut marked = None;
for (i, step) in tutorial.steps.iter().enumerate() {
let mut done = app.ui.learn_progress.step_done(tutorial, i);
ui.horizontal_top(|ui| {
let resp = ui.add_enabled(markable, egui::Checkbox::without_text(&mut done));
let name = format!("{}. {}", i + 1, step.plain());
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, markable, done, &name));
if resp.changed() {
marked = Some((i, done));
}
ui.label(egui::RichText::new(format!("{}.", i + 1)).color(t.text_dim));
let (Some(tool), Some((before, name, after))) = (step.tool, step.tool_name()) else {
ui.add(egui::Label::new(egui::RichText::new(step.text).color(t.text)).wrap());
@@ -173,6 +196,11 @@ fn steps(app: &mut VectorcraftApp, ui: &mut Ui, tutorial: &catalog::Tutorial) {
});
ui.add_space(2.0);
}
if let Some((step, complete)) = marked
&& let Err(e) = app.run("learn.markStep", json!({ "tutorial": tutorial.id, "step": step, "complete": complete }))
{
app.status(e);
}
if let Some(tool) = locate {
if let Err(e) = app.run("learn.locateTool", json!({ "tool": tool })) {
app.status(e);
@@ -205,12 +233,11 @@ fn practice(app: &mut VectorcraftApp, ui: &mut Ui, tutorial: &'static catalog::T
if ui.button(tl!("View more")).clicked() {
run = Some(("learn.openSample", params.clone()));
}
let done = &app.ui.learn_practice;
if done.opened.contains(&id) && !done.finished.contains(&id) && ui.button(tl!("Finish tutorial")).clicked() {
if app.ui.learn_practice.opened.contains(&id) && !app.ui.learn_progress.is_finished(id) && ui.button(tl!("Finish tutorial")).clicked() {
run = Some(("learn.finish", params.clone()));
}
});
if app.ui.learn_practice.finished.contains(&id) {
if app.ui.learn_progress.is_finished(id) {
ui.add_space(6.0);
match catalog::next(id) {
Some(next) => {
@@ -236,46 +263,112 @@ fn practice(app: &mut VectorcraftApp, ui: &mut Ui, tutorial: &'static catalog::T
}
}
/// A clickable row: `title`, and for a skill (`tutorials`) a progress ring before it and the
/// count of tutorials done of all after it. No progress is kept yet, so the ring is empty.
fn row(ui: &mut Ui, title: &str, tutorials: Option<usize>) -> egui::Response {
/// What a row shows before its title.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Mark {
/// A skill: a progress ring that fills as its tutorials are finished, and after the title the
/// count of tutorials finished of all.
Ring { done: usize, total: usize },
/// A tutorial: a check mark once it is finished (true).
Check(bool),
}
/// The radius of a row's progress ring or check mark.
const MARK_RADIUS: f32 = 7.0;
/// A clickable row: `title`, with `mark` before it. Screen readers get the count of a skill's
/// tutorials finished, or Finished for a finished tutorial, as the row's value.
fn row(ui: &mut Ui, title: &str, mark: Mark) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(rect, egui::CornerRadius::same(4), t.hover);
}
let painter = ui.painter().with_clip_rect(rect);
let mut left = rect.left() + 6.0;
let mut right = rect.right() - 6.0;
let chevron = egui::Rect::from_center_size(egui::pos2(right - 6.0, rect.center().y), vec2(12.0, 12.0));
crate::icons::paint(ui, "chevron-right", chevron, t.text_dim);
right = chevron.left() - 6.0;
if let Some(total) = tutorials {
ui.painter().circle_stroke(egui::pos2(left + 8.0, rect.center().y), 7.0, Stroke::new(2.0, t.border));
left += 24.0;
let count = ui.painter().layout_no_wrap(format!("0/{total}"), egui::FontId::proportional(11.5), t.text_dim);
right -= count.size().x;
ui.painter().galley(egui::pos2(right, rect.center().y - count.size().y / 2.0), count, t.text_dim);
right -= 6.0;
}
let name = ui.painter().layout(title.to_string(), egui::FontId::proportional(12.5), t.text, f32::INFINITY);
let at = egui::pos2(left + 8.0, rect.center().y);
left += 24.0;
let value = match mark {
Mark::Ring { done, total } => {
ring(&painter, &t, at, done, total);
let count = painter.layout_no_wrap(format!("{done}/{total}"), egui::FontId::proportional(11.5), t.text_dim);
right -= count.size().x;
painter.galley(egui::pos2(right, rect.center().y - count.size().y / 2.0), count, t.text_dim);
right -= 6.0;
Some(format!("{done}/{total}"))
}
Mark::Check(finished) => {
check(&painter, &t, at, finished);
finished.then(|| tl!("Finished").to_string())
}
};
let name = painter.layout(title.to_string(), egui::FontId::proportional(12.5), t.text, f32::INFINITY);
let clip = egui::Rect::from_min_max(egui::pos2(left, rect.top()), egui::pos2(right.max(left), rect.bottom()));
ui.painter().with_clip_rect(clip).galley(egui::pos2(left, rect.center().y - name.size().y / 2.0), name, t.text);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), title));
painter.with_clip_rect(clip).galley(egui::pos2(left, rect.center().y - name.size().y / 2.0), name, t.text);
resp.widget_info(|| egui::WidgetInfo {
current_text_value: value.clone(),
..egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), title)
});
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// A skill's progress ring at `center`: an outline, with the share `done` of `total` drawn over it
/// clockwise from the top. With no tutorials it stays empty.
fn ring(painter: &egui::Painter, t: &Tokens, center: egui::Pos2, done: usize, total: usize) {
painter.circle_stroke(center, MARK_RADIUS, Stroke::new(2.0, t.border));
if done == 0 || total == 0 {
return;
}
let stroke = Stroke::new(2.0, t.accent);
if done >= total {
painter.circle_stroke(center, MARK_RADIUS, stroke);
return;
}
let sweep = std::f32::consts::TAU * done as f32 / total as f32;
// About one segment per 1/32 of a turn, and never fewer than two.
let segments = ((32.0 * done as f32 / total as f32).ceil() as usize).max(2);
let points = (0..=segments)
.map(|k| {
let angle = -std::f32::consts::FRAC_PI_2 + sweep * k as f32 / segments as f32;
center + MARK_RADIUS * vec2(angle.cos(), angle.sin())
})
.collect();
painter.add(egui::Shape::line(points, stroke));
}
/// A tutorial's mark at `center`: an outline, filled with a check mark once it is finished.
fn check(painter: &egui::Painter, t: &Tokens, center: egui::Pos2, finished: bool) {
if !finished {
painter.circle_stroke(center, MARK_RADIUS, Stroke::new(1.0, t.border));
return;
}
painter.circle_filled(center, MARK_RADIUS, t.accent);
let tick = vec![center + vec2(-3.5, 0.0), center + vec2(-1.0, 2.5), center + vec2(3.5, -2.5)];
painter.add(egui::Shape::line(tick, Stroke::new(1.5, t.panel)));
}
/// The Learn panel's UI commands: `window.learn`, `learn.list`, `learn.skills`, `learn.show`,
/// `learn.locateTool`, `learn.openSample`, `learn.finish` and `learn.next`.
/// `learn.locateTool`, `learn.openSample`, `learn.markStep`, `learn.finish`, `learn.next` and
/// `learn.resetProgress`.
pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
Some(match id {
"window.learn" => toggle(app, p),
"learn.list" => Ok(list()),
"learn.list" => Ok(list(&app.ui.learn_progress)),
"learn.skills" => skills_list(app),
"learn.show" => open(app, p),
"learn.locateTool" => locate::locate_tool(app, p),
"learn.openSample" => open_sample(app, p),
"learn.markStep" => mark_step(app, p),
"learn.finish" => finish(app, p),
"learn.next" => next(app, p),
"learn.resetProgress" => {
app.ui.learn_progress = progress::Progress::default();
Ok(list(&app.ui.learn_progress))
}
_ => return None,
})
}
@@ -300,11 +393,25 @@ fn toggle(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
Ok(json!(visible(&app.ui)))
}
/// `learn.list`: every skill with its tutorials and their step counts.
fn list() -> Value {
/// `learn.list`: every skill with its count of tutorials finished (`done`) of all (`total`), and its
/// tutorials with their step counts, whether each is finished, and the zero-based indexes of its
/// steps marked done (`completedSteps`).
fn list(progress: &progress::Progress) -> Value {
let skills = catalog::SKILLS.iter().map(|s| {
let tutorials: Vec<Value> = s.tutorials.iter().map(|t| json!({"id": t.id, "title": t.title, "steps": t.steps.len()})).collect();
json!({"id": s.id, "title": s.title, "tutorials": tutorials})
let tutorials: Vec<Value> = s
.tutorials
.iter()
.map(|t| {
json!({
"id": t.id,
"title": t.title,
"steps": t.steps.len(),
"finished": progress.is_finished(t.id),
"completedSteps": progress.completed_steps(t),
})
})
.collect();
json!({"id": s.id, "title": s.title, "done": progress.done(s), "total": s.tutorials.len(), "tutorials": tutorials})
});
json!({ "skills": skills.collect::<Vec<_>>() })
}
@@ -314,7 +421,7 @@ fn list() -> Value {
fn skills_list(app: &mut VectorcraftApp) -> Result<Value, String> {
crate::panel_docking::reveal_panel(app, ID)?;
app.ui.learn = View { tab: Tab::Skills, skill: None, tutorial: None };
Ok(list())
Ok(list(&app.ui.learn_progress))
}
/// `learn.show {tutorial}`: show the panel on the Skills tab with that tutorial's steps → the
@@ -357,19 +464,42 @@ fn open_sample(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
Ok(json!({"tutorial": id, "index": opened["index"], "title": opened["title"]}))
}
/// `learn.finish {tutorial}`: Finish tutorial. Mark a tutorial whose sample was opened finished →
/// `{tutorial, finished: true, next}`, where `next` is the id of the next tutorial in its skill, or
/// null after the skill's last one. Finishing it again keeps one entry. The Learn view stays as it is.
/// `learn.markStep {tutorial, step, complete?}`: a step's check box. Mark step `step` (zero-based)
/// of a tutorial whose sample was opened done (`complete` true or omitted) or not done (false) →
/// `{tutorial, step, complete}`. A finished tutorial keeps every step done, so marking one of its
/// steps is an error; so are a step the tutorial does not have and a `complete` that is not true or
/// false. An error changes nothing.
fn mark_step(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let (_, tutorial) = tutorial_param(p)?;
let id = tutorial.id;
let last = tutorial.steps.len().saturating_sub(1);
let step = match p.get("step") {
None | Some(Value::Null) => return Err("missing `step` (see learn.show)".into()),
Some(v) => v.as_u64().and_then(|s| usize::try_from(s).ok()).filter(|s| *s < tutorial.steps.len()),
};
let step = step.ok_or_else(|| format!("`step` must be a step number from 0 to {last}"))?;
let complete = crate::menus::opt_bool(p, "complete")?.unwrap_or(true);
if !app.ui.learn_practice.opened.contains(&id) {
return Err(format!("open the practice sample of `{id}` first (see learn.openSample)"));
}
if app.ui.learn_progress.is_finished(id) {
return Err(format!("`{id}` is finished (see learn.resetProgress)"));
}
app.ui.learn_progress.mark(id, step, complete);
Ok(json!({"tutorial": id, "step": step, "complete": complete}))
}
/// `learn.finish {tutorial}`: Finish tutorial. Mark a tutorial whose sample was opened finished,
/// with all its steps done → `{tutorial, finished: true, next}`, where `next` is the id of the next
/// tutorial in its skill, or null after the skill's last one. Finishing it again keeps one entry,
/// also after a restart, when its sample has not been opened again. The Learn view stays as it is.
fn finish(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let (_, tutorial) = tutorial_param(p)?;
let id = tutorial.id;
let practice = &mut app.ui.learn_practice;
if !practice.opened.contains(&id) {
if !app.ui.learn_practice.opened.contains(&id) && !app.ui.learn_progress.is_finished(id) {
return Err(format!("open the practice sample of `{id}` first (see learn.openSample)"));
}
if !practice.finished.contains(&id) {
practice.finished.push(id);
}
app.ui.learn_progress.finish(id);
Ok(json!({"tutorial": id, "finished": true, "next": catalog::next(id).map(|t| t.id)}))
}
@@ -379,7 +509,7 @@ fn finish(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
fn next(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let (skill, tutorial) = tutorial_param(p)?;
let id = tutorial.id;
if !app.ui.learn_practice.finished.contains(&id) {
if !app.ui.learn_progress.is_finished(id) {
return Err(format!("finish `{id}` first (see learn.finish)"));
}
let after = catalog::next(id).ok_or_else(|| format!("`{id}` is the last tutorial in its skill"))?;
+122
View File
@@ -0,0 +1,122 @@
//! What a learner has done in the Learn panel: the steps marked done in each tutorial and the
//! tutorials finished. The UI preferences keep it (`UiState::learn_progress`), so it lasts from
//! one start to the next where the preferences are saved. Saved progress is read leniently: an
//! unknown tutorial id, a step the tutorial does not have, a repeated entry or a value of the wrong
//! kind is skipped, and the rest of the preferences read as usual.
use std::collections::BTreeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use super::catalog;
/// The most entries read from one saved list or map; the rest are skipped.
const MAX_READ: usize = 1024;
/// The tutorials finished and the steps marked done.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Progress {
/// The tutorials finished, in the order they were finished. Each id is listed once.
pub finished: Vec<&'static str>,
/// For each tutorial not finished, the zero-based indexes of the steps marked done, ascending.
/// A tutorial with no step marked has no entry.
pub steps: BTreeMap<&'static str, Vec<usize>>,
}
impl Progress {
/// Nothing is finished and no step is marked.
pub fn is_empty(&self) -> bool {
self.finished.is_empty() && self.steps.is_empty()
}
/// Whether the tutorial `id` is finished.
pub fn is_finished(&self, id: &str) -> bool {
self.finished.contains(&id)
}
/// The zero-based indexes of `tutorial`'s steps marked done, ascending: every step of a
/// finished tutorial.
pub fn completed_steps(&self, tutorial: &catalog::Tutorial) -> Vec<usize> {
if self.is_finished(tutorial.id) {
return (0..tutorial.steps.len()).collect();
}
self.steps.get(tutorial.id).cloned().unwrap_or_default()
}
/// Whether step `step` of `tutorial` is marked done.
pub fn step_done(&self, tutorial: &catalog::Tutorial, step: usize) -> bool {
self.is_finished(tutorial.id) || self.steps.get(tutorial.id).is_some_and(|s| s.contains(&step))
}
/// Mark step `step` of the unfinished tutorial `id` done (`complete`) or not done.
pub fn mark(&mut self, id: &'static str, step: usize, complete: bool) {
let marks = self.steps.entry(id).or_default();
match (marks.binary_search(&step), complete) {
(Err(at), true) => marks.insert(at, step),
(Ok(at), false) => {
marks.remove(at);
}
_ => {}
}
if marks.is_empty() {
self.steps.remove(id);
}
}
/// Mark the tutorial `id` finished, with all its steps done. Finishing it again keeps one entry.
pub fn finish(&mut self, id: &'static str) {
if !self.is_finished(id) {
self.finished.push(id);
}
self.steps.remove(id);
}
/// How many of `skill`'s tutorials are finished.
pub fn done(&self, skill: &catalog::Skill) -> usize {
skill.tutorials.iter().filter(|t| self.is_finished(t.id)).count()
}
/// Progress read from a saved value, keeping only what the catalog knows (see the module).
fn from_value(saved: &Value) -> Self {
let mut progress = Progress::default();
let known = |id: &Value| id.as_str().and_then(catalog::tutorial).map(|(_, t)| t);
if let Some(finished) = saved.get("finished").and_then(Value::as_array) {
for tutorial in finished.iter().take(MAX_READ).filter_map(known) {
progress.finish(tutorial.id);
}
}
if let Some(steps) = saved.get("steps").and_then(Value::as_object) {
for (id, marks) in steps.iter().take(MAX_READ) {
let Some((_, tutorial)) = catalog::tutorial(id) else { continue };
if progress.is_finished(tutorial.id) {
continue;
}
let indexes = marks.as_array().into_iter().flatten().take(MAX_READ).filter_map(Value::as_u64);
for step in indexes.filter_map(|s| usize::try_from(s).ok()).filter(|s| *s < tutorial.steps.len()) {
progress.mark(tutorial.id, step, true);
}
}
}
progress
}
}
/// The saved form: `{finished: [id], steps: {id: [index]}}`.
#[derive(Serialize)]
struct Saved<'a> {
finished: &'a [&'static str],
steps: &'a BTreeMap<&'static str, Vec<usize>>,
}
impl Serialize for Progress {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
Saved { finished: &self.finished, steps: &self.steps }.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for Progress {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(Progress::from_value(&Value::deserialize(deserializer)?))
}
}
+282 -7
View File
@@ -1,7 +1,7 @@
use std::collections::HashSet;
use egui::accesskit::Node;
use serde_json::json;
use egui::accesskit::{Node, Role, Toggled};
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use super::{ID, Practice, Tab, View, catalog, samples, show};
@@ -225,7 +225,8 @@ fn learn_show_opens_a_tutorial_and_learn_list_lists_them() {
assert_eq!(skills.len(), catalog::SKILLS.len());
let count: usize = skills.iter().map(|s| s["tutorials"].as_array().map_or(0, Vec::len)).sum();
assert_eq!(count, catalog::SKILLS.iter().map(|s| s.tutorials.len()).sum::<usize>());
assert_eq!(skills[2]["tutorials"][1], json!({"id": "gradient", "title": "Add a gradient", "steps": 3}));
assert_eq!(skills[2]["tutorials"][1], json!({"id": "gradient", "title": "Add a gradient", "steps": 3, "finished": false, "completedSteps": []}));
assert_eq!((skills[2]["done"].clone(), skills[2]["total"].clone()), (json!(0), json!(3)));
}
/// Every tutorial has one sample of its own, in the catalog's order, each one small and read
@@ -279,7 +280,8 @@ fn view_more_opens_the_tutorial_s_sample_as_a_new_untitled_document() {
assert_eq!(docs[0].doc.node_count(), work, "the open document stays as it was");
assert!(docs[1].path.is_none() && docs[1].title().starts_with("Untitled-"), "{}", docs[1].title());
let pen = docs[1].doc.node_count();
assert_eq!(app.ui.learn_practice, Practice { opened: vec!["pen-paths"], finished: vec![] });
assert_eq!(app.ui.learn_practice, Practice { opened: vec!["pen-paths"] });
assert!(app.ui.learn_progress.is_empty(), "opening a sample marks nothing");
let r = app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap();
assert_eq!(r["tutorial"], "gradient");
@@ -306,7 +308,7 @@ fn finishing_a_tutorial_offers_next_up_in_its_skill() {
let text = painted_text(&mut app, show);
assert!(text.contains("Finish tutorial") && !text.contains("Next Up"), "{text}");
click(&mut app, &ctx, "Finish tutorial", show);
assert_eq!(app.ui.learn_practice.finished, ["pen-paths"]);
assert_eq!(app.ui.learn_progress.finished, ["pen-paths"]);
let text = painted_text(&mut app, show);
assert!(text.contains("Next Up") && text.contains("Draw smooth curves") && !text.contains("Finish tutorial"), "{text}");
let docs = app.session.documents().len();
@@ -350,7 +352,7 @@ fn the_practice_controls_work_from_the_keyboard() {
assert_eq!(app.ui.learn_practice.opened, ["pen-paths"]);
tab_to(&mut app, &ctx, "Finish tutorial", show);
key(&mut app, &ctx, egui::Key::Enter, show);
assert_eq!(app.ui.learn_practice.finished, ["pen-paths"]);
assert_eq!(app.ui.learn_progress.finished, ["pen-paths"]);
tab_to(&mut app, &ctx, "Next Up", show);
key(&mut app, &ctx, egui::Key::Enter, show);
assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("draw"), tutorial: Some("smooth-curves") });
@@ -408,13 +410,14 @@ fn learn_finish_and_learn_next_follow_the_practice() {
assert_eq!(app.run("learn.finish", json!({"tutorial": "gradient"})).unwrap_err(), open);
assert_eq!(app.run("learn.next", json!({"tutorial": "gradient"})).unwrap_err(), "finish `gradient` first (see learn.finish)");
assert_eq!(app.ui.learn_practice, Practice::default());
assert!(app.ui.learn_progress.is_empty());
app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap();
assert_eq!(app.run("learn.next", json!({"tutorial": "gradient"})).unwrap_err(), "finish `gradient` first (see learn.finish)");
for _ in 0..2 {
let r = app.run("learn.finish", json!({"tutorial": "gradient"})).unwrap();
assert_eq!(r, json!({"tutorial": "gradient", "finished": true, "next": "copy-colors"}));
}
assert_eq!(app.ui.learn_practice.finished, ["gradient"], "finishing again keeps one entry");
assert_eq!(app.ui.learn_progress.finished, ["gradient"], "finishing again keeps one entry");
let r = app.run("learn.next", json!({"tutorial": "gradient"})).unwrap();
assert_eq!(r, json!({"skill": "color", "tutorial": "copy-colors", "title": "Copy colors from other art"}));
assert!(shown(&app));
@@ -471,6 +474,7 @@ fn the_practice_commands_reject_a_missing_or_unknown_tutorial() {
assert_eq!(app.run(cmd, params).unwrap_err(), error, "{cmd}");
assert_eq!(app.ui.learn, View::default());
assert_eq!(app.ui.learn_practice, Practice::default());
assert!(app.ui.learn_progress.is_empty());
assert_eq!(app.session.documents().len(), 1);
assert!(!shown(&app));
}
@@ -581,3 +585,274 @@ fn learn_locate_tool_is_unavailable_while_the_toolbars_are_hidden() {
assert_eq!(reply["location"], "unavailable", "{reply}");
assert!(app.ui.learn_locate.is_none() && !app.ui.toolbar && app.ui.tab_hid.is_some(), "Tab's state stays");
}
/// The screen-reader name of step `i` of the Pen tutorial's check box.
fn pen_step(i: usize) -> String {
let (_, tutorial) = catalog::tutorial("pen-paths").unwrap();
format!("{}. {}", i + 1, tutorial.steps[i].plain())
}
/// The check boxes of what `draw` draws, from the top, as (enabled, checked).
fn check_boxes(app: &mut VectorcraftApp, ctx: &egui::Context, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) -> Vec<(bool, bool)> {
let nodes = frame(app, ctx, vec![], draw);
let mut boxes: Vec<&Node> = nodes.iter().filter(|n| n.role() == Role::CheckBox).collect();
boxes.sort_by(|a, b| a.bounds().map_or(0.0, |r| r.y0).total_cmp(&b.bounds().map_or(0.0, |r| r.y0)));
boxes.iter().map(|n| (!n.is_disabled(), n.toggled() == Some(Toggled::True))).collect()
}
/// Each step has a check box, off until the tutorial's sample is open. A click marks only its own
/// step and a second click clears it; marking every step does not finish the tutorial. A finished
/// tutorial shows every step done, and its check boxes and `learn.markStep` no longer change them.
#[test]
fn a_step_s_check_box_marks_only_that_step() {
let mut app = app();
let ctx = context();
assert_eq!(check_boxes(&mut app, &ctx, pen_paths), [(false, false); 3], "off before View more");
click(&mut app, &ctx, &pen_step(1), pen_paths);
assert!(app.ui.learn_progress.is_empty(), "a check box that is off changes nothing");
app.run("learn.openSample", json!({"tutorial": "pen-paths"})).unwrap();
click(&mut app, &ctx, &pen_step(1), pen_paths);
assert_eq!(check_boxes(&mut app, &ctx, pen_paths), [(true, false), (true, true), (true, false)], "{}", app.ui.status);
let list = app.run("learn.list", json!({})).unwrap();
assert_eq!(list["skills"][1]["tutorials"][0]["completedSteps"], json!([1]));
assert_eq!(list["skills"][1]["tutorials"][1]["completedSteps"], json!([]), "other tutorials stay as they are");
click(&mut app, &ctx, &pen_step(1), pen_paths);
assert!(app.ui.learn_progress.is_empty(), "a second click clears the mark");
for i in 0..3 {
click(&mut app, &ctx, &pen_step(i), pen_paths);
}
assert_eq!(check_boxes(&mut app, &ctx, pen_paths), [(true, true); 3]);
assert!(!app.ui.learn_progress.is_finished("pen-paths"), "marking every step does not finish the tutorial");
assert!(painted_text(&mut app, pen_paths).contains("Finish tutorial"));
click(&mut app, &ctx, &pen_step(0), pen_paths);
click(&mut app, &ctx, "Finish tutorial", pen_paths);
assert_eq!(app.ui.learn_progress.finished, ["pen-paths"]);
assert!(app.ui.learn_progress.steps.is_empty(), "a finished tutorial keeps no marks of its own");
assert_eq!(check_boxes(&mut app, &ctx, pen_paths), [(false, true); 3], "Finish marks every step");
let before = app.ui.learn_progress.clone();
click(&mut app, &ctx, &pen_step(2), pen_paths);
let error = app.run("learn.markStep", json!({"tutorial": "pen-paths", "step": 2, "complete": false})).unwrap_err();
assert_eq!(error, "`pen-paths` is finished (see learn.resetProgress)");
assert_eq!(app.ui.learn_progress, before);
}
/// The check boxes are reached with Tab and changed with Space.
#[test]
fn a_step_s_check_box_works_from_the_keyboard() {
let mut app = app();
let ctx = context();
app.run("learn.openSample", json!({"tutorial": "pen-paths"})).unwrap();
frame(&mut app, &ctx, vec![], pen_paths);
tab_to(&mut app, &ctx, &pen_step(2), pen_paths);
key(&mut app, &ctx, egui::Key::Space, pen_paths);
assert_eq!(app.ui.learn_progress.steps.get("pen-paths"), Some(&vec![2]), "{}", app.ui.status);
}
/// Finishing tutorials fills only their own skill's ring and count, once each however often they
/// are finished; the skill's tutorial rows show which are finished; `learn.list` answers the same.
#[test]
fn finishing_tutorials_fills_their_skill_s_ring_and_count() {
let mut app = app();
let ctx = context();
for id in ["pen-paths", "smooth-curves"] {
app.run("learn.openSample", json!({"tutorial": id})).unwrap();
for _ in 0..2 {
app.run("learn.finish", json!({"tutorial": id})).unwrap();
}
}
let values = |app: &mut VectorcraftApp, view: View| {
app.ui.learn = view;
let nodes = frame(app, &ctx, vec![], show);
nodes
.iter()
.filter(|n| n.role() == Role::Button)
.map(|n| (n.label().unwrap_or_default().to_string(), n.value().map(str::to_string)))
.collect::<Vec<_>>()
};
let skills = View { tab: Tab::Skills, skill: None, tutorial: None };
let rows = values(&mut app, skills.clone());
for skill in catalog::SKILLS {
let count = if skill.id == "draw" { "2/3".to_string() } else { format!("0/{}", skill.tutorials.len()) };
assert!(rows.contains(&(skill.title.to_string(), Some(count.clone()))), "{} {count} in {rows:?}", skill.id);
}
assert!(painted_text(&mut app, show).contains("2/3"));
let list = app.run("learn.list", json!({})).unwrap();
let draw = &list["skills"][1];
assert_eq!((draw["done"].clone(), draw["total"].clone()), (json!(2), json!(3)));
assert_eq!(draw["tutorials"][1]["finished"], json!(true));
assert_eq!(draw["tutorials"][1]["completedSteps"], json!([0, 1, 2]));
assert_eq!(list["skills"][2]["done"], json!(0), "other skills stay at 0");
let rows = values(&mut app, View { tab: Tab::Skills, skill: Some("draw"), tutorial: None });
assert!(rows.contains(&("Draw paths with the Pen".to_string(), Some("Finished".to_string()))), "{rows:?}");
assert!(rows.contains(&("Draw smooth curves".to_string(), Some("Finished".to_string()))), "{rows:?}");
assert!(rows.contains(&("Sketch with the Pencil".to_string(), None)), "{rows:?}");
app.run("learn.openSample", json!({"tutorial": "pencil-sketch"})).unwrap();
app.run("learn.finish", json!({"tutorial": "pencil-sketch"})).unwrap();
let rows = values(&mut app, skills);
assert!(rows.contains(&("Draw and build".to_string(), Some("3/3".to_string()))), "every tutorial finished: {rows:?}");
}
/// A skill row with no tutorials, which the catalog does not have today, shows an empty ring and
/// 0/0.
fn empty_skill(_: &mut VectorcraftApp, ui: &mut egui::Ui) {
super::row(ui, "Empty skill", super::Mark::Ring { done: 0, total: 0 });
}
#[test]
fn a_skill_with_no_tutorials_shows_0_of_0() {
let mut app = app();
let text = painted_text(&mut app, empty_skill);
assert!(text.contains("Empty skill") && text.contains("0/0"), "{text}");
}
/// `learn.markStep` marks a step done by default and with `complete: true`, clears it with `false`,
/// and answers what it did; a bad param is an error and changes nothing.
#[test]
fn learn_mark_step_checks_its_params() {
let mut app = app();
let open = "open the practice sample of `gradient` first (see learn.openSample)";
assert_eq!(app.run("learn.markStep", json!({"tutorial": "gradient", "step": 0})).unwrap_err(), open);
assert!(app.ui.learn_progress.is_empty());
app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap();
assert_eq!(
app.run("learn.markStep", json!({"tutorial": "gradient", "step": 0})).unwrap(),
json!({"tutorial": "gradient", "step": 0, "complete": true})
);
let before = app.ui.learn_progress.clone();
let missing = "missing `step` (see learn.show)";
let range = "`step` must be a step number from 0 to 2";
for (params, error) in [
(json!({"tutorial": "gradient"}), missing),
(json!({"tutorial": "gradient", "step": null}), missing),
(json!({"tutorial": "gradient", "step": 3}), range),
(json!({"tutorial": "gradient", "step": -1}), range),
(json!({"tutorial": "gradient", "step": 1.5}), range),
(json!({"tutorial": "gradient", "step": "1"}), range),
(json!({"tutorial": "gradient", "step": u64::MAX}), range),
(json!({"tutorial": "gradient", "step": 1, "complete": "yes"}), "`complete` must be true or false"),
(json!({"tutorial": "nope", "step": 1}), "unknown tutorial `nope` (see learn.list)"),
(json!({"step": 1}), "missing `tutorial` (see learn.list)"),
] {
assert_eq!(app.run("learn.markStep", params.clone()).unwrap_err(), error, "{params}");
assert_eq!(app.ui.learn_progress, before, "{params}");
}
let r = app.run("learn.markStep", json!({"tutorial": "gradient", "step": 2, "complete": null})).unwrap();
assert_eq!(r, json!({"tutorial": "gradient", "step": 2, "complete": true}));
let r = app.run("learn.markStep", json!({"tutorial": "gradient", "step": 0, "complete": false})).unwrap();
assert_eq!(r, json!({"tutorial": "gradient", "step": 0, "complete": false}));
let r = app.run("learn.markStep", json!({"tutorial": "gradient", "step": 0, "complete": false})).unwrap();
assert_eq!(r["complete"], json!(false), "clearing a step that is not marked is no error");
assert_eq!(app.ui.learn_progress.steps.get("gradient"), Some(&vec![2]));
assert!(app.ui.learn_progress.finished.is_empty());
}
/// `learn.resetProgress` and Reset progress clear every tutorial finished and every step marked;
/// documents, opened samples and the Learn view stay. Reset progress shows only when there is
/// progress.
#[test]
fn reset_progress_clears_only_the_progress() {
let mut app = app();
let ctx = context();
let skills = View { tab: Tab::Skills, skill: None, tutorial: None };
app.ui.learn = skills.clone();
assert!(!painted_text(&mut app, show).contains("Reset progress"));
app.run("learn.openSample", json!({"tutorial": "pen-paths"})).unwrap();
app.run("learn.finish", json!({"tutorial": "pen-paths"})).unwrap();
app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap();
app.run("learn.markStep", json!({"tutorial": "gradient", "step": 1})).unwrap();
let view = app.ui.learn.clone();
let docs = app.session.documents().len();
let reply = app.run("learn.resetProgress", json!({})).unwrap();
assert!(app.ui.learn_progress.is_empty());
assert_eq!(reply, app.run("learn.list", json!({})).unwrap());
assert_eq!(reply["skills"][1]["done"], json!(0));
assert_eq!((app.ui.learn.clone(), app.session.documents().len()), (view, docs));
assert_eq!(app.ui.learn_practice.opened, ["pen-paths", "gradient"], "opened samples stay open for practice");
app.run("learn.finish", json!({"tutorial": "gradient"})).unwrap();
app.ui.learn = skills.clone();
assert!(painted_text(&mut app, show).contains("Reset progress"));
click(&mut app, &ctx, "Reset progress", show);
assert!(app.ui.learn_progress.is_empty(), "{}", app.ui.status);
assert_eq!(app.ui.learn, skills);
assert!(!painted_text(&mut app, show).contains("Reset progress"));
}
/// The UI state after its preferences are saved and read again, as at the next start.
fn restarted(app: &VectorcraftApp) -> crate::state::UiState {
let bytes = serde_json::to_vec(&app.ui).unwrap();
crate::state::UiState::read_preferences(&bytes).expect("the preferences read").ui.sanitized()
}
/// The preferences keep the tutorials finished and the steps marked; the opened samples, the
/// Learn view and a tool's mark last only while the app runs, so a step can be marked again only
/// after View more.
#[test]
fn the_preferences_keep_the_progress() {
let mut app = app();
app.run("learn.openSample", json!({"tutorial": "pen-paths"})).unwrap();
app.run("learn.finish", json!({"tutorial": "pen-paths"})).unwrap();
app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap();
for step in [2, 0] {
app.run("learn.markStep", json!({"tutorial": "gradient", "step": step})).unwrap();
}
let list = app.run("learn.list", json!({})).unwrap();
let ui = restarted(&app);
assert_eq!(ui.learn_progress, app.ui.learn_progress);
assert_eq!(ui.learn_progress.steps.get("gradient"), Some(&vec![0, 2]));
assert_eq!((ui.learn_practice.clone(), ui.learn.clone(), ui.learn_locate.is_none()), (Practice::default(), View::default(), true));
let mut next = self::app();
next.ui = ui;
assert_eq!(next.run("learn.list", json!({})).unwrap(), list);
assert_eq!(next.run("learn.next", json!({"tutorial": "pen-paths"})).unwrap()["tutorial"], "smooth-curves");
let again = next.run("learn.finish", json!({"tutorial": "pen-paths"})).unwrap();
assert_eq!(again, json!({"tutorial": "pen-paths", "finished": true, "next": "smooth-curves"}), "finishing again needs no sample");
assert_eq!(next.ui.learn_progress.finished, ["pen-paths"]);
let open = "open the practice sample of `gradient` first (see learn.openSample)";
assert_eq!(next.run("learn.markStep", json!({"tutorial": "gradient", "step": 1})).unwrap_err(), open);
}
/// Saved preferences with `learn_progress` set to `progress` (left out for None), and
/// `control_bar` on, read as at a start.
fn read_progress(progress: Option<Value>) -> crate::state::UiState {
let mut saved = serde_json::to_value(crate::state::UiState::default()).unwrap();
let fields = saved.as_object_mut().unwrap();
fields.insert("control_bar".into(), json!(true));
match progress {
Some(p) => fields.insert("learn_progress".into(), p),
None => fields.remove("learn_progress"),
};
let ui = crate::state::UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).expect("the preferences read").ui;
assert!(ui.control_bar, "the other preferences read as usual");
ui
}
/// Preferences saved before the progress was kept read with none. Saved progress keeps only the
/// catalog's tutorials and steps, each once, and a value of the wrong kind, or a huge list, does not
/// stop the rest of the preferences from reading.
#[test]
fn saved_progress_is_read_leniently() {
assert!(read_progress(None).learn_progress.is_empty());
for bad in [json!("garbage"), json!(3), json!(null), json!([1, 2]), json!({"finished": "pen-paths", "steps": [0]})] {
assert!(read_progress(Some(bad.clone())).learn_progress.is_empty(), "{bad}");
}
let ui = read_progress(Some(json!({
"finished": ["pen-paths", "nope", 3, "pen-paths", "gradient"],
"steps": {
"gradient": [0],
"smooth-curves": [2, 0, 2, 99, -1, "1", 1.5],
"nope": [0],
"copy-colors": "x",
"add-headline": [],
},
})));
assert_eq!(ui.learn_progress.finished, ["pen-paths", "gradient"]);
let steps: Vec<(&str, Vec<usize>)> = ui.learn_progress.steps.iter().map(|(id, s)| (*id, s.clone())).collect();
assert_eq!(steps, [("smooth-curves", vec![0, 2])], "a finished tutorial keeps no marks");
let ui = read_progress(Some(json!({"finished": vec!["pen-paths"; 100_000], "steps": {"smooth-curves": vec![1; 100_000]}})));
assert_eq!(ui.learn_progress.finished, ["pen-paths"]);
assert_eq!(ui.learn_progress.steps.get("smooth-curves"), Some(&vec![1]));
}
+4 -2
View File
@@ -424,10 +424,11 @@ pub struct UiState {
/// is on screen. Not saved.
#[serde(skip)]
pub learn_locate: Option<crate::panels::learn::locate::Locate>,
/// The tutorials whose practice samples were opened and the tutorials finished, kept while the
/// app runs.
/// The tutorials whose practice samples were opened, kept while the app runs.
#[serde(skip)]
pub learn_practice: crate::panels::learn::Practice,
/// The Learn panel's tutorials finished and steps marked done, saved with the preferences.
pub learn_progress: crate::panels::learn::progress::Progress,
pub control_bar: bool,
/// The Control bar is docked to the bottom of the window instead of the top
/// (`window.control.dock`).
@@ -1121,6 +1122,7 @@ impl Default for UiState {
learn: Default::default(),
learn_locate: None,
learn_practice: Default::default(),
learn_progress: Default::default(),
control_bar: false,
control_bar_bottom: false,
control_bar_floating: false,
+29 -8
View File
@@ -2910,14 +2910,18 @@ own layout rules.
Window › Learn and the Explore tutorials button beside the search icon at the right end of the app bar show and hide
the Learn panel. Its For you tab holds a Get started section with a Quick skill guide card; choosing the card opens
the Skills tab. The Skills tab lists VectorCraft's own skills, each with its number of tutorials and an empty progress
ring. Choosing a skill lists its tutorials, choosing a tutorial shows its numbered steps, and Back goes up one level.
the Skills tab. The Skills tab lists VectorCraft's own skills, each with a progress ring and the count of its tutorials
finished of all its tutorials. Choosing a skill lists its tutorials, choosing a tutorial shows its numbered steps, and
Back goes up one level.
The tutorial text is English. `window.learn {show?}` shows the panel (`show: true`), hides it (`show: false`) or
toggles it (`show` omitted or null), and answers whether it shows; Window › Learn and Explore tutorials run it.
The panel counts as hidden while the dock is hidden or a full screen mode hides the panels. Showing it shows a hidden
dock; in a full screen mode it makes Learn the front panel, which shows when the mode ends, and answers false. Any
other value of `show` is an error and changes nothing. `learn.list {}` → `{skills: [{id, title, tutorials: [{id, title, steps}]}]}`, in the order the Skills tab
lists them, where `steps` is the number of steps. `learn.skills {}` shows the panel on its Skills tab with the list of
other value of `show` is an error and changes nothing. `learn.list {}` →
`{skills: [{id, title, done, total, tutorials: [{id, title, steps, finished, completedSteps}]}]}`, in the order the
Skills tab lists them, where `done` is the number of the skill's tutorials finished, `total` the number of its
tutorials, `steps` the number of a tutorial's steps, `finished` whether it is finished, and `completedSteps` the
zero-based indexes of its steps marked done (every step of a finished tutorial). `learn.skills {}` shows the panel on its Skills tab with the list of
skills, with no skill and no tutorial open, as the Quick skill guide card and Back to skills do, and answers what
`learn.list` answers. `learn.show {tutorial}` shows the panel on its Skills tab with that
tutorial's steps → `{skill, tutorial, title, steps: [text]}`. A missing, non-string or unknown `tutorial` is an error
@@ -2927,18 +2931,33 @@ Under a tutorial's steps, View more opens the tutorial's practice sample, a smal
art its steps work on, as a new untitled document that becomes the active one; Save asks where to save it. Once the
sample is open, Finish tutorial marks the tutorial finished. A finished tutorial shows Next Up with the title of the
next tutorial in the same skill, which Next Up shows without opening its sample. A skill's last tutorial, finished,
says so and shows Back to skills instead; Next Up never moves to another skill. Which tutorials are finished is kept
while the app runs, and the skill rings and counts do not show it yet. These are VectorCraft's own rules; the help
page also plays a short video, which VectorCraft does not have. `learn.openSample {tutorial}` runs View more →
says so and shows Back to skills instead; Next Up never moves to another skill. These are VectorCraft's own rules; the
help page also plays a short video, which VectorCraft does not have. `learn.openSample {tutorial}` runs View more →
`{tutorial, index, title}` of the new document, and moves the Learn view to that tutorial; each call opens another
copy and leaves the open documents as they are. `learn.finish {tutorial}` runs Finish tutorial →
`{tutorial, finished: true, next}`, where `next` is the next tutorial's id in the skill, or null after the skill's
last one; the tutorial's sample must have been opened, and finishing it again keeps one entry. `learn.next {tutorial}`
last one; the tutorial's sample must have been opened, and finishing it again keeps one entry, also after a restart
without opening the sample again. `learn.next {tutorial}`
runs Next Up on a finished tutorial and shows the panel on the next one → `{skill, tutorial, title}`; on a tutorial
not finished, or a skill's last tutorial, it is an error. For all three, a missing, non-string or unknown `tutorial`
is an error, and an error changes nothing. Behavior reference:
[Quick skill guide](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html).
Each step has a check box before its number that shows whether it is done, and a skill's ring fills as its tutorials
are finished, as the help page describes; a finished tutorial has a check mark in its skill's list. A step is marked
done by its check box, which can be changed once the tutorial's sample is open and until the tutorial is finished;
marking every step does not finish the tutorial. Finish tutorial marks all its steps done, and a finished tutorial's
steps stay marked. Reset progress under the list of skills, shown while there is progress, clears every tutorial
finished and every step marked. The desktop app keeps the progress in its preferences, so it lasts to the next start;
when two windows change it, the one that saves last keeps its progress. The web build keeps it while the page is open.
Marking steps by hand, keeping the progress and Reset progress are VectorCraft's own rules.
`learn.markStep {tutorial, step, complete?}` runs a step's check box → `{tutorial, step, complete}`: `step` is the
zero-based step number, and `complete` marks the step done (`true`, omitted or null) or not done (`false`). A missing
`step` or one the tutorial does not have, a `complete` that is not true or false, a tutorial whose sample was not
opened, and a finished tutorial are errors. `learn.resetProgress {}` runs Reset progress and answers what `learn.list`
answers; open documents, opened samples and the Learn view stay as they are. For both, a missing, non-string or
unknown `tutorial` is an error, and an error changes nothing.
The Home screen has a Learn section between Recent Files and the new-file presets, as
[the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/homescreen-overview.html)
lists Learn, with in-app tutorials, among the Home screen's options. The section holds the Quick skill guide card,
@@ -2950,9 +2969,11 @@ rules.
```json
{"name":"invoke_menu","arguments":{"command":"learn.show","params":{"tutorial":"pen-paths"}}}
{"name":"invoke_menu","arguments":{"command":"learn.openSample","params":{"tutorial":"pen-paths"}}}
{"name":"invoke_menu","arguments":{"command":"learn.markStep","params":{"tutorial":"pen-paths","step":0}}}
{"name":"invoke_menu","arguments":{"command":"learn.finish","params":{"tutorial":"pen-paths"}}}
{"name":"invoke_menu","arguments":{"command":"learn.next","params":{"tutorial":"pen-paths"}}}
{"name":"invoke_menu","arguments":{"command":"learn.skills","params":{}}}
{"name":"invoke_menu","arguments":{"command":"learn.resetProgress","params":{}}}
```
A step that names a tool shows the tool's name as a blue link. A click on it, or Enter or Space while it has the
+3 -2
View File
@@ -938,9 +938,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Perspective Distort and Free Distort (`object.distort`) turn each warped object's bounding box as the warp turns the object at its own center, the objects inside groups, compound paths and clip groups and the art of linked opacity masks included, so the next Selection or Free Transform drag uses the tilted box; Object › Transform › Reset Bounding Box squares it again, and corners left where they are add no undo step (VectorCraft's own rule; `bboxrotate::distort_turns_an_upright_box_with_the_warp_at_its_center`, `distort_turns_each_member_and_linked_mask_by_its_own_warp`, `mirrored_and_collapsing_distorts_keep_valid_angles`, `distort_angles_undo_redo_and_reopen_together`, `xform::unchanged_distort_records_no_history`).
- The Free Transform widget's Constrain works in Free Distort as well as in Free Transform ([the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/free-transform-tool.html)). With Constrain on, or Shift held, a corner dragged in Free Distort moves only along the line through the opposite corner, in the box's own proportions, and the other three corners stay in place; on a turned box the line runs through the box's own corners. The line is VectorCraft's own rule. Cmd/Ctrl+Alt+Shift on a corner still drags in Perspective Distort, where Constrain stays unavailable (tools `xform::free::tests::free_distort_corner_keeps_to_its_diagonal_with_shift_or_constrain`, `shift_pressed_during_a_free_distort_drag_constrains_it`, `a_turned_box_is_constrained_along_its_own_diagonal`, `along_diagonal_of_a_degenerate_box`; engine `xform::free_transform_constrained_distort_keeps_the_corner_on_its_diagonal`; ui-egui `free_transform::tests::the_widget_sets_the_mode_and_a_corner_drag_then_distorts`).
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8).
- The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`).
- The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their progress rings and counts of tutorials finished; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`).
- A tool's name in a Learn tutorial step is a blue link that shows where the tool is without choosing it (`learn.locateTool {tool}`): its toolbar button is marked, a collapsed group's flyout opens with the tool's row marked, a torn-off strip comes to the front, and a tool no shown toolbar holds is marked in the All Tools drawer. The mark, its 3 seconds and the hidden-toolbar cases are VectorCraft's own rules ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`, the `a_learn_link_*` tests in `toolbar::tests`).
- Under a tutorial's steps, View more opens the tutorial's own practice sample as a new untitled document, and once the tutorial is finished, Next Up moves to the next tutorial in the same skill, as the help page describes ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html)). The samples are VectorCraft's own drawings. A Finish tutorial button that shows once the sample is open, the untitled copy, Back to skills in place of Next Up after a skill's last tutorial, and Next Up showing the next tutorial without opening its sample are VectorCraft's own rules. `learn.openSample`, `learn.finish`, `learn.next` and `learn.skills` (Back to skills) script them. Not done: the help page's short video, and progress kept between sessions and shown in the skill rings. Regression tests: `view_more_opens_the_tutorial_s_sample_as_a_new_untitled_document`, `finishing_a_tutorial_offers_next_up_in_its_skill`, `the_practice_controls_work_from_the_keyboard`, `the_last_tutorial_of_a_skill_offers_back_to_skills`, `learn_finish_and_learn_next_follow_the_practice`, `learn_skills_shows_the_list_of_skills`, `the_practice_commands_reject_a_missing_or_unknown_tutorial`, `every_tutorial_has_its_own_readable_sample` and `the_next_tutorial_is_the_next_one_in_the_same_skill` in `crates/ui-egui/src/panels/learn/tests.rs`.
- Under a tutorial's steps, View more opens the tutorial's own practice sample as a new untitled document, and once the tutorial is finished, Next Up moves to the next tutorial in the same skill, as the help page describes ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html)). The samples are VectorCraft's own drawings. A Finish tutorial button that shows once the sample is open, the untitled copy, Back to skills in place of Next Up after a skill's last tutorial, and Next Up showing the next tutorial without opening its sample are VectorCraft's own rules. `learn.openSample`, `learn.finish`, `learn.next` and `learn.skills` (Back to skills) script them. Not done: the help page's short video. Regression tests: `view_more_opens_the_tutorial_s_sample_as_a_new_untitled_document`, `finishing_a_tutorial_offers_next_up_in_its_skill`, `the_practice_controls_work_from_the_keyboard`, `the_last_tutorial_of_a_skill_offers_back_to_skills`, `learn_finish_and_learn_next_follow_the_practice`, `learn_skills_shows_the_list_of_skills`, `the_practice_commands_reject_a_missing_or_unknown_tutorial`, `every_tutorial_has_its_own_readable_sample` and `the_next_tutorial_is_the_next_one_in_the_same_skill` in `crates/ui-egui/src/panels/learn/tests.rs`.
- A Learn tutorial's steps are marked complete as the learner works, and a skill's progress ring fills as its tutorials are finished, as the help page describes ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html)). Each step has a check box that marks it done once the tutorial's sample is open (`learn.markStep`); Finish tutorial marks every step done; a skill's count shows its tutorials finished of all, and a finished tutorial has a check mark in its skill's list. Marking steps by hand, the count, keeping the progress in the preferences (the web build keeps it while the page is open) and Reset progress (`learn.resetProgress`) are VectorCraft's own rules; `learn.list` reports the progress. Regression tests: `a_step_s_check_box_marks_only_that_step`, `a_step_s_check_box_works_from_the_keyboard`, `finishing_tutorials_fills_their_skill_s_ring_and_count`, `a_skill_with_no_tutorials_shows_0_of_0`, `learn_mark_step_checks_its_params`, `reset_progress_clears_only_the_progress`, `the_preferences_keep_the_progress` and `saved_progress_is_read_leniently` in `crates/ui-egui/src/panels/learn/tests.rs`.
- The Home screen has a Learn section with the in-app tutorials, as the help page lists among the Home screen's options ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/homescreen-overview.html)): the Quick skill guide card shows the Learn panel's list of skills (`learn.skills`), and each tutorial, listed under its skill, shows the panel at its steps (`learn.show`). It shows with no document open and over open documents, and a choice leaves the documents as they are. Its place between Recent Files and the new-file presets, its layout and what a choice opens are VectorCraft's own rules (`tests::home::home_has_a_learn_section_that_opens_the_learn_panel`, `home_learn_section_over_open_documents_leaves_them_as_they_are`, `home_quick_skill_guide_card_works_from_the_keyboard`).
- The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`).
- The errors of the 3D effects (Extrude & Bevel, Revolve, Inflate, Rotate and their Classic forms) show in the status bar in the interface language: the art a 3D effect cannot build a solid from, invalid light, graphics and surface settings, and the notes for an object that draws no 3D faces or more wireframe edges than the limit. Each has a row in every catalog that translates all status and error messages, and the effect's name and the values in the message are translated or kept as written (`three_d_refusals_are_translated_in_every_complete_catalog`, `three_d_refusals_translate_with_their_values`).
+1 -1
View File
@@ -384,7 +384,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
|---|---|---|
| Effect > Warp Options: style previews, orientation choices and percent controls | done | #1760; focused input, mixed-style and history checks passed; original captures reviewed. No geometry or optional Properties changes. |
| Command search: Up and Down move through the results, Enter runs the highlighted one | done | #1798 (#1757) |
| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with tutorial counts and empty progress rings, a tutorial shows its numbered steps, Back goes up a level; a tool's name in a step is a link that marks the tool where the toolbars show it (its button, its group's flyout, a torn-off strip, a toolbar made with New Toolbar or the All Tools drawer) without choosing it; View more opens the tutorial's practice sample as a new untitled document, Finish tutorial then marks it finished, and Next Up shows the next tutorial in the same skill (no video) | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.skills`, `learn.show`, `learn.locateTool`, `learn.openSample`, `learn.finish`, `learn.next`; the mark's look and 3-second lifetime are VectorCraft's own rules; `panels::learn::tests`, `toolbar::tests` |
| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with progress rings that fill and counts of tutorials finished, a tutorial shows its numbered steps with a check box each that marks the step done once its sample is open, a finished tutorial has a check mark, Back goes up a level; a tool's name in a step is a link that marks the tool where the toolbars show it (its button, its group's flyout, a torn-off strip, a toolbar made with New Toolbar or the All Tools drawer) without choosing it; View more opens the tutorial's practice sample as a new untitled document, Finish tutorial then marks it finished with all its steps, and Next Up shows the next tutorial in the same skill; Reset progress clears the progress (no video) | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.skills`, `learn.show`, `learn.locateTool`, `learn.openSample`, `learn.markStep`, `learn.finish`, `learn.next`, `learn.resetProgress`; the mark's look and 3-second lifetime, marking steps by hand, keeping the progress in the preferences and Reset progress are VectorCraft's own rules; `panels::learn::tests`, `toolbar::tests` |
| Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled | done | #1642 (#1635) |
| Selected Grid Repeat shows row, column and corner handles and spacing sliders; bounding-box handles take priority where they meet | done | #2691, `tools/src/repeat_grid.rs`; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html); drags preview and commit `object.repeat.options` as one undo step, Escape cancels; Radial Repeat has no handles yet |
| Grid Repeat Options (Object › Repeat › Options…) has a Preview checkbox: the repeat on the canvas follows the fields while the dialog is open, Cancel puts the original back without a history step, OK keeps the previewed values as one undo step | done | #2651; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html) lists the Preview option for the grid options only, so Radial and Mirror Repeat Options have none; `dialogs/command.rs` tests |