diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 3e064a7f8..8f492e2b9 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1356,6 +1356,21 @@ common = .color-hex = سداسي عشري .nearest-index-color = أقرب فهرس .exact-index-match = تطابق تام مع الفهرس + .color-recently-used = المستخدمة مؤخرًا + .color-most-used = الأكثر استخدامًا + .color-number = رقم اللون + .color-in-drawing = الألوان في هذا الرسم + .color-sort = فرز + .color-no-drawing-colors = لا توجد ألوان في هذا الرسم بعد + .color-books = كتب الألوان + .color-no-books = لم يتم تحميل كتب ألوان + .color-load-book = تحميل كتاب ألوان (.acb) + .color-book-label = كتاب الألوان + .color-rgb-equivalent = مكافئ RGB + .color-no-match = لا توجد ألوان مطابقة + .color-not-a-book = ليس كتاب ألوان + .color-book-encrypted = كتاب الألوان هذا مشفر ولا يمكن قراءته + .color-book-empty = لا يحتوي كتاب الألوان على ألوان .cad-color-index-aci-10-249 = فهرس ألوان ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = الرسم متساوي القياس diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index e02e20d0f..89a531b0b 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1343,6 +1343,21 @@ common = .color-hex = Hex .nearest-index-color = Най-близък индекс .exact-index-match = Точно съвпадение с индекс + .color-recently-used = Наскоро използвани + .color-most-used = Най-често използвани + .color-number = Номер на цвят + .color-in-drawing = Цветове в този чертеж + .color-sort = Сортиране + .color-no-drawing-colors = Все още няма цветове в този чертеж + .color-books = Цветови каталози + .color-no-books = Няма заредени цветови каталози + .color-load-book = Зареждане на цветови каталог (.acb) + .color-book-label = Цветови каталог + .color-rgb-equivalent = RGB еквивалент + .color-no-match = Няма съвпадащи цветове + .color-not-a-book = Не е цветови каталог + .color-book-encrypted = Този цветови каталог е шифрован и не може да бъде прочетен + .color-book-empty = Цветовият каталог няма цветове .cad-color-index-aci-10-249 = ACI Цветови индекс 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Изометрично чертане diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 544cbc5c6..36bc5f400 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1343,6 +1343,21 @@ common = .color-hex = Hex .nearest-index-color = Nejbližší index .exact-index-match = Přesná shoda s indexem + .color-recently-used = Naposledy použité + .color-most-used = Nejpoužívanější + .color-number = Číslo barvy + .color-in-drawing = Barvy v tomto výkresu + .color-sort = Řadit + .color-no-drawing-colors = V tomto výkresu zatím nejsou žádné barvy + .color-books = Barevné vzorníky + .color-no-books = Nejsou načteny žádné barevné vzorníky + .color-load-book = Načíst barevný vzorník (.acb) + .color-book-label = Barevný vzorník + .color-rgb-equivalent = Ekvivalent RGB + .color-no-match = Žádné odpovídající barvy + .color-not-a-book = Nejde o barevný vzorník + .color-book-encrypted = Tento barevný vzorník je zašifrovaný a nelze jej přečíst + .color-book-empty = Barevný vzorník neobsahuje žádné barvy .cad-color-index-aci-10-249 = Barevný index ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Izometrické kreslení diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 8ca17932f..518150aa6 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1344,6 +1344,21 @@ common = .color-hex = Hex .nearest-index-color = Nächster Index .exact-index-match = Exakter Index-Treffer + .color-recently-used = Zuletzt verwendet + .color-most-used = Am häufigsten verwendet + .color-number = Farbnummer + .color-in-drawing = Farben in dieser Zeichnung + .color-sort = Sortieren + .color-no-drawing-colors = Noch keine Farben in dieser Zeichnung + .color-books = Farbbücher + .color-no-books = Keine Farbbücher geladen + .color-load-book = Farbbuch laden (.acb) + .color-book-label = Farbbuch + .color-rgb-equivalent = RGB-Entsprechung + .color-no-match = Keine passenden Farben + .color-not-a-book = Kein Farbbuch + .color-book-encrypted = Dieses Farbbuch ist verschlüsselt und kann nicht gelesen werden + .color-book-empty = Das Farbbuch enthält keine Farben .cad-color-index-aci-10-249 = ACI Farbindex 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Isometrisches Zeichnen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 7c28112e9..9eb330ec7 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1345,6 +1345,21 @@ common = .color-hex = Hex .nearest-index-color = Πλησιέστερος δείκτης .exact-index-match = Ακριβής αντιστοίχιση δείκτη + .color-recently-used = Πρόσφατα χρησιμοποιημένα + .color-most-used = Πιο συχνά χρησιμοποιημένα + .color-number = Αριθμός χρώματος + .color-in-drawing = Χρώματα σε αυτό το σχέδιο + .color-sort = Ταξινόμηση + .color-no-drawing-colors = Δεν υπάρχουν ακόμη χρώματα σε αυτό το σχέδιο + .color-books = Βιβλία χρωμάτων + .color-no-books = Δεν έχουν φορτωθεί βιβλία χρωμάτων + .color-load-book = Φόρτωση βιβλίου χρωμάτων (.acb) + .color-book-label = Βιβλίο χρωμάτων + .color-rgb-equivalent = Ισοδύναμο RGB + .color-no-match = Κανένα χρώμα δεν ταιριάζει + .color-not-a-book = Δεν είναι βιβλίο χρωμάτων + .color-book-encrypted = Αυτό το βιβλίο χρωμάτων είναι κρυπτογραφημένο και δεν μπορεί να διαβαστεί + .color-book-empty = Το βιβλίο χρωμάτων δεν έχει χρώματα .cad-color-index-aci-10-249 = Δείκτης χρώματος ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Ισομετρική σχεδίαση diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 20d9158c5..9c743dcdd 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1344,6 +1344,21 @@ common = .color-hex = Hex .nearest-index-color = Nearest index .exact-index-match = Exact index match + .color-recently-used = Recently used + .color-most-used = Most used + .color-number = Color number + .color-in-drawing = Colors in this drawing + .color-sort = Sort + .color-no-drawing-colors = No colors in this drawing yet + .color-books = Color Books + .color-no-books = No color books loaded + .color-load-book = Load color book (.acb) + .color-book-label = Color book + .color-rgb-equivalent = RGB equivalent + .color-no-match = No colors match + .color-not-a-book = Not a color book + .color-book-encrypted = This color book is encrypted and cannot be read + .color-book-empty = The color book has no colors .cad-color-index-aci-10-249 = CAD Color Index (ACI) 10–249 .grayscale-250-255 = Grayscale 250–255 .isometric-drafting = Isometric drafting diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 02f7d27aa..a07925a3f 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1344,6 +1344,21 @@ common = .color-hex = Hex .nearest-index-color = Índice más cercano .exact-index-match = Coincide con un índice + .color-recently-used = Usados recientemente + .color-most-used = Más usados + .color-number = Número de color + .color-in-drawing = Colores de este dibujo + .color-sort = Ordenar + .color-no-drawing-colors = Aún no hay colores en este dibujo + .color-books = Libros de colores + .color-no-books = No hay libros de colores cargados + .color-load-book = Cargar libro de colores (.acb) + .color-book-label = Libro de colores + .color-rgb-equivalent = Equivalente RGB + .color-no-match = Ningún color coincide + .color-not-a-book = No es un libro de colores + .color-book-encrypted = Este libro de colores está cifrado y no se puede leer + .color-book-empty = El libro de colores no tiene colores .cad-color-index-aci-10-249 = Índice de color ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Dibujo isométrico diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 2cd800ab5..59b67a113 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1343,6 +1343,21 @@ common = .color-hex = Hex .nearest-index-color = Lähin indeksi .exact-index-match = Täsmälleen indeksiväri + .color-recently-used = Viimeksi käytetyt + .color-most-used = Eniten käytetyt + .color-number = Värin numero + .color-in-drawing = Tämän piirustuksen värit + .color-sort = Lajittele + .color-no-drawing-colors = Tässä piirustuksessa ei ole vielä värejä + .color-books = Värikirjat + .color-no-books = Värikirjoja ei ole ladattu + .color-load-book = Lataa värikirja (.acb) + .color-book-label = Värikirja + .color-rgb-equivalent = RGB-vastine + .color-no-match = Ei vastaavia värejä + .color-not-a-book = Ei värikirja + .color-book-encrypted = Tämä värikirja on salattu eikä sitä voi lukea + .color-book-empty = Värikirjassa ei ole värejä .cad-color-index-aci-10-249 = ACI väriindeksi 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Isometrinen piirtäminen diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 562ba530d..0068f6c15 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1344,6 +1344,21 @@ common = .color-hex = Hex .nearest-index-color = Index le plus proche .exact-index-match = Correspondance exacte d'index + .color-recently-used = Utilisées récemment + .color-most-used = Les plus utilisées + .color-number = Numéro de couleur + .color-in-drawing = Couleurs de ce dessin + .color-sort = Trier + .color-no-drawing-colors = Aucune couleur dans ce dessin pour l'instant + .color-books = Carnets de couleurs + .color-no-books = Aucun carnet de couleurs chargé + .color-load-book = Charger un carnet de couleurs (.acb) + .color-book-label = Carnet de couleurs + .color-rgb-equivalent = Équivalent RVB + .color-no-match = Aucune couleur correspondante + .color-not-a-book = Ce n'est pas un carnet de couleurs + .color-book-encrypted = Ce carnet de couleurs est chiffré et ne peut pas être lu + .color-book-empty = Le carnet de couleurs ne contient aucune couleur .cad-color-index-aci-10-249 = ACI Indice de couleur 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Dessin isométrique diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 38dd1bc64..05aa07fd8 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1332,6 +1332,21 @@ common = .color-hex = हेक्स .nearest-index-color = निकटतम इंडेक्स .exact-index-match = इंडेक्स से सटीक मेल + .color-recently-used = हाल ही में उपयोग किए गए + .color-most-used = सबसे अधिक उपयोग किए गए + .color-number = रंग संख्या + .color-in-drawing = इस ड्रॉइंग के रंग + .color-sort = क्रमबद्ध करें + .color-no-drawing-colors = इस ड्रॉइंग में अभी तक कोई रंग नहीं है + .color-books = रंग पुस्तकें + .color-no-books = कोई रंग पुस्तक लोड नहीं है + .color-load-book = रंग पुस्तक लोड करें (.acb) + .color-book-label = रंग पुस्तक + .color-rgb-equivalent = RGB समतुल्य + .color-no-match = कोई रंग मेल नहीं खाता + .color-not-a-book = यह रंग पुस्तक नहीं है + .color-book-encrypted = यह रंग पुस्तक एन्क्रिप्टेड है और पढ़ी नहीं जा सकती + .color-book-empty = रंग पुस्तक में कोई रंग नहीं है .cad-color-index-aci-10-249 = ACI रंग सूचकांक 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = सममितीय आरेखन diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index c2c689c6f..1e75ed541 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1343,6 +1343,21 @@ common = .color-hex = Hex .nearest-index-color = Legközelebbi index .exact-index-match = Pontos indexegyezés + .color-recently-used = Legutóbb használt + .color-most-used = Leggyakrabban használt + .color-number = Színszám + .color-in-drawing = Színek ebben a rajzban + .color-sort = Rendezés + .color-no-drawing-colors = Ebben a rajzban még nincsenek színek + .color-books = Színkönyvek + .color-no-books = Nincs betöltött színkönyv + .color-load-book = Színkönyv betöltése (.acb) + .color-book-label = Színkönyv + .color-rgb-equivalent = RGB-megfelelő + .color-no-match = Nincs egyező szín + .color-not-a-book = Nem színkönyv + .color-book-encrypted = Ez a színkönyv titkosított, és nem olvasható + .color-book-empty = A színkönyv nem tartalmaz színeket .cad-color-index-aci-10-249 = ACI színindex 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Izometrikus rajzolás diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 8af2dad92..241c4f721 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1343,6 +1343,21 @@ common = .color-hex = Hex .nearest-index-color = Indice più vicino .exact-index-match = Corrispondenza esatta indice + .color-recently-used = Usati di recente + .color-most-used = Più usati + .color-number = Numero colore + .color-in-drawing = Colori in questo disegno + .color-sort = Ordina + .color-no-drawing-colors = Ancora nessun colore in questo disegno + .color-books = Album colori + .color-no-books = Nessun album colori caricato + .color-load-book = Carica album colori (.acb) + .color-book-label = Album colori + .color-rgb-equivalent = Equivalente RGB + .color-no-match = Nessun colore corrispondente + .color-not-a-book = Non è un album colori + .color-book-encrypted = Questo album colori è crittografato e non può essere letto + .color-book-empty = L'album colori non contiene colori .cad-color-index-aci-10-249 = ACI Indice colori 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Disegno isometrico diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index e221469e4..3e3d9709d 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1334,6 +1334,21 @@ common = .color-hex = 16進 .nearest-index-color = 最も近いインデックス .exact-index-match = インデックスと完全一致 + .color-recently-used = 最近使用した色 + .color-most-used = 使用頻度の高い色 + .color-number = 色番号 + .color-in-drawing = この図面の色 + .color-sort = 並べ替え + .color-no-drawing-colors = この図面にはまだ色がありません + .color-books = カラー ブック + .color-no-books = カラー ブックが読み込まれていません + .color-load-book = カラー ブックを読み込む (.acb) + .color-book-label = カラー ブック + .color-rgb-equivalent = RGB 相当値 + .color-no-match = 一致する色がありません + .color-not-a-book = カラー ブックではありません + .color-book-encrypted = このカラー ブックは暗号化されているため読み込めません + .color-book-empty = カラー ブックに色がありません .cad-color-index-aci-10-249 = ACI カラーインデックス 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = アイソメ図作成 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 931a8f746..0c6364171 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1343,6 +1343,21 @@ common = .color-hex = 16진수 .nearest-index-color = 가장 가까운 인덱스 .exact-index-match = 인덱스와 정확히 일치 + .color-recently-used = 최근 사용 + .color-most-used = 가장 많이 사용 + .color-number = 색상 번호 + .color-in-drawing = 이 도면의 색상 + .color-sort = 정렬 + .color-no-drawing-colors = 이 도면에는 아직 색상이 없습니다 + .color-books = 색상표 + .color-no-books = 로드된 색상표가 없습니다 + .color-load-book = 색상표 로드 (.acb) + .color-book-label = 색상표 + .color-rgb-equivalent = RGB 등가 + .color-no-match = 일치하는 색상이 없습니다 + .color-not-a-book = 색상표가 아닙니다 + .color-book-encrypted = 이 색상표는 암호화되어 있어 읽을 수 없습니다 + .color-book-empty = 색상표에 색상이 없습니다 .cad-color-index-aci-10-249 = ACI 컬러 인덱스 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = 등각 제도 diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index 49f805744..859458ff0 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -1349,6 +1349,21 @@ common = .color-hex = Hex .nearest-index-color = ດັດຊະນີທີ່ໃກ້ທີ່ສຸດ .exact-index-match = ກົງກັບດັດຊະນີພໍດີ + .color-recently-used = ໃຊ້ຫຼ້າສຸດ + .color-most-used = ໃຊ້ຫຼາຍທີ່ສຸດ + .color-number = ເລກສີ + .color-in-drawing = ສີໃນແບບແຕ້ມນີ້ + .color-sort = ຈັດລຽງ + .color-no-drawing-colors = ຍັງບໍ່ມີສີໃນແບບແຕ້ມນີ້ + .color-books = ປຶ້ມສີ + .color-no-books = ບໍ່ໄດ້ໂຫຼດປຶ້ມສີ + .color-load-book = ໂຫຼດປຶ້ມສີ (.acb) + .color-book-label = ປຶ້ມສີ + .color-rgb-equivalent = ທຽບເທົ່າ RGB + .color-no-match = ບໍ່ມີສີທີ່ກົງກັນ + .color-not-a-book = ບໍ່ແມ່ນປຶ້ມສີ + .color-book-encrypted = ປຶ້ມສີນີ້ຖືກເຂົ້າລະຫັດ ແລະອ່ານບໍ່ໄດ້ + .color-book-empty = ປຶ້ມສີບໍ່ມີສີ .cad-color-index-aci-10-249 = CAD Color Index (ACI) 10–249 .grayscale-250-255 = ລະດັບສີເທົາ 250–255 .isometric-drafting = ການຂຽນແບບໄອໂຊເມຕຣິກ diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index fb4af2b56..f7d735880 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1342,6 +1342,21 @@ common = .color-hex = Hex .nearest-index-color = Dichtstbijzijnde index .exact-index-match = Exacte indexkleur + .color-recently-used = Recent gebruikt + .color-most-used = Meest gebruikt + .color-number = Kleurnummer + .color-in-drawing = Kleuren in deze tekening + .color-sort = Sorteren + .color-no-drawing-colors = Nog geen kleuren in deze tekening + .color-books = Kleurboeken + .color-no-books = Geen kleurboeken geladen + .color-load-book = Kleurboek laden (.acb) + .color-book-label = Kleurboek + .color-rgb-equivalent = RGB-equivalent + .color-no-match = Geen kleuren gevonden + .color-not-a-book = Geen kleurboek + .color-book-encrypted = Dit kleurboek is versleuteld en kan niet worden gelezen + .color-book-empty = Het kleurboek bevat geen kleuren .cad-color-index-aci-10-249 = ACI Kleurindex 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Isometrisch tekenen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index cc06b8293..0852391c9 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1343,6 +1343,21 @@ common = .color-hex = Hex .nearest-index-color = Najbliższy indeks .exact-index-match = Dokładne dopasowanie indeksu + .color-recently-used = Ostatnio używane + .color-most-used = Najczęściej używane + .color-number = Numer koloru + .color-in-drawing = Kolory w tym rysunku + .color-sort = Sortuj + .color-no-drawing-colors = Brak kolorów w tym rysunku + .color-books = Wzorniki kolorów + .color-no-books = Nie wczytano wzorników kolorów + .color-load-book = Wczytaj wzornik kolorów (.acb) + .color-book-label = Wzornik kolorów + .color-rgb-equivalent = Odpowiednik RGB + .color-no-match = Brak pasujących kolorów + .color-not-a-book = To nie jest wzornik kolorów + .color-book-encrypted = Ten wzornik kolorów jest zaszyfrowany i nie można go odczytać + .color-book-empty = Wzornik kolorów nie zawiera kolorów .cad-color-index-aci-10-249 = Indeks kolorów ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Rysowanie izometryczne diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 3b8aedc44..33fa9e2e8 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1344,6 +1344,21 @@ common = .color-hex = Hex .nearest-index-color = Índice mais próximo .exact-index-match = Corresponde exatamente ao índice + .color-recently-used = Usadas recentemente + .color-most-used = Mais usadas + .color-number = Número da cor + .color-in-drawing = Cores neste desenho + .color-sort = Ordenar + .color-no-drawing-colors = Ainda não há cores neste desenho + .color-books = Catálogos de cores + .color-no-books = Nenhum catálogo de cores carregado + .color-load-book = Carregar catálogo de cores (.acb) + .color-book-label = Catálogo de cores + .color-rgb-equivalent = Equivalente RGB + .color-no-match = Nenhuma cor corresponde + .color-not-a-book = Não é um catálogo de cores + .color-book-encrypted = Este catálogo de cores é criptografado e não pode ser lido + .color-book-empty = O catálogo de cores não tem cores .cad-color-index-aci-10-249 = Índice de cor ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Desenho isométrico diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index c8e255413..4778249cf 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1347,6 +1347,21 @@ common = .color-hex = Hex .nearest-index-color = Ближайший индекс .exact-index-match = Точное совпадение с индексом + .color-recently-used = Недавно использованные + .color-most-used = Чаще всего используемые + .color-number = Номер цвета + .color-in-drawing = Цвета в этом чертеже + .color-sort = Сортировка + .color-no-drawing-colors = В этом чертеже пока нет цветов + .color-books = Альбомы цветов + .color-no-books = Альбомы цветов не загружены + .color-load-book = Загрузить альбом цветов (.acb) + .color-book-label = Альбом цветов + .color-rgb-equivalent = Эквивалент RGB + .color-no-match = Нет подходящих цветов + .color-not-a-book = Это не альбом цветов + .color-book-encrypted = Этот альбом цветов зашифрован и не может быть прочитан + .color-book-empty = В альбоме цветов нет цветов .cad-color-index-aci-10-249 = Индекс цвета ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Изометрическое черчение diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index a2fe8529d..c94918f97 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1330,6 +1330,21 @@ common = .color-hex = Hex .nearest-index-color = En yakın indeks .exact-index-match = Tam indeks eşleşmesi + .color-recently-used = Son kullanılanlar + .color-most-used = En çok kullanılanlar + .color-number = Renk numarası + .color-in-drawing = Bu çizimdeki renkler + .color-sort = Sırala + .color-no-drawing-colors = Bu çizimde henüz renk yok + .color-books = Renk kitapları + .color-no-books = Yüklenmiş renk kitabı yok + .color-load-book = Renk kitabı yükle (.acb) + .color-book-label = Renk kitabı + .color-rgb-equivalent = RGB karşılığı + .color-no-match = Eşleşen renk yok + .color-not-a-book = Bir renk kitabı değil + .color-book-encrypted = Bu renk kitabı şifreli ve okunamıyor + .color-book-empty = Renk kitabında renk yok .cad-color-index-aci-10-249 = ACI renk dizini 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = İzometrik çizim diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index af02d48a4..d14c18f2c 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1331,6 +1331,21 @@ common = .color-hex = 十六进制 .nearest-index-color = 最接近的索引色 .exact-index-match = 与索引色完全匹配 + .color-recently-used = 最近使用 + .color-most-used = 最常使用 + .color-number = 颜色编号 + .color-in-drawing = 此图形中的颜色 + .color-sort = 排序 + .color-no-drawing-colors = 此图形中还没有颜色 + .color-books = 配色系统 + .color-no-books = 未加载配色系统 + .color-load-book = 加载配色系统 (.acb) + .color-book-label = 配色系统 + .color-rgb-equivalent = RGB 等效值 + .color-no-match = 没有匹配的颜色 + .color-not-a-book = 不是配色系统 + .color-book-encrypted = 此配色系统已加密,无法读取 + .color-book-empty = 配色系统中没有颜色 .cad-color-index-aci-10-249 = ACI 颜色索引 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = 等轴测绘图 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 11b4868a6..fec601b75 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1343,6 +1343,21 @@ common = .color-hex = 十六進位 .nearest-index-color = 最接近的索引色 .exact-index-match = 與索引色完全相符 + .color-recently-used = 最近使用 + .color-most-used = 最常使用 + .color-number = 顏色編號 + .color-in-drawing = 此圖面中的顏色 + .color-sort = 排序 + .color-no-drawing-colors = 此圖面中還沒有顏色 + .color-books = 配色系統 + .color-no-books = 未載入配色系統 + .color-load-book = 載入配色系統 (.acb) + .color-book-label = 配色系統 + .color-rgb-equivalent = RGB 等效值 + .color-no-match = 沒有相符的顏色 + .color-not-a-book = 不是配色系統 + .color-book-encrypted = 此配色系統已加密,無法讀取 + .color-book-empty = 配色系統中沒有顏色 .cad-color-index-aci-10-249 = ACI 顏色索引 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = 等角製圖 diff --git a/src/app/document.rs b/src/app/document.rs index bf0b130b7..8c3a33df8 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -277,6 +277,8 @@ pub(super) struct DocumentTab { /// Model-space corners of the last crossing window drawn with no command /// running: STRETCH on that selection stretches through it (#1068). pub(super) last_crossing_window: Option<([f64; 2], [f64; 2])>, + /// Colours picked in this drawing this session, newest first. Not saved. + pub(super) recent_colors: Vec, } impl DocumentTab { @@ -679,6 +681,7 @@ impl DocumentTab { suspended_cmd: None, transparent_resume: false, last_crossing_window: None, + recent_colors: Vec::new(), } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 73e7b8c63..3906e3dc1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -965,9 +965,12 @@ pub(super) struct OpenCADStudio { // ── Floating panel windows ──────────────────────────────────────────── /// The open Select Color panel, if any. color_picker: Option, + /// Colour books loaded this session, read lazily from `color_book_paths`. + color_books: std::sync::Arc>, + color_book_paths: Vec, + color_books_read: bool, /// Recently committed real colours, newest first. /// ACI and True Color remain semantically distinct even when their RGB matches. - recent_colors: Vec, /// The open in-canvas modal dialog, if any (Plan B: shared overlay instead /// of OS windows). active_modal: Option, @@ -4168,8 +4171,11 @@ pub enum Message { ColorPicker(crate::ui::color_select::ColorPickerMsg), /// Close the colour picker without choosing. CloseColorPicker, - /// Commit the colour chosen in the shared picker. - ColorWindowPick(codec::types::Color), + /// Commit the colour chosen in the shared picker, with its `BOOK$NAME` + /// when it comes from a colour book. + ColorWindowPick(codec::types::Color, Option), + /// An `.acb` file was read: the book and, on desktop, where it lives. + ColorBookLoaded(Result<(crate::ui::color_select::book::ColorBook, Option), String>), /// Set a block/linetype Handle field on the selected dim style from a /// dropdown of available block-records / linetypes (by name). DsSetHandle { @@ -4601,7 +4607,9 @@ impl OpenCADStudio { #[cfg(target_arch = "wasm32")] web_autosave_clear_after: Vec::new(), color_picker: None, - recent_colors: Vec::new(), + color_books: Default::default(), + color_book_paths: Vec::new(), + color_books_read: false, active_modal: None, hyperlink_editor_handles: Vec::new(), hyperlink_editor_url: String::new(), diff --git a/src/app/settings.rs b/src/app/settings.rs index b62c6b072..2a3795ce2 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -619,6 +619,9 @@ pub struct UserSettings { /// Blocks palette: library folders or drawings, last used first. #[serde(default)] pub block_libraries: Vec, + /// Colour book (.acb) files loaded in the Select Color panel. + #[serde(default)] + pub color_books: Vec, /// Blocks palette view: 0 extra large, 1 large, 2 medium, 3 small icons, /// 4 details, 5 list. #[serde(default = "default_block_view")] @@ -901,6 +904,7 @@ impl Default for UserSettings { block_recent: Vec::new(), block_favorites: Vec::new(), block_libraries: Vec::new(), + color_books: Vec::new(), block_palette_view: 1, block_insert: BlockInsertOptions::default(), block_mru_list: 50, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 6032c3b2d..082199c2a 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -860,6 +860,7 @@ impl OpenCADStudio { block_recent: self.block_palette.recent.clone(), block_favorites: self.block_palette.favorites.clone(), block_libraries: self.block_palette.libraries.clone(), + color_books: self.color_book_paths.clone(), block_palette_view: self.block_palette.view as u8, block_insert: self.block_palette.options.clone(), block_mru_list: self.block_mru_list, @@ -1025,6 +1026,7 @@ impl OpenCADStudio { self.block_palette.recent = s.block_recent.clone(); self.block_palette.favorites = s.block_favorites.clone(); self.block_palette.libraries = s.block_libraries.clone(); + self.color_book_paths = s.color_books.clone(); self.block_palette.view = crate::ui::window::block_palette::ViewMode::from_u8(s.block_palette_view); self.block_palette.set_options(s.block_insert.clone()); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index c467d0468..8830f12a4 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4180,38 +4180,7 @@ impl OpenCADStudio { Task::none() } - Message::LayerColorSet(color) => { - let i = self.active_tab; - // Apply to every selected layer (multi-select), not just one. - let names = self.selected_layer_names(i); - if !names.is_empty() { - let undo = self.begin_layer_undo(i, "LAYER COLOR", &names); - for name in &names { - if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) { - dl.color = color; - dl.color_name = None; - dl.book_name = None; - } - } - for pl in self.tabs[i].layers.layers.iter_mut() { - if names.contains(&pl.name) { - pl.color = color; - pl.color_name = None; - pl.book_name = None; - } - } - self.tabs[i].dirty = true; - self.commit_layer_undo(i, undo); - // ByLayer color is baked into the cached wires at - // tessellation time, so bump the geometry epoch to - // invalidate the wire cache and repaint with the new color. - self.tabs[i].scene.invalidate_layer_dependencies(&names); - self.tabs[i].layers.color_picker_row = None; - self.tabs[i].layers.color_full_palette = false; - self.sync_ribbon_layers(); - } - Task::none() - } + Message::LayerColorSet(color) => self.set_layer_color(color, None), Message::LayerLinetypeSet(lt) => { let i = self.active_tab; @@ -7264,22 +7233,7 @@ impl OpenCADStudio { Task::none() } - Message::PropColorChanged(color) => { - let i = self.active_tab; - let handles = self.property_target_handles(i); - if handles.is_empty() { - let task = self.on_ribbon_color_changed(color); - self.refresh_properties(); - return task; - } - self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_color(entity, color); - } - }); - self.tabs[i].properties.color_picker_open = false; - Task::none() - } + Message::PropColorChanged(color) => self.set_property_color(color, None), Message::PropLwChanged(lw) => { let i = self.active_tab; @@ -11605,10 +11559,19 @@ impl OpenCADStudio { ) { self.graphic_attributes.open_menu = None; } - let (current, varies) = self.color_picker_current(&target, color); - self.color_picker = Some(crate::ui::color_select::ColorPicker::new( - target, current, varies, - )); + let (current, varies, name) = self.color_picker_current(&target, color); + let mut picker = crate::ui::color_select::ColorPicker::new(target, current, varies); + if !matches!(picker.target, crate::app::ColorPickTarget::Interface(_)) { + self.read_color_books(); + let tab = &self.tabs[self.active_tab]; + picker = picker.with_drawing( + crate::ui::color_select::document::collect(&tab.scene.document), + &tab.recent_colors, + std::sync::Arc::clone(&self.color_books), + name, + ); + } + self.color_picker = Some(picker); self.modal_offset = iced::Vector::ZERO; self.ds_color_open = None; self.mls_color_open = None; @@ -11628,9 +11591,28 @@ impl OpenCADStudio { return Task::none(); }; match picker.update(msg) { - Some(crate::ui::color_select::Outcome::Commit(color)) => { - self.update(Message::ColorWindowPick(color)) + Some(crate::ui::color_select::Outcome::Commit(color, name)) => { + self.update(Message::ColorWindowPick(color, name)) } + Some(crate::ui::color_select::Outcome::LoadBook) => Task::perform( + async { + let file = crate::sys::file_dialog() + .add_filter(crate::t!("Color book").as_ref(), &["acb", "ACB"]) + .pick_file() + .await + .ok_or_else(|| "Cancelled".to_string())?; + let book = crate::ui::color_select::book::parse( + &String::from_utf8_lossy(&file.read().await), + )?; + // The web build has no paths to read again later. + #[cfg(not(target_arch = "wasm32"))] + let path = Some(crate::sys::handle_path(&file).display().to_string()); + #[cfg(target_arch = "wasm32")] + let path = None; + Ok((book, path)) + }, + Message::ColorBookLoaded, + ), Some(crate::ui::color_select::Outcome::Cancel) => { self.color_picker = None; Task::none() @@ -11656,7 +11638,7 @@ impl OpenCADStudio { Task::none() } - Message::ColorWindowPick(color) => { + Message::ColorWindowPick(color, name) => { // Interface colours are not drawing colours: keep them out of // the recent list. if !matches!( @@ -11665,7 +11647,35 @@ impl OpenCADStudio { ) { self.note_recent_color(color); } - self.on_color_window_pick(color) + self.on_color_window_pick(color, name) + } + Message::ColorBookLoaded(Ok((book, path))) => { + // A book loaded again replaces the earlier copy. + let books = std::sync::Arc::make_mut(&mut self.color_books); + let index = match books.iter().position(|b| b.name == book.name) { + Some(index) => { + books[index] = book; + index + } + None => { + books.push(book); + books.len() - 1 + } + }; + if let Some(path) = path.filter(|path| !self.color_book_paths.contains(path)) { + self.color_book_paths.push(path); + self.save_config(); + } + if let Some(picker) = self.color_picker.as_mut() { + picker.show_book(std::sync::Arc::clone(&self.color_books), index); + } + Task::none() + } + Message::ColorBookLoaded(Err(error)) => { + if let Some(picker) = self.color_picker.as_mut().filter(|_| error != "Cancelled") { + picker.set_status(error); + } + Task::none() } Message::DsSetHandle { field, value } => self.on_ds_set_handle(field, value), } @@ -11674,16 +11684,33 @@ impl OpenCADStudio { /// The colour the Select Color panel shows as "Current", and whether the /// selection it edits has mixed colours. A mixed selection starts from the /// colour of its most recently drawn object (the highest handle). + /// Also returns the colour-book identity (`BOOK$NAME`) of that colour. fn color_picker_current( &self, target: &crate::app::ColorPickTarget, color: codec::types::Color, - ) -> (codec::types::Color, bool) { - if !matches!(target, crate::app::ColorPickTarget::Properties) { - return (color, false); - } + ) -> (codec::types::Color, bool, Option) { let i = self.active_tab; let document = &self.tabs[i].scene.document; + let name_of = |handle| { + document + .get_entity(handle) + .and_then(|entity| entity.common().color_name.clone()) + }; + match target { + crate::app::ColorPickTarget::Properties => {} + crate::app::ColorPickTarget::Layer(layer) => { + let name = document.layers.get(layer).and_then(|layer| { + match (&layer.book_name, &layer.color_name) { + (Some(book), Some(name)) => Some(format!("{book}${name}")), + (None, Some(name)) => Some(name.clone()), + _ => None, + } + }); + return (color, false, name); + } + _ => return (color, false, None), + } let mut colors = self .property_target_handles(i) .into_iter() @@ -11693,15 +11720,99 @@ impl OpenCADStudio { .map(|entity| (handle, entity.common().color)) }) .collect::>(); - let Some(&(_, first)) = colors.first() else { - return (color, false); + let Some(&(first_handle, first)) = colors.first() else { + return (color, false, None); }; if colors.iter().all(|&(_, c)| c == first) { - return (first, false); + return (first, false, name_of(first_handle)); } colors.sort_by_key(|&(handle, _)| handle); - let newest = colors.last().map_or(color, |&(_, c)| c); - (newest, true) + let (newest_handle, newest) = colors.last().copied().unwrap_or((first_handle, color)); + (newest, true, name_of(newest_handle)) + } + + /// Read the colour books remembered in the settings, once per session. + fn read_color_books(&mut self) { + if std::mem::replace(&mut self.color_books_read, true) { + return; + } + #[cfg(not(target_arch = "wasm32"))] + { + let books: Vec<_> = self + .color_book_paths + .iter() + .filter_map(|path| std::fs::read_to_string(path).ok()) + .filter_map(|text| crate::ui::color_select::book::parse(&text).ok()) + .collect(); + self.color_books = std::sync::Arc::new(books); + } + } + + /// Colour the selected objects (or set the current colour when nothing + /// is selected). `name` is a colour-book identity, `BOOK$NAME`. + pub(super) fn set_property_color( + &mut self, + color: codec::types::Color, + name: Option, + ) -> Task { + let i = self.active_tab; + let handles = self.property_target_handles(i); + if handles.is_empty() { + let task = self.on_ribbon_color_changed(color); + self.refresh_properties(); + return task; + } + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::view::dispatch::apply_named_color(entity, color, name.clone()); + } + }); + self.tabs[i].properties.color_picker_open = false; + Task::none() + } + + /// Colour every selected layer (multi-select), not just one. `name` is a + /// colour-book identity, `BOOK$NAME`. + pub(super) fn set_layer_color( + &mut self, + color: codec::types::Color, + name: Option<&str>, + ) -> Task { + let i = self.active_tab; + // Layers keep the book and the colour name apart. + let (book_name, color_name) = match name.map(|name| name.split_once('$')) { + Some(Some((book, name))) => (Some(book), Some(name)), + Some(None) => (None, name), + None => (None, None), + }; + let names = self.selected_layer_names(i); + if !names.is_empty() { + let undo = self.begin_layer_undo(i, "LAYER COLOR", &names); + for name in &names { + if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) { + dl.color = color; + dl.color_name = color_name.map(str::to_string); + dl.book_name = book_name.map(str::to_string); + } + } + for pl in self.tabs[i].layers.layers.iter_mut() { + if names.contains(&pl.name) { + pl.color = color; + pl.color_name = color_name.map(str::to_string); + pl.book_name = book_name.map(str::to_string); + } + } + self.tabs[i].dirty = true; + self.commit_layer_undo(i, undo); + // ByLayer color is baked into the cached wires at + // tessellation time, so bump the geometry epoch to + // invalidate the wire cache and repaint with the new color. + self.tabs[i].scene.invalidate_layer_dependencies(&names); + self.tabs[i].layers.color_picker_row = None; + self.tabs[i].layers.color_full_palette = false; + self.sync_ribbon_layers(); + } + Task::none() } pub(crate) fn note_recent_color(&mut self, color: codec::types::Color) { @@ -11712,12 +11823,13 @@ impl OpenCADStudio { codec::types::Color::Index(_) | codec::types::Color::Rgb { .. } ) { // No duplicates: selecting an existing colour moves it to the front. - if let Some(pos) = self.recent_colors.iter().position(|c| c == &color) { - self.recent_colors.remove(pos); + let recent = &mut self.tabs[self.active_tab].recent_colors; + if let Some(pos) = recent.iter().position(|c| c == &color) { + recent.remove(pos); } - self.recent_colors.insert(0, color); - self.recent_colors.truncate(12); + recent.insert(0, color); + recent.truncate(12); } } diff --git a/src/app/update/style.rs b/src/app/update/style.rs index bb944373d..fc0db8af7 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -1136,7 +1136,11 @@ impl OpenCADStudio { Task::none() } - pub(super) fn on_color_window_pick(&mut self, color: codec::types::Color) -> Task { + pub(super) fn on_color_window_pick( + &mut self, + color: codec::types::Color, + name: Option, + ) -> Task { if let Some(crate::app::ColorPickTarget::Interface(target)) = self .color_picker .as_ref() @@ -1201,7 +1205,9 @@ impl OpenCADStudio { field: f, value: s, }), - Some(crate::app::ColorPickTarget::Properties) => Some(Message::PropColorChanged(color)), + Some(crate::app::ColorPickTarget::Properties) => { + return self.set_property_color(color, name); + } Some(crate::app::ColorPickTarget::PropertiesBg) => { Some(Message::PropBgColorChanged(color)) } @@ -1210,12 +1216,12 @@ impl OpenCADStudio { } Some(crate::app::ColorPickTarget::MText) => Some(Message::MTextColorChanged(color)), Some(crate::app::ColorPickTarget::Ribbon) => Some(Message::RibbonColorChanged(color)), - Some(crate::app::ColorPickTarget::Layer(name)) => { + Some(crate::app::ColorPickTarget::Layer(layer)) => { let i = self.active_tab; - match self.layer_panel_index(i, &name) { + match self.layer_panel_index(i, &layer) { Some(idx) => { self.tabs[i].layers.selected = Some(idx); - Some(Message::LayerColorSet(color)) + return self.set_layer_color(color, name.as_deref()); } None => None, } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index db73b3a84..8b41da05c 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2187,7 +2187,7 @@ bg={bg_ms:.1}ms n={view_count}" &redo_labels, self.win_size, self.tabs[self.active_tab].is_start, - &self.recent_colors, + &self.tabs[self.active_tab].recent_colors, ) .unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into()) }; diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 7fd608a1d..18ecb5ed0 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -4720,6 +4720,21 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Hex" => Some(("common", "color-hex")), "Nearest index" => Some(("common", "nearest-index-color")), "Exact index match" => Some(("common", "exact-index-match")), + "Recently used" => Some(("common", "color-recently-used")), + "Most used" => Some(("common", "color-most-used")), + "Color number" => Some(("common", "color-number")), + "Colors in this drawing" => Some(("common", "color-in-drawing")), + "Sort" => Some(("common", "color-sort")), + "No colors in this drawing yet" => Some(("common", "color-no-drawing-colors")), + "Color Books" => Some(("common", "color-books")), + "No color books loaded" => Some(("common", "color-no-books")), + "Load color book (.acb)" => Some(("common", "color-load-book")), + "Color book" => Some(("common", "color-book-label")), + "RGB equivalent" => Some(("common", "color-rgb-equivalent")), + "No colors match" => Some(("common", "color-no-match")), + "Not a color book" => Some(("common", "color-not-a-book")), + "This color book is encrypted and cannot be read" => Some(("common", "color-book-encrypted")), + "The color book has no colors" => Some(("common", "color-book-empty")), "CAD Color Index (ACI) 10–249" => Some(("common", "cad-color-index-aci-10-249")), "Grayscale 250–255" => Some(("common", "grayscale-250-255")), "Isometric drafting" => Some(("common", "isometric-drafting")), diff --git a/src/scene/view/dispatch.rs b/src/scene/view/dispatch.rs index 927f6877b..0178f1cd2 100644 --- a/src/scene/view/dispatch.rs +++ b/src/scene/view/dispatch.rs @@ -322,6 +322,12 @@ pub fn apply_color(entity: &mut EntityType, color: AcadColor) { common.color_book_handle = None; } +/// Like [`apply_color`], but keeps a colour-book identity (`BOOK$NAME`). +pub fn apply_named_color(entity: &mut EntityType, color: AcadColor, name: Option) { + apply_color(entity, color); + entity.common_mut().color_name = name; +} + pub fn apply_line_weight(entity: &mut EntityType, lw: LineWeight) { entity.as_entity_mut().set_line_weight(lw); } diff --git a/src/ui/color_select/book.rs b/src/ui/color_select/book.rs new file mode 100644 index 000000000..f06cf8e3e --- /dev/null +++ b/src/ui/color_select/book.rs @@ -0,0 +1,176 @@ +//! Colour books: AutoCAD `.acb` files. +//! +//! An `.acb` is XML: a `bookName` and `colorPage`s of `colorEntry`s, each a +//! `colorName` and an `RGB8` value. Books from some vendors encrypt their +//! values; those cannot be read. + +/// One named colour. +#[derive(Debug, Clone, PartialEq)] +pub struct BookColor { + pub name: String, + pub rgb: (u8, u8, u8), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ColorBook { + pub name: String, + /// Every colour, page after page. + pub colors: Vec, + /// Where each page starts in `colors`. + page_starts: Vec, +} + +/// Pages for a book without `colorPage`s, as AutoCAD shows them. +const PAGE_SIZE: usize = 7; + +impl ColorBook { + pub fn page_count(&self) -> usize { + self.page_starts.len() + } + + /// The colours of one page, and the index of its first colour. + pub fn page(&self, page: usize) -> (usize, &[BookColor]) { + let start = self.page_starts.get(page).copied().unwrap_or(0); + let end = self + .page_starts + .get(page + 1) + .copied() + .unwrap_or(self.colors.len()); + (start, &self.colors[start..end]) + } + + /// The page holding colour `index`. + pub fn page_of(&self, index: usize) -> usize { + self.page_starts + .partition_point(|&start| start <= index) + .saturating_sub(1) + } + + /// The colour with this name, as stored after the `$` of `BOOK$NAME`. + pub fn find(&self, name: &str) -> Option { + self.colors.iter().position(|c| c.name == name) + } + + /// The `BOOK$NAME` identity objects and layers store. + pub fn identity(&self, index: usize) -> String { + format!("{}${}", self.name, self.colors[index].name) + } +} + +/// Read an `.acb` file's text. +pub fn parse(text: &str) -> Result { + let doc = roxmltree::Document::parse(text.trim_start_matches('\u{feff}')) + .map_err(|e| e.to_string())?; + let root = doc.root_element(); + if !root.has_tag_name("colorBook") { + return Err(crate::t!("Not a color book").into_owned()); + } + let mut book = ColorBook { + name: text_of(root, "bookName").unwrap_or_default().to_string(), + colors: Vec::new(), + page_starts: Vec::new(), + }; + let mut encrypted = false; + let mut read_entries = |parent: roxmltree::Node<'_, '_>, book: &mut ColorBook| { + for entry in parent.children().filter(|n| n.has_tag_name("colorEntry")) { + let rgb = child(entry, "RGB8").and_then(|rgb| { + let channel = |tag| text_of(rgb, tag)?.parse::().ok(); + Some((channel("red")?, channel("green")?, channel("blue")?)) + }); + match (text_of(entry, "colorName"), rgb) { + (Some(name), Some(rgb)) => book.colors.push(BookColor { + name: name.to_string(), + rgb, + }), + _ => encrypted = true, + } + } + }; + let mut pages = root + .children() + .filter(|n| n.has_tag_name("colorPage")) + .peekable(); + if pages.peek().is_some() { + for page in pages { + let start = book.colors.len(); + read_entries(page, &mut book); + if book.colors.len() > start { + book.page_starts.push(start); + } + } + } else { + read_entries(root, &mut book); + book.page_starts = (0..book.colors.len()).step_by(PAGE_SIZE).collect(); + } + if book.colors.is_empty() { + return Err(if encrypted { + crate::t!("This color book is encrypted and cannot be read") + } else { + crate::t!("The color book has no colors") + } + .into_owned()); + } + Ok(book) +} + +fn child<'a, 'i>(node: roxmltree::Node<'a, 'i>, tag: &str) -> Option> { + node.children().find(|n| n.has_tag_name(tag)) +} + +fn text_of<'a>(node: roxmltree::Node<'a, '_>, tag: &str) -> Option<&'a str> { + child(node, tag).and_then(|n| n.text()).map(str::trim) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(name: &str, (r, g, b): (u8, u8, u8)) -> String { + format!( + "{name}\ + {r}{g}{b}" + ) + } + + #[test] + fn reads_pages_and_colours() { + let text = format!( + "\u{feff}RAL CLASSIC\ + 123{}{}\ + {}", + entry("RAL 1000", (190, 189, 127)), + entry("RAL 1001", (194, 176, 120)), + entry("RAL 5015", (34, 113, 179)), + ); + let book = parse(&text).unwrap(); + assert_eq!(book.name, "RAL CLASSIC"); + assert_eq!(book.page_count(), 2); + assert_eq!(book.page(1), (2, &book.colors[2..])); + assert_eq!(book.page_of(1), 0); + assert_eq!(book.page_of(2), 1); + let blue = book.find("RAL 5015").unwrap(); + assert_eq!(book.colors[blue].rgb, (34, 113, 179)); + assert_eq!(book.identity(blue), "RAL CLASSIC$RAL 5015"); + } + + #[test] + fn a_book_without_pages_gets_pages_of_seven() { + let entries: String = (0..9).map(|i| entry(&format!("C{i}"), (i, i, i))).collect(); + let book = parse(&format!( + "B{entries}" + )) + .unwrap(); + assert_eq!(book.page_count(), 2); + assert_eq!(book.page(1).1.len(), 2); + } + + #[test] + fn rejects_unreadable_books() { + assert!(parse("not xml").is_err()); + assert!(parse("").is_err()); + let encrypted = "P\ + XAbC\ + "; + assert!(parse(encrypted).is_err()); + } +} diff --git a/src/ui/color_select/document.rs b/src/ui/color_select/document.rs new file mode 100644 index 000000000..9f976b66d --- /dev/null +++ b/src/ui/color_select/document.rs @@ -0,0 +1,233 @@ +//! The colours a drawing uses, for the Document tab. +//! +//! One pass over every object (block definitions included) and every layer +//! when the panel opens. Keys borrow from the drawing, so the pass allocates +//! only per distinct colour, never per object. + +use super::math; +use codec::types::{Color as AcadColor, Handle}; +use rustc_hash::FxHashMap; + +/// One distinct colour: a concrete colour plus, for a colour-book colour, +/// its `BOOK$NAME` identity. +#[derive(Debug, Clone, PartialEq)] +pub struct DocColor { + pub color: AcadColor, + pub name: Option, + /// Objects and layers using it. Only used for sorting, never shown. + uses: u32, + /// The newest object or layer using it. + newest: Handle, +} + +impl DocColor { + /// The few characters drawn on the swatch: the index number, `RGB`, or + /// the first word of the colour book. + pub fn tag(&self) -> String { + match (&self.name, self.color) { + (Some(name), _) => name + .split(['$', ' ']) + .next() + .unwrap_or_default() + .chars() + .take(5) + .collect(), + (None, AcadColor::Index(i)) => i.to_string(), + _ => "RGB".to_string(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DocSort { + #[default] + Recent, + Usage, + Hue, + Number, +} + +impl DocSort { + pub const ALL: [DocSort; 4] = [ + DocSort::Recent, + DocSort::Usage, + DocSort::Hue, + DocSort::Number, + ]; +} + +impl std::fmt::Display for DocSort { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&match self { + DocSort::Recent => crate::t!("Recently used"), + DocSort::Usage => crate::t!("Most used"), + DocSort::Hue => crate::t!("Color"), + DocSort::Number => crate::t!("Color number"), + }) + } +} + +/// A colour as a hash key: index colours keep their number, true colours +/// sit above 255. +fn key(color: AcadColor) -> Option { + match color { + AcadColor::Index(i) => Some(u32::from(i)), + AcadColor::Rgb { r, g, b } => { + Some(1 << 24 | u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b)) + } + _ => None, + } +} + +/// Every concrete colour in the drawing. ByLayer, ByBlock and None are +/// states rather than colours and are skipped. +pub fn collect(document: &codec::CadDocument) -> Vec { + // Layers keep book and colour name apart; join them like objects do. + let layer_names: Vec> = document + .layers + .iter() + .map(|layer| match (&layer.book_name, &layer.color_name) { + (Some(book), Some(name)) => Some(format!("{book}${name}")), + (None, Some(name)) => Some(name.clone()), + _ => None, + }) + .collect(); + let objects = document.entities().map(|entity| { + let common = entity.common(); + (common.color, common.color_name.as_deref(), common.handle) + }); + let layers = document + .layers + .iter() + .zip(&layer_names) + .map(|(layer, name)| (layer.color, name.as_deref(), layer.handle)); + tally(objects.chain(layers)) +} + +fn tally<'a>(uses: impl Iterator, Handle)>) -> Vec { + let mut index: FxHashMap<(u32, Option<&str>), usize> = FxHashMap::default(); + let mut colors: Vec = Vec::new(); + for (color, name, handle) in uses { + let Some(k) = key(color) else { continue }; + let name = name.filter(|name| !name.is_empty()); + let slot = *index.entry((k, name)).or_insert_with(|| { + colors.push(DocColor { + color, + name: name.map(str::to_string), + uses: 0, + newest: handle, + }); + colors.len() - 1 + }); + let entry = &mut colors[slot]; + entry.uses += 1; + entry.newest = entry.newest.max(handle); + } + colors +} + +/// Sort for display. `recent` is this drawing's pick history, newest first; +/// colours not picked this session follow, newest object first. +pub fn sort(colors: &mut [DocColor], by: DocSort, recent: &[AcadColor]) { + let rank = |c: &DocColor| { + recent + .iter() + .position(|&r| r == c.color) + .unwrap_or(usize::MAX) + }; + match by { + DocSort::Recent => { + colors.sort_by(|a, b| rank(a).cmp(&rank(b)).then(b.newest.cmp(&a.newest))) + } + DocSort::Usage => colors.sort_by(|a, b| b.uses.cmp(&a.uses).then(b.newest.cmp(&a.newest))), + DocSort::Hue => colors.sort_by_cached_key(|c| { + let (r, g, b) = math::color_rgb(c.color).unwrap_or_default(); + let hsb = math::rgb_to_hsb(r, g, b); + // Grays after the colours; within a hue band, light to dark. + let gray = hsb.s < 0.12 || hsb.b < 0.08; + let band = if gray { + 0 + } else { + (hsb.h / 15.0).round() as u32 + }; + (gray, band, std::cmp::Reverse((hsb.b * 1000.0) as u32)) + }), + DocSort::Number => colors.sort_by(|a, b| { + let kind = |c: &DocColor| match (&c.name, c.color) { + (None, AcadColor::Index(_)) => 0, + (None, _) => 1, + _ => 2, + }; + kind(a) + .cmp(&kind(b)) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| key(a.color).cmp(&key(b.color))) + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const RED: AcadColor = AcadColor::Index(1); + const BLUE: AcadColor = AcadColor::Index(5); + const SKY: AcadColor = AcadColor::Rgb { + r: 57, + g: 191, + b: 252, + }; + + fn sample() -> Vec { + let h = Handle::new; + tally( + [ + (RED, None, h(1)), + (RED, None, h(2)), + (RED, None, h(3)), + (AcadColor::ByLayer, None, h(4)), + (SKY, None, h(5)), + (SKY, Some("RAL CLASSIC$RAL 5015"), h(6)), + (BLUE, Some(""), h(7)), + ] + .into_iter(), + ) + } + + #[test] + fn collects_each_concrete_colour_once() { + let colors = sample(); + assert_eq!(colors.len(), 4, "ByLayer skipped, book colour kept apart"); + assert_eq!(colors[0].uses, 3); + assert_eq!(colors[0].newest, Handle::new(3)); + assert_eq!(colors[3].name, None, "an empty name is no name"); + assert_eq!(colors[2].tag(), "RAL"); + assert_eq!(colors[0].tag(), "1"); + assert_eq!(colors[1].tag(), "RGB"); + } + + #[test] + fn sorts_four_ways() { + let order = |by, recent: &[AcadColor]| { + let mut colors = sample(); + sort(&mut colors, by, recent); + colors + .iter() + .map(|c| (c.color, c.name.is_some())) + .collect::>() + }; + // Picked this session first, then newest object first. + assert_eq!( + order(DocSort::Recent, &[RED]), + [(RED, false), (BLUE, false), (SKY, true), (SKY, false)] + ); + assert_eq!(order(DocSort::Usage, &[])[0], (RED, false)); + assert_eq!( + order(DocSort::Number, &[]), + [(RED, false), (BLUE, false), (SKY, false), (SKY, true)] + ); + // Red (hue 0) before sky blue (hue ~199) before blue (hue 240). + assert_eq!(order(DocSort::Hue, &[])[0], (RED, false)); + assert_eq!(order(DocSort::Hue, &[])[3], (BLUE, false)); + } +} diff --git a/src/ui/color_select/mod.rs b/src/ui/color_select/mod.rs index adcefdf2f..6a1f752f6 100644 --- a/src/ui/color_select/mod.rs +++ b/src/ui/color_select/mod.rs @@ -3,6 +3,8 @@ //! properties panel and every style editor so colour selection looks and //! behaves the same everywhere. +pub mod book; +pub mod document; pub mod editor; pub mod eyedropper; pub mod math; diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index ccd63b85d..f78f870b0 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -5,6 +5,8 @@ //! Opened for an interface colour (any colour in Options) it shows only the //! colour editor: no tabs, no index colours. +use super::book::ColorBook; +use super::document::{self, DocColor, DocSort}; use super::editor::{rgb_color, ColorEditor, EditorMsg}; use super::eyedropper::{Eyedropper, EyedropperMsg}; use super::math; @@ -12,20 +14,29 @@ use crate::app::{ArrowKey, ColorPickTarget, Message}; use crate::t; use crate::ui::properties::acad_color_display; use codec::types::Color as AcadColor; -use iced::widget::{button, column, container, row, text, Space}; +use iced::widget::{ + button, column, container, mouse_area, pick_list, row, scrollable, svg, text, text_input, Space, +}; use iced::{Background, Border, Color, Element, Length, Theme}; +use std::sync::Arc; const PANEL_WIDTH: f32 = 400.0; /// Scope for Tab / Shift+Tab, so focus cycles through the panel's own /// fields instead of the dialogs and command line underneath. const PANEL_ID: &str = "select-color-panel"; const BODY_HEIGHT: f32 = 352.0; +/// Swatches per row in the Document tab. +const DOC_COLUMNS: usize = 8; +/// Search matches shown at once in the Color books tab. +const PAGE_BARS: usize = 7; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ColorPickerTab { + Document, #[default] Index, TrueColor, + Books, } #[derive(Debug, Clone)] @@ -40,17 +51,32 @@ pub enum ColorPickerMsg { UseNearest, Arrow(ArrowKey), Eyedropper(EyedropperMsg), + /// A colour of the Document tab, by its place in the list. + PickDocument(usize), + DocSort(DocSort), + /// Show another loaded colour book. + Book(usize), + BookPage(usize), + /// Scrolling over the page: one page per notch. + BookScroll(f32), + BookQuery(String), + /// A colour of the shown book, by its index in the book. + PickBook(usize), + LoadBook, Ok, Cancel, } /// What the caller has to do after an update. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub enum Outcome { - Commit(AcadColor), + /// The colour, and its `BOOK$NAME` when it comes from a colour book. + Commit(AcadColor, Option), Cancel, /// Capture the window for the eyedropper. Screenshot, + /// Ask for an `.acb` file. + LoadBook, } pub struct ColorPicker { @@ -60,9 +86,22 @@ pub struct ColorPicker { current: AcadColor, varies: bool, pending: AcadColor, + /// `BOOK$NAME` of the current and the new colour, for colour-book colours. + current_name: Option, + pending_name: Option, tab: ColorPickerTab, editor: ColorEditor, eyedropper: Option, + doc: Vec, + doc_sort: DocSort, + /// This drawing's colours picked this session, newest first. + recent: Vec, + books: Arc>, + book: usize, + book_page: usize, + book_query: String, + /// Why the last colour book did not load. + status: Option, } impl ColorPicker { @@ -77,12 +116,85 @@ impl ColorPicker { current, varies, pending: current, + current_name: None, + pending_name: None, tab, editor: ColorEditor::new(math::color_rgb(current).unwrap_or((255, 255, 255))), eyedropper: None, + doc: Vec::new(), + doc_sort: DocSort::default(), + recent: Vec::new(), + books: Arc::default(), + book: 0, + book_page: 0, + book_query: String::new(), + status: None, } } + /// Fill the Document and Color books tabs. `current_name` is the + /// `BOOK$NAME` of the current colour; a colour from a loaded book opens + /// on its page. + pub fn with_drawing( + mut self, + mut doc: Vec, + recent: &[AcadColor], + books: Arc>, + current_name: Option, + ) -> Self { + document::sort(&mut doc, self.doc_sort, recent); + self.doc = doc; + self.recent = recent.to_vec(); + self.books = books; + if let Some(name) = current_name.filter(|name| !name.is_empty()) { + if self.show_identity(&name) { + self.tab = ColorPickerTab::Books; + } + self.current_name = Some(name.clone()); + self.pending_name = Some(name); + } + self + } + + /// The loaded books changed; show book `index`. + pub fn show_book(&mut self, books: Arc>, index: usize) { + self.books = books; + self.book = index; + self.book_page = 0; + self.book_query.clear(); + self.status = None; + self.tab = ColorPickerTab::Books; + } + + pub fn set_status(&mut self, status: String) { + self.status = Some(status); + } + + /// Turn to the page holding `BOOK$NAME`. False when no loaded book has it. + fn show_identity(&mut self, identity: &str) -> bool { + let Some((book_name, name)) = identity.split_once('$') else { + return false; + }; + for (i, book) in self.books.iter().enumerate() { + if let Some(color) = book.find(name).filter(|_| book.name == book_name) { + self.book = i; + self.book_page = book.page_of(color); + return true; + } + } + false + } + + /// The colour of the shown book that is the new colour, if any. + fn book_selection(&self) -> Option { + let (book_name, name) = self.pending_name.as_deref()?.split_once('$')?; + let book = self + .books + .get(self.book) + .filter(|book| book.name == book_name)?; + book.find(name) + } + pub fn pending(&self) -> AcadColor { self.pending } @@ -98,6 +210,7 @@ impl ColorPicker { fn set_pending(&mut self, color: AcadColor) { self.pending = color; + self.pending_name = None; if let Some(rgb) = math::color_rgb(color) { self.editor.set_rgb(rgb); } @@ -131,24 +244,82 @@ impl ColorPicker { match msg { ColorPickerMsg::Tab(tab) => self.tab = tab, ColorPickerMsg::Pick(color) => self.set_pending(color), - ColorPickerMsg::Revert => self.set_pending(self.current), + ColorPickerMsg::Revert => { + self.set_pending(self.current); + self.pending_name = self.current_name.clone(); + } ColorPickerMsg::Editor(editor) => { if self.editor.update(editor) { let (r, g, b) = self.editor.rgb(); self.pending = AcadColor::Rgb { r, g, b }; + self.pending_name = None; } } ColorPickerMsg::UseNearest => { let (r, g, b) = self.rgb(); self.set_pending(AcadColor::Index(math::nearest_aci(r, g, b))); } - ColorPickerMsg::Arrow(direction) => { - if let (ColorPickerTab::Index, AcadColor::Index(index)) = (self.tab, self.pending) { + ColorPickerMsg::Arrow(direction) => match (self.tab, self.pending) { + (ColorPickerTab::Index, AcadColor::Index(index)) => { if let Some(next) = step_index(index, direction) { self.set_pending(AcadColor::Index(next)); } } + (ColorPickerTab::Document, _) => { + let at = self + .doc + .iter() + .position(|c| c.color == self.pending && c.name == self.pending_name); + if let Some(next) = step_list(at, self.doc.len(), DOC_COLUMNS, direction) { + self.update(ColorPickerMsg::PickDocument(next)); + } + } + (ColorPickerTab::Books, _) => { + let len = self + .books + .get(self.book) + .map_or(0, |book| book.colors.len()); + if let Some(next) = step_list(self.book_selection(), len, 1, direction) { + self.pick_book(next); + } + } + _ => {} + }, + ColorPickerMsg::PickDocument(i) => { + if let Some(entry) = self.doc.get(i) { + let (color, name) = (entry.color, entry.name.clone()); + self.set_pending(color); + self.pending_name = name; + } } + ColorPickerMsg::DocSort(by) => { + self.doc_sort = by; + document::sort(&mut self.doc, by, &self.recent); + } + ColorPickerMsg::Book(i) => { + if i < self.books.len() { + self.book = i; + self.book_page = 0; + self.book_query.clear(); + } + } + ColorPickerMsg::BookPage(page) => { + if let Some(book) = self.books.get(self.book) { + self.book_page = page.min(book.page_count().saturating_sub(1)); + self.book_query.clear(); + } + } + ColorPickerMsg::BookScroll(delta) => { + let page = if delta < 0.0 { + self.book_page + 1 + } else { + self.book_page.saturating_sub(1) + }; + self.update(ColorPickerMsg::BookPage(page)); + } + ColorPickerMsg::BookQuery(query) => self.book_query = query, + ColorPickerMsg::PickBook(i) => self.pick_book(i), + ColorPickerMsg::LoadBook => return Some(Outcome::LoadBook), ColorPickerMsg::Eyedropper(EyedropperMsg::Start) => { self.eyedropper = Some(Eyedropper::default()); } @@ -156,13 +327,26 @@ impl ColorPicker { ColorPickerMsg::Ok => { // Apply a value still being typed, as if Enter had been pressed. self.update(ColorPickerMsg::Editor(EditorMsg::HexSubmit)); - return Some(Outcome::Commit(self.pending)); + return Some(Outcome::Commit(self.pending, self.pending_name.clone())); } ColorPickerMsg::Cancel => return Some(Outcome::Cancel), } None } + fn pick_book(&mut self, index: usize) { + let books = Arc::clone(&self.books); + let Some(book) = books.get(self.book) else { + return; + }; + if let Some(color) = book.colors.get(index) { + let (r, g, b) = color.rgb; + self.set_pending(AcadColor::Rgb { r, g, b }); + self.pending_name = Some(book.identity(index)); + self.book_page = book.page_of(index); + } + } + /// The full-window eyedropper layer, while it is active. pub fn eyedropper_view(&self) -> Option> { self.eyedropper.as_ref().map(Eyedropper::view) @@ -170,19 +354,33 @@ impl ColorPicker { pub fn view(&self) -> Element<'_, Message> { let editor_only = self.editor_only(); - let body = if editor_only || self.tab == ColorPickerTab::TrueColor { - self.true_color_tab() - } else { - self.index_tab() + let body = match self.tab { + _ if editor_only => self.true_color_tab(), + ColorPickerTab::Document => self.document_tab(), + ColorPickerTab::Index => self.index_tab(), + ColorPickerTab::TrueColor => self.true_color_tab(), + ColorPickerTab::Books => self.books_tab(), }; // The interface picker shrinks to the editor; the CAD panel keeps one // height across its tabs, with the info line at the bottom. let mut body = column![body].spacing(8); if !editor_only { - body = body.push(Space::new().height(Length::Fill)); + // Document and Color books fill the height themselves. + if matches!(self.tab, ColorPickerTab::Index | ColorPickerTab::TrueColor) { + body = body.push(Space::new().height(Length::Fill)); + } let fill = math::color_rgb(self.pending).map_or(Color::from_rgb8(128, 128, 128), rgb_color); + let info = match (&self.status, &self.pending_name) { + (Some(status), _) => status.clone(), + (None, Some(name)) => { + let (r, g, b) = math::color_rgb(self.pending).unwrap_or_default(); + format!("{name} · RGB {r}, {g}, {b}") + } + (None, None) => math::describe(self.pending), + }; + let error = self.status.is_some(); body = body .push( container(Space::new()) @@ -196,9 +394,11 @@ impl ColorPicker { .width(12) .height(12) .style(move |_theme: &Theme| swatch_style(fill)), - text(math::describe(self.pending)) - .size(11) - .font(iced::Font::MONOSPACE), + text(info).size(11).font(iced::Font::MONOSPACE).style( + move |theme: &Theme| text::Style { + color: error.then(|| theme.palette().danger.base.color), + } + ), ] .spacing(8) .align_y(iced::Center), @@ -247,8 +447,10 @@ impl ColorPicker { }; container( row![ + tab(t!("Document").into_owned(), ColorPickerTab::Document), tab(t!("Index Color").into_owned(), ColorPickerTab::Index), tab(t!("True Color").into_owned(), ColorPickerTab::TrueColor), + tab(t!("Color Books").into_owned(), ColorPickerTab::Books), ] .spacing(2), ) @@ -261,11 +463,7 @@ impl ColorPicker { /// under the middle of the tab bar. Clicking Current reverts. fn preview_row(&self) -> Element<'_, Message> { let half = |label: String, value: String, fill: Color| { - let ink = if ink_is_dark(fill) { - Color::from_rgb8(0x11, 0x12, 0x14) - } else { - Color::WHITE - }; + let ink = ink(fill); column![ text(label).size(11).color(ink.scale_alpha(0.85)), Space::new().height(Length::Fill), @@ -277,10 +475,18 @@ impl ColorPicker { }; let (current_bg, _) = acad_color_display(self.current); let (new_bg, _) = acad_color_display(self.pending); + // A colour-book colour shows its own name, without the book. + let name = |color: AcadColor, identity: &Option| match identity { + Some(identity) => identity + .split_once('$') + .map_or(identity.as_str(), |(_, name)| name) + .to_string(), + None => math::short_name(color), + }; let current_value = if self.varies { "*VARIES*".to_string() } else { - math::short_name(self.current) + name(self.current, &self.current_name) }; let current = button(half(t!("Current").into_owned(), current_value, current_bg)) .on_press(picker_msg(ColorPickerMsg::Revert)) @@ -301,7 +507,7 @@ impl ColorPicker { .style(|_theme: &Theme| filled(Color::from_rgba(0.0, 0.0, 0.0, 0.35))); let new = container(half( t!("New").into_owned(), - math::short_name(self.pending), + name(self.pending, &self.pending_name), new_bg, )) .style(move |_theme: &Theme| container::Style { @@ -364,29 +570,272 @@ impl ColorPicker { button(Space::new().width(width).height(height)) .on_press(picker_msg(ColorPickerMsg::Pick(AcadColor::Index(index)))) .padding(0) - .style(move |theme: &Theme, status| { - let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed); - button::Style { - background: Some(fill.into()), + .style(move |theme: &Theme, status| swatch_button_style(theme, status, fill, selected)) + .into() + } + + fn document_tab(&self) -> Element<'_, Message> { + let header = row![ + text(t!("Colors in this drawing")).size(11), + text(format!("· {}", self.doc.len())) + .size(11) + .style(muted_text), + Space::new().width(Length::Fill), + text(t!("Sort")).size(11).style(muted_text), + pick_list(Some(self.doc_sort), &DocSort::ALL[..], DocSort::to_string) + .on_select(|by| picker_msg(ColorPickerMsg::DocSort(by))) + .text_size(12) + .padding([3, 6]), + ] + .spacing(6) + .align_y(iced::Center); + if self.doc.is_empty() { + return column![ + header, + empty_note(t!("No colors in this drawing yet").into_owned()) + ] + .spacing(10) + .into(); + } + let grid = self.doc.chunks(DOC_COLUMNS).enumerate().fold( + column![].spacing(4), + |grid, (r, chunk)| { + let line = chunk + .iter() + .enumerate() + .fold(row![].spacing(4), |line, (c, entry)| { + line.push(self.doc_swatch(r * DOC_COLUMNS + c, entry)) + }); + let line = (chunk.len()..DOC_COLUMNS) + .fold(line, |line, _| line.push(Space::new().width(Length::Fill))); + grid.push(line) + }, + ); + column![ + header, + scrollable(grid).height(Length::Fill).spacing(4).direction( + scrollable::Direction::Vertical( + scrollable::Scrollbar::new().width(4).scroller_width(4) + ) + ), + ] + .spacing(10) + .into() + } + + fn doc_swatch<'a>(&self, i: usize, entry: &'a DocColor) -> Element<'a, Message> { + let fill = math::color_rgb(entry.color).map_or(Color::from_rgb8(128, 128, 128), rgb_color); + let selected = self.pending == entry.color && self.pending_name == entry.name; + button( + container( + text(entry.tag()) + .size(10) + .font(iced::Font::MONOSPACE) + .color(ink(fill)), + ) + .align_y(iced::Bottom) + .padding([1, 4]) + .height(Length::Fill), + ) + .on_press(picker_msg(ColorPickerMsg::PickDocument(i))) + .padding(0) + .width(Length::Fill) + .height(30) + .style(move |theme: &Theme, status| swatch_button_style(theme, status, fill, selected)) + .into() + } + + fn books_tab(&self) -> Element<'_, Message> { + let load_button = |content: Element<'static, Message>| { + button(content) + .on_press(picker_msg(ColorPickerMsg::LoadBook)) + .style(button::secondary) + }; + let Some(book) = self.books.get(self.book) else { + return container( + column![ + text(t!("No color books loaded")).size(12).style(muted_text), + load_button(text(t!("Load color book (.acb)")).size(12).into()) + .padding([6, 14]), + ] + .spacing(10) + .align_x(iced::Center), + ) + .center(Length::Fill) + .into(); + }; + let label = |label: std::borrow::Cow<'static, str>| text(label).size(11).style(muted_text); + let books = Arc::clone(&self.books); + let top = row![ + column![ + label(t!("Color book")), + pick_list( + Some(self.book), + (0..self.books.len()).collect::>(), + move |&i| books[i].name.clone(), + ) + .on_select(|i| picker_msg(ColorPickerMsg::Book(i))) + .text_size(12) + .padding([4, 6]) + .width(Length::Fill), + ] + .spacing(4) + .width(Length::Fill), + column![ + label(t!("Find")), + text_input("", &self.book_query) + .on_input(|query| picker_msg(ColorPickerMsg::BookQuery(query))) + .size(12) + .padding([5, 8]) + .font(iced::Font::MONOSPACE) + .width(116), + ] + .spacing(4), + load_button( + svg(crate::ui::icons::themed_handle( + crate::ui::icons::FOLDER_OPEN + )) + .width(15) + .height(15) + .into() + ) + .padding([6, 7]), + ] + .spacing(8) + .align_y(iced::Bottom); + + // The page's colours, or the first matches while searching. + let selected = self.book_selection(); + let query = self.book_query.trim().to_lowercase(); + let mut bars = column![].spacing(2).height(Length::Fill); + let mut shown = 0; + if query.is_empty() { + let (start, colors) = book.page(self.book_page); + for (i, color) in colors.iter().enumerate() { + bars = bars.push(book_bar(start + i, color, selected)); + shown += 1; + } + } else { + for (i, color) in book + .colors + .iter() + .enumerate() + .filter(|(_, color)| color.name.to_lowercase().contains(&query)) + .take(PAGE_BARS) + { + bars = bars.push(book_bar(i, color, selected)); + shown += 1; + } + } + let bars: Element<'_, Message> = if shown == 0 { + empty_note(t!("No colors match").into_owned()) + } else { + mouse_area(bars) + .on_scroll(|delta| { + let (iced::mouse::ScrollDelta::Lines { y, .. } + | iced::mouse::ScrollDelta::Pixels { y, .. }) = delta; + picker_msg(ColorPickerMsg::BookScroll(y)) + }) + .into() + }; + + // Every page as one band of its middle colour; the shown page framed. + let strip = (0..book.page_count()).fold(column![], |strip, page| { + let (_, colors) = book.page(page); + let fill = rgb_color(colors[colors.len() / 2].rgb); + let shown = query.is_empty() && page == self.book_page; + strip.push( + button(Space::new()) + .on_press(picker_msg(ColorPickerMsg::BookPage(page))) + .padding(0) + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme: &Theme, _status| button::Style { + background: Some(fill.into()), + border: Border { + color: Color::WHITE, + width: if shown { 2.0 } else { 0.0 }, + radius: 2.0.into(), + }, + ..Default::default() + }), + ) + }); + let arrow = |path: &'static str, page: usize| { + button( + svg(svg::Handle::from_memory( + format!( + "\ + " + ) + .into_bytes(), + )) + .width(9) + .height(5) + .style(|theme: &Theme, _status| svg::Style { + color: Some(theme.palette().background.base.text), + }), + ) + .on_press(picker_msg(ColorPickerMsg::BookPage(page))) + .width(Length::Fill) + .height(18) + .padding([6, 8]) + .style(button::secondary) + }; + let strip = column![ + arrow("M0 4L3.5 0L7 4z", self.book_page.saturating_sub(1)), + container(strip) + .height(Length::Fill) + .padding(1) + .style(|theme: &Theme| container::Style { border: Border { - color: if selected || hovered { - theme.palette().primary.base.color - } else { - Color::from_rgba(0.0, 0.0, 0.0, 0.35) - }, - width: if selected { - 2.5 - } else if hovered { - 1.5 - } else { - 1.0 - }, - radius: 2.0.into(), + color: theme.palette().background.strong.color, + width: 1.0, + radius: 3.0.into(), }, ..Default::default() - } - }) - .into() + }), + arrow("M0 0L7 0L3.5 4z", self.book_page + 1), + ] + .spacing(3) + .width(26); + + // RGB equivalent of the selected colour. + let chosen = selected.map(|i| &book.colors[i]); + let channel = |name: std::borrow::Cow<'static, str>, value: Option| { + row![ + text(name).size(12).style(muted_text), + Space::new().width(Length::Fill), + text(value.map_or("—".to_string(), |v| v.to_string())) + .size(12) + .font(iced::Font::MONOSPACE), + ] + }; + let equivalent = column![ + text(t!("RGB equivalent")).size(11), + channel(t!("Red"), chosen.map(|c| c.rgb.0)), + channel(t!("Green"), chosen.map(|c| c.rgb.1)), + channel(t!("Blue"), chosen.map(|c| c.rgb.2)), + container(Space::new()) + .width(Length::Fill) + .height(1) + .style(|theme: &Theme| filled(theme.palette().background.weak.color)), + text(format!("{} / {}", self.book_page + 1, book.page_count())) + .size(11) + .style(muted_text), + text(chosen.map_or(String::new(), |c| c.name.clone())).size(12), + ] + .spacing(6) + .width(104); + + column![ + top, + row![bars, strip, equivalent] + .spacing(10) + .height(Length::Fill) + ] + .spacing(12) + .into() } fn true_color_tab(&self) -> Element<'_, Message> { @@ -494,6 +943,21 @@ fn step_index(index: u8, direction: ArrowKey) -> Option { } } +/// Arrow keys over a list laid out `columns` wide. Nothing selected yet +/// starts at the first entry. +fn step_list(at: Option, len: usize, columns: usize, direction: ArrowKey) -> Option { + let Some(at) = at else { + return (len > 0).then_some(0); + }; + let next = match direction { + ArrowKey::Left => at.checked_sub(1), + ArrowKey::Right => Some(at + 1), + ArrowKey::Up => at.checked_sub(columns), + ArrowKey::Down => Some(at + columns), + }; + next.filter(|&next| next < len) +} + /// Move keyboard focus to the next (or previous) field inside the panel. pub fn focus_step(forward: bool) -> iced::Task { use iced::advanced::widget::operation::{focusable, scope}; @@ -514,6 +978,77 @@ fn ink_is_dark(color: Color) -> bool { math::ink_is_dark(r, g, b) } +/// Text that reads on top of `fill`. +fn ink(fill: Color) -> Color { + if ink_is_dark(fill) { + Color::from_rgb8(0x11, 0x12, 0x14) + } else { + Color::WHITE + } +} + +/// One colour of a colour book: a full-width bar with its name. +fn book_bar( + i: usize, + color: &super::book::BookColor, + selected: Option, +) -> Element<'_, Message> { + let fill = rgb_color(color.rgb); + button( + container( + text(&color.name) + .size(12) + .font(iced::Font::MONOSPACE) + .color(ink(fill)), + ) + .center(Length::Fill), + ) + .on_press(picker_msg(ColorPickerMsg::PickBook(i))) + .padding(0) + .width(Length::Fill) + .height(Length::Fill) + .style(move |theme: &Theme, status| { + swatch_button_style(theme, status, fill, selected == Some(i)) + }) + .into() +} + +/// A short gray note in the middle of the free space. +fn empty_note<'a>(note: String) -> Element<'a, Message> { + container(text(note).size(12).style(muted_text)) + .center(Length::Fill) + .into() +} + +/// A clickable colour: ringed in the accent colour when selected or hovered. +fn swatch_button_style( + theme: &Theme, + status: button::Status, + fill: Color, + selected: bool, +) -> button::Style { + let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed); + button::Style { + background: Some(fill.into()), + border: Border { + color: if selected || hovered { + theme.palette().primary.base.color + } else { + Color::from_rgba(0.0, 0.0, 0.0, 0.35) + }, + width: if selected { + 2.5 + } else if hovered { + 1.5 + } else { + 1.0 + }, + radius: 2.0.into(), + }, + ..Default::default() + } +} + fn filled(color: Color) -> container::Style { container::Style { background: Some(color.into()), @@ -641,7 +1176,7 @@ mod tests { p.update(ColorPickerMsg::Pick(AcadColor::Index(5))); assert_eq!( p.update(ColorPickerMsg::Ok), - Some(Outcome::Commit(AcadColor::Index(5))) + Some(Outcome::Commit(AcadColor::Index(5), None)) ); assert_eq!(p.update(ColorPickerMsg::Cancel), Some(Outcome::Cancel)); } @@ -652,11 +1187,14 @@ mod tests { edit(&mut p, EditorMsg::HexInput("#3bf".into())); assert_eq!( p.update(ColorPickerMsg::Ok), - Some(Outcome::Commit(AcadColor::Rgb { - r: 0x33, - g: 0xBB, - b: 0xFF - })) + Some(Outcome::Commit( + AcadColor::Rgb { + r: 0x33, + g: 0xBB, + b: 0xFF + }, + None + )) ); } @@ -724,6 +1262,89 @@ mod tests { ); } + fn ral() -> Arc> { + let entry = |name: &str, (r, g, b): (u8, u8, u8)| { + format!( + "{name}{r}\ + {g}{b}" + ) + }; + let text = format!( + "RAL CLASSIC{}{}\ + {}", + entry("RAL 1000", (190, 189, 127)), + entry("RAL 3020", (204, 6, 5)), + entry("RAL 5015", (34, 113, 179)), + ); + Arc::new(vec![super::super::book::parse(&text).unwrap()]) + } + + #[test] + fn book_colours_keep_their_name_until_edited() { + let mut p = picker(AcadColor::Index(30)).with_drawing(Vec::new(), &[], ral(), None); + p.update(ColorPickerMsg::PickBook(2)); + assert_eq!(p.book_page, 1, "turns to the colour's page"); + assert_eq!( + p.update(ColorPickerMsg::Ok), + Some(Outcome::Commit( + AcadColor::Rgb { + r: 34, + g: 113, + b: 179 + }, + Some("RAL CLASSIC$RAL 5015".into()) + )) + ); + p.update(ColorPickerMsg::Arrow(ArrowKey::Up)); + assert_eq!(p.pending_name.as_deref(), Some("RAL CLASSIC$RAL 3020")); + edit(&mut p, EditorMsg::Hue(120.0)); + assert_eq!( + p.pending_name, None, + "an edited colour is no longer the book's" + ); + } + + #[test] + fn opens_a_book_colour_on_its_page() { + let p = picker(AcadColor::Rgb { + r: 34, + g: 113, + b: 179, + }) + .with_drawing(Vec::new(), &[], ral(), Some("RAL CLASSIC$RAL 5015".into())); + assert_eq!(p.tab(), ColorPickerTab::Books); + assert_eq!(p.book_selection(), Some(2)); + assert_eq!(p.book_page, 1); + } + + #[test] + fn document_colours_pick_and_walk() { + // A new drawing's layer 0 is white. + let doc = document::collect(&codec::CadDocument::new()); + assert!(doc.iter().any(|c| c.color == AcadColor::Index(7))); + let mut p = picker(AcadColor::Index(30)).with_drawing(doc, &[], Arc::default(), None); + p.update(ColorPickerMsg::Tab(ColorPickerTab::Document)); + p.update(ColorPickerMsg::Arrow(ArrowKey::Right)); + assert_eq!( + p.pending(), + p.doc[0].color, + "the first key press starts at the top" + ); + + assert_eq!(step_list(None, 3, 8, ArrowKey::Down), Some(0)); + assert_eq!(step_list(Some(1), 20, 8, ArrowKey::Down), Some(9)); + assert_eq!(step_list(Some(1), 5, 8, ArrowKey::Down), None); + assert_eq!(step_list(Some(0), 5, 8, ArrowKey::Left), None); + } + + #[test] + fn loading_a_book_is_left_to_the_app() { + let mut p = picker(AcadColor::Index(30)); + assert_eq!(p.update(ColorPickerMsg::LoadBook), Some(Outcome::LoadBook)); + p.show_book(ral(), 0); + assert_eq!(p.tab(), ColorPickerTab::Books); + } + // ── Headless UI tests ─────────────────────────────────────────────── // // These run the real widgets through iced's simulator. Setting @@ -809,7 +1430,31 @@ mod tests { ColorPickerTab, EditorMode, bool, - ); 7] = [ + ); 10] = [ + ( + "document", + ColorPickTarget::Properties, + AcadColor::Index(7), + ColorPickerTab::Document, + EditorMode::Hsb, + false, + ), + ( + "books", + ColorPickTarget::Properties, + AcadColor::Index(30), + ColorPickerTab::Books, + EditorMode::Hsb, + false, + ), + ( + "no-books", + ColorPickTarget::Properties, + AcadColor::Index(30), + ColorPickerTab::Books, + EditorMode::Hsb, + false, + ), ( "index", ColorPickTarget::Properties, @@ -876,8 +1521,21 @@ mod tests { crate::ui::style::fusion_theme::fusion_white(), ] { for (name, target, color, tab, mode, varies) in views.clone() { - let mut p = ColorPicker::new(target, color, varies); + let books = if name == "no-books" { + Arc::default() + } else { + ral() + }; + let mut p = ColorPicker::new(target, color, varies).with_drawing( + document::collect(&codec::CadDocument::new()), + &[], + books, + None, + ); p.update(ColorPickerMsg::Tab(tab)); + if name == "books" { + p.update(ColorPickerMsg::PickBook(1)); + } edit(&mut p, EditorMsg::Mode(mode)); let mut ui = simulator(&p); let snapshot = ui.snapshot(&theme).expect("snapshot");