diff --git a/Cargo.lock b/Cargo.lock index e02dcc368..edd1f54e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2593,6 +2593,8 @@ dependencies = [ "iced_core", "iced_fonts_macros", "iced_widget", + "num-format", + "num-traits", "web-time", ] @@ -3868,6 +3870,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + [[package]] name = "num-integer" version = "0.1.46" diff --git a/Cargo.toml b/Cargo.toml index b8295f976..04941271b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ url = "2" iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["image", "svg", "advanced", "canvas", "smol", "markdown"] } iced_runtime = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } -iced_aw = { git = "https://github.com/HakanSeven12/iced_aw.git", branch = "agent/fix-iced-fonts", features = ["menu", "context_menu", "color_picker", "drop_down"] } +iced_aw = { git = "https://github.com/HakanSeven12/iced_aw.git", branch = "agent/fix-iced-fonts", features = ["menu", "context_menu", "drop_down", "number_input"] } bytemuck = { version = "1.25", features = ["derive"] } glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" diff --git a/assets/color_books/README.md b/assets/color_books/README.md new file mode 100644 index 000000000..334a4a632 --- /dev/null +++ b/assets/color_books/README.md @@ -0,0 +1,12 @@ +# Built-in colour books + +AutoCAD `.acb` colour books built into the Select Color panel. Each file +carries its source and licence in its header comment. + +| File | Book | Source and licence | +|---|---|---| +| `ral-classic.acb` | RAL CLASSIC, 216 approximate sRGB values | Adapted from Wikipedia, "List of RAL colours"; CC BY-SA 4.0 | + +Values are approximate screen representations, not colour-binding +references. RAL is a registered trademark of RAL gGmbH; this file is not +affiliated with or endorsed by RAL gGmbH. diff --git a/assets/color_books/ral-classic.acb b/assets/color_books/ral-classic.acb new file mode 100644 index 000000000..cd2462f3d --- /dev/null +++ b/assets/color_books/ral-classic.acb @@ -0,0 +1,301 @@ + + + + RAL CLASSIC + 1 + 0 + + RAL 1000205186136 + RAL 1001208176132 + RAL 1002210170109 + RAL 10032491680 + RAL 10042281580 + RAL 10052031420 + RAL 10062261440 + + + RAL 10072321400 + RAL 101117512879 + RAL 101222117539 + RAL 1013227217198 + RAL 1014221196154 + RAL 1015230210181 + RAL 101624122156 + + + RAL 101724616980 + RAL 101825020248 + RAL 1019164143122 + RAL 1020160143101 + RAL 10212461820 + RAL 10232471810 + RAL 102418614376 + + + RAL 10262552550 + RAL 102716712714 + RAL 10282551550 + RAL 10322261630 + RAL 103324915428 + RAL 103423515682 + RAL 1035144131112 + + + RAL 103612810063 + RAL 10372401460 + + + RAL 20002211217 + RAL 20011907832 + RAL 20021985739 + RAL 200325013243 + RAL 20042319118 + RAL 2005255350 + RAL 200725516433 + + + RAL 200824311744 + RAL 2009225851 + RAL 201021210147 + RAL 201123612437 + RAL 201221910680 + RAL 20131496939 + RAL 2017250682 + + + RAL 30001713736 + RAL 30011603340 + RAL 30021613543 + RAL 30031412944 + RAL 30041123141 + RAL 3005943240 + RAL 3007643437 + + + RAL 30091125549 + RAL 30111264144 + RAL 3012203141115 + RAL 30131565046 + RAL 3014212116121 + RAL 3015225166173 + RAL 30161726452 + + + RAL 30172118495 + RAL 30182096582 + RAL 30201931828 + RAL 302221310986 + RAL 302424700 + RAL 302625500 + RAL 30271803265 + + + RAL 30282313718 + RAL 30311725059 + RAL 30321132133 + RAL 30331787667 + + + RAL 400113890131 + RAL 40021476180 + RAL 400320991143 + RAL 40041052257 + RAL 400513199157 + RAL 400615337114 + RAL 4007743259 + + + RAL 400814470132 + RAL 4009163137149 + RAL 401019854120 + RAL 4011135115161 + RAL 4012107104128 + + + RAL 50005676112 + RAL 50013171100 + RAL 50024344124 + RAL 5003425586 + RAL 5004293142 + RAL 50052172137 + RAL 500765103141 + + + RAL 5008496072 + RAL 50094689120 + RAL 50101968124 + RAL 5011354463 + RAL 501252129184 + RAL 5013354583 + RAL 5014108124152 + + + RAL 501540116178 + RAL 50171481141 + RAL 501833136143 + RAL 50192687132 + RAL 5020116581 + RAL 50217115122 + RAL 5022474290 + + + RAL 502377102142 + RAL 5024106147176 + RAL 502541100120 + RAL 5026164484 + + + RAL 60005011898 + RAL 60014011362 + RAL 6002399853 + RAL 6003758762 + RAL 6004146667 + RAL 6005156754 + RAL 6006646759 + + + RAL 6007405236 + RAL 6008535646 + RAL 6009385747 + RAL 60106211759 + RAL 601110413091 + RAL 6012496461 + RAL 601312112490 + + + RAL 6014686755 + RAL 6015616458 + RAL 6016210682 + RAL 60177013465 + RAL 60187216463 + RAL 6019183217177 + RAL 6020537151 + + + RAL 6021134164124 + RAL 6022626050 + RAL 6024013584 + RAL 60258311760 + RAL 602609382 + RAL 6027129192187 + RAL 6028458570 + + + RAL 6029011467 + RAL 60321513388 + RAL 603371138132 + RAL 6034127176178 + RAL 6035278444 + RAL 603609376 + RAL 60373723118 + + + RAL 603802470 + RAL 603917919662 + + + RAL 7000126139146 + RAL 7001143153159 + RAL 7002129127104 + RAL 7003122123109 + RAL 7004158160161 + RAL 7005107113111 + RAL 700611711197 + + + RAL 700811610267 + RAL 7009919889 + RAL 7010879387 + RAL 7011859397 + RAL 7012899799 + RAL 7013858572 + RAL 7015818692 + + + RAL 7016556367 + RAL 7021465052 + RAL 7022757770 + RAL 7023129132121 + RAL 7024717480 + RAL 7026556871 + RAL 7030147147136 + + + RAL 703193105112 + RAL 7032185185168 + RAL 7033129137121 + RAL 7034147145118 + RAL 7035203208204 + RAL 7036154150151 + RAL 7037124127126 + + + RAL 7038180184176 + RAL 703910710595 + RAL 7040157163166 + RAL 7042143150149 + RAL 7043788481 + RAL 7044189189178 + RAL 7045145150154 + + + RAL 7046130137142 + RAL 7047207208207 + RAL 7048136129117 + + + RAL 800013611366 + RAL 800115610748 + RAL 80021238165 + RAL 80031288447 + RAL 80041437853 + RAL 80071117447 + RAL 80081117940 + + + RAL 8011905841 + RAL 80121035649 + RAL 8014735745 + RAL 8015995852 + RAL 8016764738 + RAL 8017685045 + RAL 8019635858 + + + RAL 8022333132 + RAL 80231669447 + RAL 80241218560 + RAL 80251179273 + RAL 8028785943 + RAL 80291196039 + + + RAL 9001239235220 + RAL 9002221222212 + RAL 9003255255255 + RAL 9004464850 + RAL 9005192122 + RAL 9006165168166 + RAL 9007143143140 + + + RAL 9010247249239 + RAL 9011282828 + RAL 9012255253230 + RAL 9016247251245 + RAL 9017424547 + RAL 9018207211205 + RAL 9022133133131 + + + RAL 9023126129130 + + diff --git a/assets/icons/ui/LICENSE-phosphor.txt b/assets/icons/ui/LICENSE-phosphor.txt new file mode 100644 index 000000000..78c981081 --- /dev/null +++ b/assets/icons/ui/LICENSE-phosphor.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/icons/ui/eyedropper.svg b/assets/icons/ui/eyedropper.svg new file mode 100644 index 000000000..fd3e85c09 --- /dev/null +++ b/assets/icons/ui/eyedropper.svg @@ -0,0 +1,2 @@ + + diff --git a/assets/icons/ui/list.svg b/assets/icons/ui/list.svg new file mode 100644 index 000000000..ebfaa1599 --- /dev/null +++ b/assets/icons/ui/list.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/ui/search.svg b/assets/icons/ui/search.svg new file mode 100644 index 000000000..aa0347716 --- /dev/null +++ b/assets/icons/ui/search.svg @@ -0,0 +1 @@ + diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 115c1cc05..1b3ac2476 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1430,15 +1430,31 @@ common = .dismiss = إغلاق .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = لا توجد ألوان حديثة بعد - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .color-hex = سداسي عشري + .nearest-index-color = أقرب فهرس + .exact-index-match = تطابق تام مع الفهرس + .color-recently-used = المستخدمة مؤخرًا + .color-most-used = الأكثر استخدامًا + .color-number = رقم اللون + .color-name-az = الاسم أ–ي + .color-name-za = الاسم ي–أ + .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 = لا يحتوي كتاب الألوان على ألوان + .color-book-locations = مواقع كتب الألوان .cad-color-index-aci-10-249 = فهرس ألوان ACI 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = المستخدمة مؤخرًا .isometric-drafting = الرسم متساوي القياس .f5-cycles-the-active-plane = يبدّل F5 المستوى النشط بالتتابع. .units-5430a1 = { common.units }… diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 66e1efb00..57db6d081 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1417,15 +1417,31 @@ common = .dismiss = Затваряне .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = Все още няма скорошни цветове - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .color-hex = Hex + .nearest-index-color = Най-близък индекс + .exact-index-match = Точно съвпадение с индекс + .color-recently-used = Наскоро използвани + .color-most-used = Най-често използвани + .color-number = Номер на цвят + .color-name-az = Име А–Я + .color-name-za = Име Я–А + .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 = Цветовият каталог няма цветове + .color-book-locations = Местоположения на цветови каталози .cad-color-index-aci-10-249 = ACI Цветови индекс 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = Последно използвани .isometric-drafting = Изометрично чертане .f5-cycles-the-active-plane = F5 превключва последователно активната равнина. .units-5430a1 = { common.units }… diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 1e7ad359d..6c0eb0eae 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1417,15 +1417,31 @@ common = .dismiss = Zavřít .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = Zatím žádné nedávné barvy - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .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-name-az = Název A–Z + .color-name-za = Název Z–A + .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 + .color-book-locations = Umístění barevných vzorníků .cad-color-index-aci-10-249 = Barevný index ACI 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = Nedávno použité .isometric-drafting = Izometrické kreslení .f5-cycles-the-active-plane = F5 cyklicky přepíná aktivní rovinu. .units-5430a1 = { common.units }… diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 4f2dc5862..b87caa55a 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1418,15 +1418,31 @@ common = .dismiss = Schließen .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = Noch keine zuletzt verwendeten Farben - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .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-name-az = Name A–Z + .color-name-za = Name Z–A + .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 + .color-book-locations = Speicherorte für Farbbücher .cad-color-index-aci-10-249 = ACI Farbindex 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = Zuletzt verwendet .isometric-drafting = Isometrisches Zeichnen .f5-cycles-the-active-plane = F5 wechselt die aktive Ebene der Reihe nach. .units-5430a1 = { common.units }… diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 1335f5567..47421fc23 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1419,15 +1419,31 @@ common = .dismiss = Κλείσιμο .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = Δεν υπάρχουν ακόμη πρόσφατα χρώματα - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .color-hex = Hex + .nearest-index-color = Πλησιέστερος δείκτης + .exact-index-match = Ακριβής αντιστοίχιση δείκτη + .color-recently-used = Πρόσφατα χρησιμοποιημένα + .color-most-used = Πιο συχνά χρησιμοποιημένα + .color-number = Αριθμός χρώματος + .color-name-az = Όνομα Α–Ω + .color-name-za = Όνομα Ω–Α + .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 = Το βιβλίο χρωμάτων δεν έχει χρώματα + .color-book-locations = Θέσεις βιβλίων χρωμάτων .cad-color-index-aci-10-249 = Δείκτης χρώματος ACI 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = Πρόσφατα χρησιμοποιημένα .isometric-drafting = Ισομετρική σχεδίαση .f5-cycles-the-active-plane = Το F5 εναλλάσσει το ενεργό επίπεδο. .units-5430a1 = { common.units }… diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index e760c9d45..aaa070afe 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1418,15 +1418,31 @@ common = .dismiss = Dismiss .value-8a8de8 = ? .select-color-8dddf1 = Select Color... - .no-recent-colors-yet = No recent colors yet - .true-color-rgb = True Color RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = Index Color .true-color = True Color .standard-colors = Standard colors + .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-name-az = Name A–Z + .color-name-za = Name Z–A + .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 + .color-book-locations = Color book locations .cad-color-index-aci-10-249 = CAD Color Index (ACI) 10–249 - .color-family-shade-intensity = Color family → · Shade / intensity ↓ .grayscale-250-255 = Grayscale 250–255 - .recently-used = Recently used .isometric-drafting = Isometric drafting .f5-cycles-the-active-plane = F5 cycles the active plane. .units-5430a1 = Units… diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index a14a8d851..350069c0f 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1418,15 +1418,31 @@ common = .dismiss = Cerrar .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = Aún no hay colores recientes - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .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-name-az = Nombre A–Z + .color-name-za = Nombre Z–A + .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 + .color-book-locations = Ubicaciones de libros de colores .cad-color-index-aci-10-249 = Índice de color ACI 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = Usados recientemente .isometric-drafting = Dibujo isométrico .f5-cycles-the-active-plane = F5 alterna el plano activo. .units-5430a1 = { common.units }… diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 6682909aa..d7168d455 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1417,15 +1417,31 @@ common = .dismiss = Sulje .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = Ei vielä viimeksi käytettyjä värejä - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .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-name-az = Nimi A–Ö + .color-name-za = Nimi Ö–A + .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ä + .color-book-locations = Värikirjojen sijainnit .cad-color-index-aci-10-249 = ACI väriindeksi 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = Viimeksi käytetyt .isometric-drafting = Isometrinen piirtäminen .f5-cycles-the-active-plane = F5 vaihtaa aktiivista tasoa. .units-5430a1 = { common.units }… diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 4403c1d40..213c37ce7 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1418,15 +1418,31 @@ common = .dismiss = Fermer .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = Aucune couleur récente - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .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-name-az = Nom A–Z + .color-name-za = Nom Z–A + .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 + .color-book-locations = Emplacements des carnets de couleurs .cad-color-index-aci-10-249 = ACI Indice de couleur 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = Utilisés récemment .isometric-drafting = Dessin isométrique .f5-cycles-the-active-plane = F5 fait défiler les plans actifs. .units-5430a1 = { common.units }… diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 4f69be5f5..5713ca073 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1406,15 +1406,31 @@ common = .dismiss = बंद करें .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = अभी कोई हालिया रंग नहीं है - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .color-hex = हेक्स + .nearest-index-color = निकटतम इंडेक्स + .exact-index-match = इंडेक्स से सटीक मेल + .color-recently-used = हाल ही में उपयोग किए गए + .color-most-used = सबसे अधिक उपयोग किए गए + .color-number = रंग संख्या + .color-name-az = नाम A–Z + .color-name-za = नाम Z–A + .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 = रंग पुस्तक में कोई रंग नहीं है + .color-book-locations = रंग पुस्तकों के स्थान .cad-color-index-aci-10-249 = ACI रंग सूचकांक 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = हाल में उपयोग किए गए .isometric-drafting = सममितीय आरेखन .f5-cycles-the-active-plane = F5 सक्रिय तल को क्रम से बदलता है। .units-5430a1 = { common.units }… diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index d227954e4..6090437ed 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1422,15 +1422,31 @@ common = .dismiss = Bezárás .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = Még nincsenek legutóbbi színek - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.color } ({ common.standard }) + .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-name-az = Név A–Z + .color-name-za = Név Z–A + .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 + .color-book-locations = Színkönyvek helye .cad-color-index-aci-10-249 = ACI színindex 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = Legutóbb használt .isometric-drafting = Izometrikus rajzolás .f5-cycles-the-active-plane = Az F5 váltja az aktív síkot. .units-5430a1 = { common.units }… diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 8ee782f36..865bc395d 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1417,15 +1417,31 @@ common = .dismiss = Chiudi .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = Nessun colore recente - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .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-name-az = Nome A–Z + .color-name-za = Nome Z–A + .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 + .color-book-locations = Percorsi degli album colori .cad-color-index-aci-10-249 = ACI Indice colori 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = Usati di recente .isometric-drafting = Disegno isometrico .f5-cycles-the-active-plane = F5 alterna il piano attivo. .units-5430a1 = { common.units }… diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 0e49b74f3..2e5a6dab4 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1408,15 +1408,31 @@ common = .dismiss = 閉じる .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = 最近使った色はありません - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .color-hex = 16進 + .nearest-index-color = 最も近いインデックス + .exact-index-match = インデックスと完全一致 + .color-recently-used = 最近使用した色 + .color-most-used = 使用頻度の高い色 + .color-number = 色番号 + .color-name-az = 名前 (昇順) + .color-name-za = 名前 (降順) + .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 = カラー ブックに色がありません + .color-book-locations = カラー ブックの場所 .cad-color-index-aci-10-249 = ACI カラーインデックス 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = 最近使用した色 .isometric-drafting = アイソメ図作成 .f5-cycles-the-active-plane = F5でアクティブな平面を順に切り替えます。 .units-5430a1 = { common.units }… diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index a93158a0d..47c2ac449 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1417,15 +1417,31 @@ common = .dismiss = 닫기 .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = 최근 사용한 색상이 없습니다 - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .color-hex = 16진수 + .nearest-index-color = 가장 가까운 인덱스 + .exact-index-match = 인덱스와 정확히 일치 + .color-recently-used = 최근 사용 + .color-most-used = 가장 많이 사용 + .color-number = 색상 번호 + .color-name-az = 이름 오름차순 + .color-name-za = 이름 내림차순 + .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 = 색상표에 색상이 없습니다 + .color-book-locations = 색상표 위치 .cad-color-index-aci-10-249 = ACI 컬러 인덱스 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = 최근 사용 .isometric-drafting = 등각 제도 .f5-cycles-the-active-plane = F5 키로 활성 평면을 순환 전환합니다. .units-5430a1 = { common.units }… diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index de6e265a7..ea947238f 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -1423,15 +1423,31 @@ common = .dismiss = ປິດອອກ .value-8a8de8 = ? .select-color-8dddf1 = ເລືອກສີ... - .no-recent-colors-yet = ຍັງບໍ່ມີສີຫຼ້າສຸດ - .true-color-rgb = True Color RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = ສີດັດຊະນີ .true-color = True Color .standard-colors = ສີມາດຕະຖານ + .color-hex = Hex + .nearest-index-color = ດັດຊະນີທີ່ໃກ້ທີ່ສຸດ + .exact-index-match = ກົງກັບດັດຊະນີພໍດີ + .color-recently-used = ໃຊ້ຫຼ້າສຸດ + .color-most-used = ໃຊ້ຫຼາຍທີ່ສຸດ + .color-number = ເລກສີ + .color-name-az = ຊື່ A–Z + .color-name-za = ຊື່ Z–A + .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 = ປຶ້ມສີບໍ່ມີສີ + .color-book-locations = ທີ່ຕັ້ງປຶ້ມສີ .cad-color-index-aci-10-249 = CAD Color Index (ACI) 10–249 - .color-family-shade-intensity = ກຸ່ມສີ → · ລະດັບສີ / ຄວາມເຂັ້ມ ↓ .grayscale-250-255 = ລະດັບສີເທົາ 250–255 - .recently-used = ໃຊ້ຫຼ້າສຸດ .isometric-drafting = ການຂຽນແບບໄອໂຊເມຕຣິກ .f5-cycles-the-active-plane = F5 ປ່ຽນລະນາບທີ່ໃຊ້ງານວຽນໄປ. .units-5430a1 = ຫົວໜ່ວຍ… diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 89b69afd7..ba179b790 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1416,15 +1416,31 @@ common = .dismiss = Sluiten .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = Nog geen recente kleuren - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .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-name-az = Naam A–Z + .color-name-za = Naam Z–A + .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 + .color-book-locations = Locaties van kleurboeken .cad-color-index-aci-10-249 = ACI Kleurindex 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = Recent gebruikt .isometric-drafting = Isometrisch tekenen .f5-cycles-the-active-plane = F5 wisselt het actieve vlak. .units-5430a1 = { common.units }… diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index abb137bee..f6c9c62f7 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1417,15 +1417,31 @@ common = .dismiss = Zamknij .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = Brak ostatnio używanych kolorów - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .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-name-az = Nazwa A–Z + .color-name-za = Nazwa Z–A + .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 + .color-book-locations = Lokalizacje wzorników kolorów .cad-color-index-aci-10-249 = Indeks kolorów ACI 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = Ostatnio używane .isometric-drafting = Rysowanie izometryczne .f5-cycles-the-active-plane = F5 przełącza aktywną płaszczyznę. .units-5430a1 = { common.units }… diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index d0dd578fc..cef8d43c1 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1418,15 +1418,31 @@ common = .dismiss = Fechar .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = Nenhuma cor recente ainda - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .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-name-az = Nome A–Z + .color-name-za = Nome Z–A + .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 + .color-book-locations = Locais dos catálogos de cores .cad-color-index-aci-10-249 = Índice de cor ACI 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = Usados recentemente .isometric-drafting = Desenho isométrico .f5-cycles-the-active-plane = F5 alterna o plano ativo. .units-5430a1 = { common.units }… diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index df29f9c86..d5a77aac7 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1421,15 +1421,31 @@ common = .dismiss = Закрыть .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = Недавно использованных цветов пока нет - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .color-hex = Hex + .nearest-index-color = Ближайший индекс + .exact-index-match = Точное совпадение с индексом + .color-recently-used = Недавно использованные + .color-most-used = Чаще всего используемые + .color-number = Номер цвета + .color-name-az = Имя А–Я + .color-name-za = Имя Я–А + .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 = В альбоме цветов нет цветов + .color-book-locations = Расположение альбомов цветов .cad-color-index-aci-10-249 = Индекс цвета ACI 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = Недавно использованные .isometric-drafting = Изометрическое черчение .f5-cycles-the-active-plane = F5 циклически переключает активную плоскость. .units-5430a1 = { common.units }… diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index dad95869e..473a377c1 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1404,15 +1404,31 @@ common = .dismiss = Kapat .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = Henüz son kullanılan renk yok - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .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-name-az = Ad A–Z + .color-name-za = Ad Z–A + .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 + .color-book-locations = Renk kitabı konumları .cad-color-index-aci-10-249 = ACI renk dizini 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = Son kullanılanlar .isometric-drafting = İzometrik çizim .f5-cycles-the-active-plane = F5 etkin düzlemi sırayla değiştirir. .units-5430a1 = { common.units }… diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index bef4459c8..08dfd371f 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1405,15 +1405,31 @@ common = .dismiss = 关闭 .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = 尚无最近使用的颜色 - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .color-hex = 十六进制 + .nearest-index-color = 最接近的索引色 + .exact-index-match = 与索引色完全匹配 + .color-recently-used = 最近使用 + .color-most-used = 最常使用 + .color-number = 颜色编号 + .color-name-az = 名称 A–Z + .color-name-za = 名称 Z–A + .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 = 配色系统中没有颜色 + .color-book-locations = 配色系统位置 .cad-color-index-aci-10-249 = ACI 颜色索引 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = 最近使用 .isometric-drafting = 等轴测绘图 .f5-cycles-the-active-plane = F5 循环切换活动平面。 .units-5430a1 = { common.units }… diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 2a4d42102..70af3b9ca 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1417,15 +1417,31 @@ common = .dismiss = 關閉 .value-8a8de8 = ? .select-color-8dddf1 = { common.select-color }… - .no-recent-colors-yet = 尚無最近使用的顏色 - .true-color-rgb = { common.true-color } RGB __ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__ .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .color-hex = 十六進位 + .nearest-index-color = 最接近的索引色 + .exact-index-match = 與索引色完全相符 + .color-recently-used = 最近使用 + .color-most-used = 最常使用 + .color-number = 顏色編號 + .color-name-az = 名稱 A–Z + .color-name-za = 名稱 Z–A + .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 = 配色系統中沒有顏色 + .color-book-locations = 配色系統位置 .cad-color-index-aci-10-249 = ACI 顏色索引 10–249 - .color-family-shade-intensity = { common.color-family } → · { common.shade } / { properties.intensity } ↓ .grayscale-250-255 = { common.grayscale } 250–255 - .recently-used = 最近使用 .isometric-drafting = 等角製圖 .f5-cycles-the-active-plane = F5 循環切換作用中平面。 .units-5430a1 = { common.units }… diff --git a/src/app/command_driver/entity_commit.rs b/src/app/command_driver/entity_commit.rs index 30e8de088..9a23e6383 100644 --- a/src/app/command_driver/entity_commit.rs +++ b/src/app/command_driver/entity_commit.rs @@ -358,7 +358,11 @@ impl OpenCADStudio { if layer != "0" || entity.as_entity().layer().is_empty() { entity.as_entity_mut().set_layer(layer); } - crate::scene::view::dispatch::apply_color(&mut entity, self.ribbon.active_color); + crate::scene::view::dispatch::apply_named_color( + &mut entity, + self.ribbon.active_color, + self.ribbon.active_color_name(), + ); crate::scene::view::dispatch::apply_common_prop( &mut entity, "linetype", @@ -476,7 +480,11 @@ impl OpenCADStudio { if layer != "0" || entity.as_entity().layer().is_empty() { entity.as_entity_mut().set_layer(layer); } - crate::scene::view::dispatch::apply_color(&mut entity, self.ribbon.active_color); + crate::scene::view::dispatch::apply_named_color( + &mut entity, + self.ribbon.active_color, + self.ribbon.active_color_name(), + ); crate::scene::view::dispatch::apply_common_prop( &mut entity, "linetype", diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index 7fb744d6a..a50d6ee86 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -391,8 +391,8 @@ impl OpenCADStudio { .location(id) .map_or(crate::app::config::DockSide::Right, |(side, _)| side), matches!( - self.color_pick_target, - Some((crate::app::ColorPickTarget::Gradient(_), _)) + self.color_picker.as_ref().map(|picker| &picker.target), + Some(crate::app::ColorPickTarget::Gradient(_)) ), ), crate::ui::dock::PanelId::BlockPalette => { diff --git a/src/app/document.rs b/src/app/document.rs index a0ce06b08..97a8eb9e3 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -290,6 +290,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 { @@ -697,6 +699,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 657b6b27d..651eaddca 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -952,9 +952,6 @@ pub(super) struct OpenCADStudio { pub(crate) sheet_set_pending_layout: Option<(String, String)>, /// The subset / set Import Layout as Sheet adds to, while its file picker is open. pub(crate) sheet_set_import_parent: Option, - /// Which viewport background the colour wheel is editing, or `None` when - /// it is closed. One slot, because only one wheel can be open at a time. - pub(crate) bg_picker: Option, /// General edge-stack dock layout for the side panels. pub(crate) dock: crate::ui::dock::DockState, /// Pallet revealed although it auto-hides (its edge's group, or its @@ -1074,13 +1071,14 @@ pub(super) struct OpenCADStudio { #[cfg(target_arch = "wasm32")] web_autosave_clear_after: Vec, // ── Floating panel windows ──────────────────────────────────────────── - /// Active `iced_aw` colour picker: destination plus its initial colour. - color_pick_target: Option<(ColorPickTarget, AcadColor)>, - /// Visible page in the shared CAD colour picker. - color_picker_tab: ColorPickerTab, + /// The open Select Color panel, if any. + color_picker: Option, + /// The built-in colour book and those in the colour book folder, read + /// lazily when the Select Color panel first opens. + color_books: std::sync::Arc>, + 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, @@ -1788,21 +1786,19 @@ pub struct SaveOutcome { refreshed_preview: Option>, result: Result<(), crate::io::SaveFailure>, } -/// Which viewport background a colour-wheel session is editing. +/// Which Options colour the interface colour picker is editing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BgTarget { - Model, - Paper, - Desk, +pub enum UiColor { + ModelBackground, + PaperBackground, + DeskBackground, + /// One of the six theme colours, by its row in Options. + Theme(usize), + Crosshair, + SnapMarker, + CommandText, } -/// Active page in the shared CAD colour picker. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum ColorPickerTab { - #[default] - Index, - TrueColor, -} /// Where a colour chosen in the standalone palette window should be applied. #[derive(Debug, Clone)] pub enum ColorPickTarget { @@ -1828,6 +1824,8 @@ pub enum ColorPickTarget { LayerState(usize), /// The MText editor's selection (or global) colour. MText, + /// An Options interface colour (true colour only). + Interface(UiColor), /// A colour of the view Background dialog: 0 solid, 1 top, 2 middle, 3 bottom. ViewBackground(u8), } @@ -2596,12 +2594,6 @@ pub enum Message { ModelSpaceModeChanged(config::ModelSpaceMode), /// Change Model Space custom background color as hex or empty for default. ModelSpaceBgChanged(String), - /// Open the colour wheel on one of the viewport backgrounds. - BgPickerOpen(BgTarget), - /// Dismiss the wheel, changing nothing. - BgPickerCancel, - /// Accept the wheel's colour for whichever background it was opened on. - BgPickerSubmit(iced::Color), /// Change Paper Space custom sheet background color as hex or empty for default. PaperSpaceBgChanged(String), /// Change Paper Space desk surround background (#RRGGBB). @@ -4344,14 +4336,16 @@ pub enum Message { DsColorMore(DsField), /// Open the shared CAD colour picker for a field and its current colour. OpenColorWindow(ColorPickTarget, AcadColor), - /// Switch between Index Color and True Color. - ColorPickerTabChanged(ColorPickerTab), - /// Change the pending colour without committing it yet. - ColorPickerColorChanged(AcadColor), + /// Interaction inside the Select Color panel. + 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 (and, on desktop, copied into the first + /// colour book folder). + ColorBookLoaded(Result), /// Set a block/linetype Handle field on the selected dim style from a /// dropdown of available block-records / linetypes (by name). DsSetHandle { @@ -4756,7 +4750,6 @@ impl OpenCADStudio { sheet_set: Default::default(), sheet_set_pending_layout: None, sheet_set_import_parent: None, - bg_picker: None, block_palette: Default::default(), xref_manager: Default::default(), dock: Default::default(), @@ -4805,9 +4798,9 @@ impl OpenCADStudio { web_autosave_consumed: None, #[cfg(target_arch = "wasm32")] web_autosave_clear_after: Vec::new(), - color_pick_target: None, - color_picker_tab: ColorPickerTab::Index, - recent_colors: Vec::new(), + color_picker: None, + color_books: Default::default(), + color_books_read: false, active_modal: None, hyperlink_editor_handles: Vec::new(), hyperlink_editor_url: String::new(), diff --git a/src/app/properties.rs b/src/app/properties.rs index 747629f0d..537844f50 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -3160,7 +3160,11 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} } if !preserve_entity_style { - crate::scene::view::dispatch::apply_color(&mut entity, self.ribbon.active_color); + crate::scene::view::dispatch::apply_named_color( + &mut entity, + self.ribbon.active_color, + self.ribbon.active_color_name(), + ); crate::scene::view::dispatch::apply_common_prop( &mut entity, "linetype", diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 93e428ff5..9797982ed 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -436,6 +436,41 @@ impl OpenCADStudio { let tab = &self.tabs[self.active_tab]; (started, tab.id, tab.scene.geometry_epoch) }); + // The Select Color panel can open on top of another dialog, so it + // takes the keyboard first: Esc cancels, Enter confirms, the arrows + // walk the index palette, and every other keystroke is swallowed. + if self.color_picker.is_some() { + use crate::ui::color_select::ColorPickerMsg; + match &msg { + Message::CommandEscape => { + return self.update(Message::ColorPicker(ColorPickerMsg::Cancel)); + } + Message::CommandFinalize => { + return self.update(Message::ColorPicker(ColorPickerMsg::Ok)); + } + Message::ShortcutPressed(key) => match key.rsplit('+').next() { + Some("ESCAPE") => { + return self.update(Message::ColorPicker(ColorPickerMsg::Cancel)); + } + Some("ENTER" | "RETURN") => { + return self.update(Message::ColorPicker(ColorPickerMsg::Ok)); + } + Some("TAB") => { + return crate::ui::color_select::picker::focus_step( + !key.contains("SHIFT+"), + ); + } + _ => {} + }, + Message::ArrowKeyPressed { direction, .. } => { + return self.update(Message::ColorPicker(ColorPickerMsg::Arrow(*direction))); + } + _ => {} + } + if is_modal_blocked_key_msg(&msg) { + return Task::none(); + } + } // A modal dialog must capture the keyboard the same way it already // captures the mouse. Otherwise keystrokes from the global key // subscription leak past the modal into the command line and fire as @@ -4330,38 +4365,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; @@ -4473,7 +4477,7 @@ impl OpenCADStudio { Message::PaneClicked(pane) => self.on_pane_clicked(pane), Message::PaneDragged(ev) => self.on_pane_dragged(ev), Message::PaneMove(idx, local) => { - if self.color_pick_target.is_some() { + if self.color_picker.is_some() { return Task::none(); } let p = self.pane_canvas_point(idx, local); @@ -7432,22 +7436,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; @@ -8958,39 +8947,6 @@ impl OpenCADStudio { Task::none() } - Message::BgPickerOpen(target) => { - self.bg_picker = Some(target); - Task::none() - } - - Message::BgPickerCancel => { - self.bg_picker = None; - Task::none() - } - - Message::BgPickerSubmit(color) => { - // Hand the result to the same handler the typed hex field - // uses, so the wheel and the field cannot drift apart on - // validation, persistence or the MatchTheme fallback. - let hex = crate::app::config::rgb_to_hex([ - (color.r * 255.0).round() as u8, - (color.g * 255.0).round() as u8, - (color.b * 255.0).round() as u8, - ]); - match self.bg_picker.take() { - Some(crate::app::BgTarget::Model) => { - Task::done(Message::ModelSpaceBgChanged(hex)) - } - Some(crate::app::BgTarget::Paper) => { - Task::done(Message::PaperSpaceBgChanged(hex)) - } - Some(crate::app::BgTarget::Desk) => { - Task::done(Message::DeskSpaceBgChanged(hex)) - } - None => Task::none(), - } - } - Message::ModelSpaceBgChanged(hex) => { self.model_bg_input = hex.clone(); if hex.trim().is_empty() { @@ -11922,10 +11878,21 @@ impl OpenCADStudio { ) { self.graphic_attributes.open_menu = None; } - self.color_pick_target = Some((target, color)); - - // Always open the shared CAD colour picker on the indexed ACI page. - self.color_picker_tab = crate::app::ColorPickerTab::Index; + 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 by_layer = self.color_picker_layer_color(&picker.target); + 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, + by_layer, + ); + } + self.color_picker = Some(picker); self.modal_offset = iced::Vector::ZERO; self.ds_color_open = None; self.mls_color_open = None; @@ -11940,29 +11907,226 @@ impl OpenCADStudio { Task::none() } - Message::ColorPickerTabChanged(tab) => { - self.color_picker_tab = tab; - Task::none() - } - Message::ColorPickerColorChanged(color) => { - if let Some((_, current)) = self.color_pick_target.as_mut() { - *current = color; + Message::ColorPicker(msg) => { + let Some(picker) = self.color_picker.as_mut() else { + return Task::none(); + }; + match picker.update(msg) { + Some(crate::ui::color_select::Outcome::Commit(color, name)) => { + self.update(Message::ColorWindowPick(color, name)) + } + Some(crate::ui::color_select::Outcome::LoadBook) => Task::perform( + crate::ui::color_select::book::load_from_dialog(), + Message::ColorBookLoaded, + ), + Some(crate::ui::color_select::Outcome::Cancel) => { + self.color_picker = None; + Task::none() + } + Some(crate::ui::color_select::Outcome::Screenshot) => { + use crate::ui::color_select::{eyedropper::EyedropperMsg, ColorPickerMsg}; + match self.main_window { + Some(id) => iced::window::screenshot(id).map(|shot| { + Message::ColorPicker(ColorPickerMsg::Eyedropper( + EyedropperMsg::Shot(shot), + )) + }), + // Nothing to capture: leave the eyedropper instead + // of waiting for a screenshot that never comes. + None => self.update(Message::ColorPicker(ColorPickerMsg::Cancel)), + } + } + None => Task::none(), } - Task::none() } Message::CloseColorPicker => { - self.color_pick_target = None; + self.color_picker = None; Task::none() } - Message::ColorWindowPick(color) => { - self.note_recent_color(color); - self.on_color_window_pick(color) + Message::ColorWindowPick(color, name) => { + // Interface colours are not drawing colours: keep them out of + // the recent list. + if !matches!( + self.color_picker.as_ref().map(|picker| &picker.target), + Some(crate::app::ColorPickTarget::Interface(_)) + ) { + self.note_recent_color(color); + } + self.on_color_window_pick(color, name) + } + Message::ColorBookLoaded(Ok(book)) => { + // A book loaded again replaces the earlier copy. + let index = crate::ui::color_select::book::add_or_replace( + std::sync::Arc::make_mut(&mut self.color_books), + book, + ); + 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), } } + /// 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, 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::Ribbon => { + return (color, false, self.ribbon.active_color_name()); + } + crate::app::ColorPickTarget::Layer(layer) => { + let name = document + .layers + .get(layer) + .and_then(crate::ui::color_select::document::layer_identity); + return (color, false, name); + } + _ => return (color, false, None), + } + let mut colors = self + .property_target_handles(i) + .into_iter() + .filter_map(|handle| { + document + .get_entity(handle) + .map(|entity| (handle, entity.common().color)) + }) + .collect::>(); + let Some(&(first_handle, first)) = colors.first() else { + return (color, false, None); + }; + if colors.iter().all(|&(_, c)| c == first) { + return (first, false, name_of(first_handle)); + } + colors.sort_by_key(|&(handle, _)| handle); + let (newest_handle, newest) = colors.last().copied().unwrap_or((first_handle, color)); + (newest, true, name_of(newest_handle)) + } + + /// The colour ByLayer stands for in the Select Color panel: the layer of + /// the newest selected object, or the current layer for new objects. + fn color_picker_layer_color( + &self, + target: &crate::app::ColorPickTarget, + ) -> Option { + let i = self.active_tab; + let document = &self.tabs[i].scene.document; + let layer = match target { + crate::app::ColorPickTarget::Properties => self + .property_target_handles(i) + .into_iter() + .max() + .and_then(|handle| document.get_entity(handle)) + .map_or(self.tabs[i].active_layer.as_str(), |entity| { + entity.as_entity().layer() + }), + crate::app::ColorPickTarget::Ribbon => self.tabs[i].active_layer.as_str(), + _ => return None, + }; + document.layers.get(layer).map(|layer| layer.color) + } + + /// Read the built-in colour book and every `.acb` in the colour book + /// folder, once per session. A book in the folder replaces a built-in + /// one of the same name. + fn read_color_books(&mut self) { + if std::mem::replace(&mut self.color_books_read, true) { + return; + } + self.color_books = std::sync::Arc::new(crate::ui::color_select::book::read_all()); + } + + /// 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.ribbon.active_color_book = name.map(|name| (color, name)); + 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) { // Keep only real colours in the recent list. ByLayer / ByBlock / None // are logical CAD states rather than reusable colours. @@ -11971,12 +12135,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 8ffb8f908..b3daf77d8 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -1136,42 +1136,60 @@ 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() + .map(|picker| picker.target.clone()) + { + self.color_picker = None; + let Some((r, g, b)) = crate::ui::color_select::math::color_rgb(color) else { + return Task::none(); + }; + // Hand the result to the same handler the typed hex field uses, so + // the picker and the field cannot drift apart on validation or + // persistence. + let hex = crate::app::config::rgb_to_hex([r, g, b]); + use crate::app::UiColor; + return Task::done(match target { + UiColor::ModelBackground => Message::ModelSpaceBgChanged(hex), + UiColor::PaperBackground => Message::PaperSpaceBgChanged(hex), + UiColor::DeskBackground => Message::DeskSpaceBgChanged(hex), + UiColor::Theme(index) => Message::OptionsThemeColorChanged(index, hex), + UiColor::Crosshair => Message::CrosshairColorChanged(hex), + UiColor::SnapMarker => Message::SnapMarkerColorChanged(hex), + UiColor::CommandText => Message::CommandTextColorChanged(hex), + }); + } if matches!( - self.color_pick_target.as_ref().map(|(target, _)| target), + self.color_picker.as_ref().map(|picker| &picker.target), Some(crate::app::ColorPickTarget::PlotStyle) ) { - self.color_pick_target = None; + self.color_picker = None; - let rgb = match color { - codec::types::Color::Rgb { r, g, b } => Some((r, g, b)), - codec::types::Color::Index(index) => codec::types::aci_table::aci_to_rgb(index), - _ => None, - }; - - if let Some((r, g, b)) = rgb { - self.ps_color_buf = format!("#{r:02X}{g:02X}{b:02X}"); + if let Some((r, g, b)) = crate::ui::color_select::math::color_rgb(color) { + self.ps_color_buf = crate::ui::color_select::math::hex_string(r, g, b); } return self.on_plot_style_panel_apply(); } let s = crate::ui::color_select::color_to_aci_string(color); - let edit = match self.color_pick_target.take().map(|(target, _)| target) { + let edit = match self.color_picker.take().map(|picker| picker.target) { Some(crate::app::ColorPickTarget::Gradient(index)) => { Some(Message::GraphicAttributes(GraphicAttributesMsg::Gradient( crate::ui::window::gradient_editor::GradientMsg::Color(index, color), ))) } Some(crate::app::ColorPickTarget::ViewBackground(slot)) => { - let rgb = match color { - codec::types::Color::Rgb { r, g, b } => Some([r, g, b]), - codec::types::Color::Index(index) => { - codec::types::aci_table::aci_to_rgb(index).map(|(r, g, b)| [r, g, b]) - } - _ => None, - }; - rgb.map(|rgb| { - Message::PdfDialog(crate::ui::window::pdf_dialogs::PdfDialogMsg::BgColor(slot, rgb)) + crate::ui::color_select::math::color_rgb(color).map(|(r, g, b)| { + Message::PdfDialog(crate::ui::window::pdf_dialogs::PdfDialogMsg::BgColor( + slot, + [r, g, b], + )) }) } Some(crate::app::ColorPickTarget::GraphicAttributesLine) => Some( @@ -1189,7 +1207,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)) } @@ -1197,13 +1217,17 @@ impl OpenCADStudio { Some(Message::PropColorFieldChanged { field, color }) } 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::Ribbon) => { + let task = self.update(Message::RibbonColorChanged(color)); + self.ribbon.active_color_book = name.map(|name| (color, name)); + return task; + } + 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, } @@ -1211,7 +1235,10 @@ impl OpenCADStudio { Some(crate::app::ColorPickTarget::LayerState(idx)) => { Some(Message::LayerStateEditorLayerColor(idx, color)) } - Some(crate::app::ColorPickTarget::PlotStyle) => None, + // Handled before this match. + Some( + crate::app::ColorPickTarget::PlotStyle | crate::app::ColorPickTarget::Interface(_), + ) => None, None => None, }; if let Some(m) = edit { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 1925b69ab..00e68266f 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1144,7 +1144,7 @@ impl OpenCADStudio { p: Point, expected_viewport: Option, ) -> Task { - if self.color_pick_target.is_some() { + if self.color_picker.is_some() { return Task::none(); } @@ -1232,7 +1232,7 @@ impl OpenCADStudio { // leaks through the stack to the pane mouse_area beneath and // would track the crosshair over the dropdown's empty areas. // Drop the move here instead. (#227) - if self.ribbon.open_dropdown.is_some() || self.color_pick_target.is_some() { + if self.ribbon.open_dropdown.is_some() || self.color_picker.is_some() { return Task::none(); } let i = self.active_tab; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 7ef9eafa3..6473c1c65 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2303,7 +2303,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()) }; @@ -2431,42 +2431,20 @@ bg={bg_ms:.1}ms n={view_count}" } } - if let Some((_, current)) = self.color_pick_target.as_ref() { - match self.color_picker_tab { - super::ColorPickerTab::Index => { - let content = - crate::ui::color_select::index_color_page(*current, &self.recent_colors); - - crate::ui::modal::modal( - base, - t!("Select Color"), - content, - Message::CloseColorPicker, - self.modal_offset, - crate::ui::modal::ModalOptions::NOTICE, - ) - } - - super::ColorPickerTab::TrueColor => { - let initial = crate::ui::properties::acad_color_display(*current).0; - let modal_base = crate::ui::modal::backdrop(base, Message::CloseColorPicker); - - iced_aw::ColorPicker::new( - true, - initial, - modal_base, - // Cancel inside True Color returns to the indexed page instead - // of closing the whole CAD colour picker. - Message::ColorPickerTabChanged(super::ColorPickerTab::Index), - |color| { - Message::ColorWindowPick(crate::ui::color_select::iced_to_acad_color( - color, - )) - }, - ) - .into() - } + if let Some(picker) = self.color_picker.as_ref() { + // While the eyedropper is picking, the panel steps aside so the + // whole window can be sampled. + if let Some(eyedropper) = picker.eyedropper_view() { + return iced::widget::stack![base, eyedropper].into(); } + crate::ui::modal::modal( + base, + t!("Select Color"), + picker.view(), + Message::CloseColorPicker, + self.modal_offset, + crate::ui::modal::ModalOptions::NOTICE, + ) } else { base } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index f3c9505b7..007c5e4ec 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -373,6 +373,8 @@ impl OpenCADStudio { .map(|_| std::env::temp_dir().display().to_string()), fonts: crate::io::font_repo::fonts_dir() .map(|p| p.display().to_string()), + color_books: crate::ui::color_select::book::folder() + .map(|p| p.display().to_string()), } } #[cfg(target_arch = "wasm32")] @@ -395,7 +397,6 @@ impl OpenCADStudio { &self.model_bg_input, &self.paper_bg_input, &self.desk_bg_input, - self.bg_picker, dirty, close_confirm, flow, diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index dcb5118ff..45b4b973a 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -4931,15 +4931,31 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Dismiss" => Some(("common", "dismiss")), "?" => Some(("common", "value-8a8de8")), "Select Color..." => Some(("common", "select-color-8dddf1")), - "No recent colors yet" => Some(("common", "no-recent-colors-yet")), - "True Color RGB {r}, {g}, {b}" => Some(("common", "true-color-rgb")), "Index Color" => Some(("common", "index-color")), "True Color" => Some(("common", "true-color")), "Standard colors" => Some(("common", "standard-colors")), + "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")), + "Name A–Z" => Some(("common", "color-name-az")), + "Name Z–A" => Some(("common", "color-name-za")), + "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")), + "Color book locations" => Some(("common", "color-book-locations")), "CAD Color Index (ACI) 10–249" => Some(("common", "cad-color-index-aci-10-249")), - "Color family → · Shade / intensity ↓" => Some(("common", "color-family-shade-intensity")), "Grayscale 250–255" => Some(("common", "grayscale-250-255")), - "Recently used" => Some(("common", "recently-used")), "Isometric drafting" => Some(("common", "isometric-drafting")), "F5 cycles the active plane." => Some(("common", "f5-cycles-the-active-plane")), "Units…" => Some(("common", "units-5430a1")), 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..cff68b5b6 --- /dev/null +++ b/src/ui/color_select/book.rs @@ -0,0 +1,251 @@ +//! 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) + } +} + +/// Add `book`, replacing one of the same name; returns where it now is. +pub fn add_or_replace(books: &mut Vec, book: ColorBook) -> usize { + match books.iter().position(|b| b.name == book.name) { + Some(index) => { + books[index] = book; + index + } + None => { + books.push(book); + books.len() - 1 + } + } +} + +/// The colour book that ships with the program; its licence is in the +/// file's header comment. +pub fn builtin() -> impl Iterator { + parse(include_str!("../../../assets/color_books/ral-classic.acb")).into_iter() +} + +/// Where colour books are kept: `color_books` in the config folder. The +/// web has no folders. +#[cfg(not(target_arch = "wasm32"))] +pub fn folder() -> Option { + crate::config::config_dir().map(|dir| dir.join("color_books")) +} + +/// The built-in book plus every `.acb` file in the colour book folder, in +/// file-name order. A book replaces an earlier one of the same name. +pub fn read_all() -> Vec { + #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] + let mut books: Vec<_> = builtin().collect(); + #[cfg(not(target_arch = "wasm32"))] + if let Some(dir) = folder() { + let mut files: Vec<_> = std::fs::read_dir(dir) + .into_iter() + .flatten() + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| { + path.extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("acb")) + }) + .collect(); + files.sort(); + for book in files + .into_iter() + .filter_map(|path| std::fs::read_to_string(path).ok()) + .filter_map(|text| parse(&text).ok()) + { + add_or_replace(&mut books, book); + } + } + books +} + +/// Ask for an `.acb` file and read it. On desktop the file is copied into +/// the colour book folder, so it stays when the original moves; the web +/// keeps the book for the session only. +pub async fn load_from_dialog() -> Result { + 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 bytes = file.read().await; + let book = parse(&String::from_utf8_lossy(&bytes))?; + #[cfg(not(target_arch = "wasm32"))] + if let Some(folder) = folder() { + std::fs::create_dir_all(&folder) + .and_then(|()| std::fs::write(folder.join(file.file_name()), &bytes)) + .map_err(|e| e.to_string())?; + } + Ok(book) +} + +/// 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..df024e6a4 --- /dev/null +++ b/src/ui/color_select/document.rs @@ -0,0 +1,247 @@ +//! 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 name in the list view: the colour-book colour's own name (without + /// the book), `ACI n` or `#RRGGBB`. + pub fn label(&self) -> String { + match &self.name { + Some(name) => name + .split_once('$') + .map_or(name.as_str(), |(_, name)| name) + .to_string(), + None => math::short_name(self.color), + } + } +} + +/// Swatches or a list of named bars. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DocView { + #[default] + Grid, + List, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DocSort { + #[default] + Recent, + Usage, + Hue, + Number, + NameAsc, + NameDesc, +} + +impl DocSort { + pub const ALL: [DocSort; 6] = [ + DocSort::Recent, + DocSort::Usage, + DocSort::Hue, + DocSort::Number, + DocSort::NameAsc, + DocSort::NameDesc, + ]; +} + +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"), + DocSort::NameAsc => crate::t!("Name A–Z"), + DocSort::NameDesc => crate::t!("Name Z–A"), + }) + } +} + +/// 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, + } +} + +/// A layer's colour-book identity, `BOOK$NAME`. Layers keep the book and +/// the colour name apart; objects store them joined like this. +pub fn layer_identity(layer: &codec::tables::Layer) -> Option { + match (&layer.book_name, &layer.color_name) { + (Some(book), Some(name)) => Some(format!("{book}${name}")), + (None, Some(name)) => Some(name.clone()), + _ => 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 { + let layer_names: Vec> = document.layers.iter().map(layer_identity).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]) { + match by { + // The rank is looked up once per colour, not on every comparison. + DocSort::Recent => colors.sort_by_cached_key(|c| { + let rank = recent + .iter() + .position(|&r| r == c.color) + .unwrap_or(usize::MAX); + (rank, std::cmp::Reverse(c.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))) + }), + DocSort::NameAsc => colors.sort_by_cached_key(|c| c.label().to_lowercase()), + DocSort::NameDesc => { + colors.sort_by_cached_key(|c| std::cmp::Reverse(c.label().to_lowercase())) + } + } +} + +#[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].label(), "RAL 5015"); + assert_eq!(colors[0].label(), "ACI 1"); + assert_eq!(colors[1].label(), "#39BFFC"); + } + + #[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/editor.rs b/src/ui/color_select/editor.rs new file mode 100644 index 000000000..90604ee01 --- /dev/null +++ b/src/ui/color_select/editor.rs @@ -0,0 +1,506 @@ +//! The colour editor: RGB, HSB or CMYK sliders above a saturation/brightness +//! field with a hue strip, plus a hex box. Shared by the CAD Select Color +//! panel and the interface colour picker. + +use super::math::{self, Hsb}; +use super::widgets::ColorArea; +use super::ColorPickerMsg; +use crate::app::Message; +use crate::t; +use iced::widget::{ + button, column, container, row, slider, stack, text, text_input, vertical_slider, Space, +}; +use iced::{Background, Border, Color, Element, Gradient, Length, Radians, Theme}; + +const ROW_HEIGHT: f32 = 26.0; +const TRACK: f32 = 18.0; +const STRIP_WIDTH: f32 = 22.0; +const ROW_GAP: f32 = 6.0; +/// The field's height in the interface picker, which has no fixed height. +const FIELD_HEIGHT: f32 = 96.0; +const MODE_WIDTH: f32 = 48.0; +const MODE_HEIGHT: f32 = 24.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum EditorMode { + #[default] + Rgb, + Hsb, + Cmyk, +} + +#[derive(Debug, Clone)] +pub enum EditorMsg { + Mode(EditorMode), + Hue(f32), + /// Saturation in percent. + Saturation(f32), + /// Brightness in percent. + Brightness(f32), + /// Saturation and brightness from the field, both 0–1. + Field(f32, f32), + Red(u8), + Green(u8), + Blue(u8), + /// One CMYK channel (0 cyan … 3 black), in percent. + Cmyk(usize, f32), + HexInput(String), + HexSubmit, +} + +pub struct ColorEditor { + mode: EditorMode, + /// Kept beside `rgb` so hue and saturation survive grays and black. + hsb: Hsb, + /// Kept too, so black keeps its inks and rounding doesn't move them. + cmyk: [f32; 4], + rgb: (u8, u8, u8), + hex_draft: Option, +} + +impl ColorEditor { + pub fn new(rgb: (u8, u8, u8)) -> Self { + let mut editor = Self { + mode: EditorMode::default(), + hsb: Hsb { + h: 0.0, + s: 0.0, + b: 1.0, + }, + cmyk: [0.0; 4], + rgb, + hex_draft: None, + }; + editor.set_rgb(rgb); + editor + } + + pub fn rgb(&self) -> (u8, u8, u8) { + self.rgb + } + + pub fn set_rgb(&mut self, (r, g, b): (u8, u8, u8)) { + let next = math::rgb_to_hsb(r, g, b); + // A gray has no hue and black no saturation; keep the previous ones + // so the sliders and field don't jump. + let gray = next.s == 0.0 || next.b == 0.0; + self.hsb = Hsb { + h: if gray { self.hsb.h } else { next.h }, + s: if next.b == 0.0 { self.hsb.s } else { next.s }, + b: next.b, + }; + self.cmyk = math::rgb_to_cmyk(r, g, b); + self.rgb = (r, g, b); + self.hex_draft = None; + } + + fn set_hsb(&mut self, hsb: Hsb) { + self.hsb = Hsb { + h: hsb.h.clamp(0.0, 360.0), + s: hsb.s.clamp(0.0, 1.0), + b: hsb.b.clamp(0.0, 1.0), + }; + self.rgb = self.hsb.to_rgb(); + self.cmyk = math::rgb_to_cmyk(self.rgb.0, self.rgb.1, self.rgb.2); + self.hex_draft = None; + } + + fn set_cmyk(&mut self, channel: usize, value: f32) { + let mut cmyk = self.cmyk; + cmyk[channel.min(3)] = value.clamp(0.0, 100.0); + self.set_rgb(math::cmyk_to_rgb(cmyk)); + self.cmyk = cmyk; + } + + /// Apply a message; returns whether the colour changed. + pub fn update(&mut self, msg: EditorMsg) -> bool { + let before = self.rgb; + let (r, g, b) = self.rgb; + match msg { + EditorMsg::Mode(mode) => self.mode = mode, + EditorMsg::Hue(h) => self.set_hsb(Hsb { h, ..self.hsb }), + EditorMsg::Saturation(s) => self.set_hsb(Hsb { + s: s / 100.0, + ..self.hsb + }), + EditorMsg::Brightness(v) => self.set_hsb(Hsb { + b: v / 100.0, + ..self.hsb + }), + EditorMsg::Field(s, b) => self.set_hsb(Hsb { s, b, ..self.hsb }), + EditorMsg::Red(v) => self.set_rgb((v, g, b)), + EditorMsg::Green(v) => self.set_rgb((r, v, b)), + EditorMsg::Blue(v) => self.set_rgb((r, g, v)), + EditorMsg::Cmyk(channel, v) => self.set_cmyk(channel, v), + EditorMsg::HexInput(value) => { + // Six digits are unambiguous, so apply them while typing; the + // short #RGB form waits for Enter. + let complete = value.trim().trim_start_matches('#').len() == 6; + if let Some(rgb) = math::parse_hex(&value).filter(|_| complete) { + self.set_rgb(rgb); + } + self.hex_draft = Some(value); + } + EditorMsg::HexSubmit => { + if let Some(rgb) = self.hex_draft.take().and_then(|d| math::parse_hex(&d)) { + self.set_rgb(rgb); + } + } + } + self.rgb != before + } + + /// `mode_end` sits at the far right of the mode buttons (the pipette); + /// `hex_end` at the far right of the hex row (the nearest index colour). + /// With `fill` the field takes all the height the sliders leave, so the + /// hex row stays at the bottom and CMYK's fourth slider only makes the + /// field a little lower. + pub fn view<'a>( + &'a self, + mode_end: Option>, + hex_end: Option>, + fill: bool, + ) -> Element<'a, Message> { + let height = if fill { + Length::Fill + } else { + Length::Fixed(FIELD_HEIGHT) + }; + column![ + self.mode_row(mode_end), + self.slider_rows(), + self.field(height), + self.hex_row(hex_end), + ] + .spacing(12) + .into() + } + + /// RGB, HSB and CMYK, with `end` at the far right. + fn mode_row<'a>(&self, end: Option>) -> Element<'a, Message> { + let mode_button = |label: &'static str, mode: EditorMode| { + let active = self.mode == mode; + button(container(text(label).size(13)).center(Length::Fill)) + .on_press(msg(EditorMsg::Mode(mode))) + .width(MODE_WIDTH) + .height(MODE_HEIGHT) + .padding(0) + .style(move |theme: &Theme, status| { + super::picker::toggle_style(theme, active, status) + }) + }; + let mut modes = row![ + mode_button("RGB", EditorMode::Rgb), + mode_button("HSB", EditorMode::Hsb), + mode_button("CMYK", EditorMode::Cmyk), + Space::new().width(Length::Fill), + ] + .spacing(6); + if let Some(end) = end { + modes = modes.push(end); + } + modes.into() + } + + /// Gradient sliders: red, green and blue; hue, saturation and + /// brightness; or cyan, magenta, yellow and black. + fn slider_rows<'a>(&self) -> Element<'a, Message> { + let Hsb { h, s, b } = self.hsb; + let (r, g, bl) = self.rgb; + let rows = if self.mode == EditorMode::Cmyk { + let ink = |i: usize, letter| { + let at = |v| { + let mut cmyk = self.cmyk; + cmyk[i] = v; + rgb_color(math::cmyk_to_rgb(cmyk)) + }; + channel( + letter, + self.cmyk[i], + 100.0, + Some('%'), + vec![(0.0, at(0.0)), (1.0, at(100.0))], + move |v| EditorMsg::Cmyk(i, v), + ) + }; + column![ink(0, "C"), ink(1, "M"), ink(2, "Y"), ink(3, "K")] + } else if self.mode == EditorMode::Rgb { + let ends = |lo: (u8, u8, u8), hi: (u8, u8, u8)| { + vec![(0.0, rgb_color(lo)), (1.0, rgb_color(hi))] + }; + let byte = |f: fn(u8) -> EditorMsg| move |v: f32| f(v.round() as u8); + column![ + channel( + "R", + f32::from(r), + 255.0, + None, + ends((0, g, bl), (255, g, bl)), + byte(EditorMsg::Red) + ), + channel( + "G", + f32::from(g), + 255.0, + None, + ends((r, 0, bl), (r, 255, bl)), + byte(EditorMsg::Green) + ), + channel( + "B", + f32::from(bl), + 255.0, + None, + ends((r, g, 0), (r, g, 255)), + byte(EditorMsg::Blue) + ), + ] + } else { + column![ + channel("H", h, 360.0, Some('°'), hue_stops(), EditorMsg::Hue), + channel( + "S", + s * 100.0, + 100.0, + Some('%'), + vec![(0.0, hsb_color(h, 0.0, b)), (1.0, hsb_color(h, 1.0, b))], + EditorMsg::Saturation, + ), + channel( + "B", + b * 100.0, + 100.0, + Some('%'), + vec![(0.0, Color::BLACK), (1.0, hsb_color(h, s, 1.0))], + EditorMsg::Brightness, + ), + ] + }; + rows.spacing(ROW_GAP).into() + } + + /// The saturation/brightness field with its hue strip. + fn field<'a>(&self, height: Length) -> Element<'a, Message> { + let Hsb { h, s, b } = self.hsb; + let area = ColorArea::new(hsb_color(h, 1.0, 1.0), rgb_color(self.rgb), s, b, |s, b| { + msg(EditorMsg::Field(s, b)) + }) + .height(height); + // A vertical slider grows upwards; the strip runs red at the top. + let strip = stack![ + container(Space::new()) + .width(STRIP_WIDTH) + .height(height) + .style(|_theme: &Theme| gradient_box(std::f32::consts::PI, &hue_stops())), + vertical_slider(0.0..=360.0, 360.0 - h, |v| msg(EditorMsg::Hue(360.0 - v))) + .width(STRIP_WIDTH) + .height(height) + .style(handle_style), + ]; + row![area, strip].spacing(12).into() + } + + /// The hex value with `end` (the nearest index colour) at the far right. + fn hex_row<'a>(&self, end: Option>) -> Element<'a, Message> { + let (r, g, b) = self.rgb; + let hex = self + .hex_draft + .clone() + .unwrap_or_else(|| math::hex_string(r, g, b)); + let mut hex_row = row![ + text(t!("Hex")).size(11).style(super::picker::muted_text), + text_input("", &hex) + .on_input(|value| msg(EditorMsg::HexInput(value))) + .on_submit(msg(EditorMsg::HexSubmit)) + .size(12) + .font(iced::Font::MONOSPACE) + .padding([5, 6]) + .width(Length::Fixed(74.0)), + Space::new().width(Length::Fill), + ] + .spacing(8) + .align_y(iced::Center); + if let Some(end) = end { + hex_row = hex_row.push(end); + } + hex_row.into() + } +} + +fn msg(editor: EditorMsg) -> Message { + Message::ColorPicker(ColorPickerMsg::Editor(editor)) +} + +/// One labelled channel: a gradient slider and its number box. +fn channel<'a>( + letter: &'static str, + value: f32, + max: f32, + unit: Option, + stops: Vec<(f32, Color)>, + on_change: impl Fn(f32) -> EditorMsg + Copy + 'static, +) -> Element<'a, Message> { + // iced's slider splits its own rail at the handle, which would squeeze a + // gradient; draw the gradient underneath and keep the rail transparent. + let track = container( + container(Space::new()) + .width(Length::Fill) + .height(TRACK) + .style(move |_theme: &Theme| gradient_box(std::f32::consts::FRAC_PI_2, &stops)), + ) + .height(ROW_HEIGHT) + .center_y(ROW_HEIGHT); + row![ + container(text(letter).size(12).font(iced::Font::MONOSPACE)) + .width(12) + .align_x(iced::Center), + stack![ + track, + slider(0.0..=max, value, move |v| msg(on_change(v))) + .height(ROW_HEIGHT) + .shift_step(10.0) + .style(handle_style), + ], + number_box(value, max, unit, on_change), + ] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// A compact spin box with the unit drawn inside, after the digits. +fn number_box( + value: f32, + max: f32, + unit: Option, + on_change: impl Fn(f32) -> EditorMsg + Copy + 'static, +) -> Element<'static, Message> { + let input = + iced_aw::widget::NumberInput::new(&(value.round() as u16), 0..=max as u16, move |v| { + msg(on_change(f32::from(v))) + }) + .set_size(12.0) + .font(iced::Font::MONOSPACE) + .width(Length::Fixed(54.0)) + // Padding under 5 would make NumberInput swap its arrows for + / −; + // the extra right padding keeps the digits clear of the arrows. + .padding(iced::Padding { + top: 5.0, + right: 18.0, + bottom: 5.0, + left: 2.0, + }) + .align_x(iced::alignment::Horizontal::Right) + .style( + |theme: &Theme, _status| iced_aw::style::number_input::Style { + button_background: None, + icon_color: theme.palette().background.base.text.scale_alpha(0.75), + }, + ); + match unit { + Some(code_point) => input + .icon(text_input::Icon { + font: iced::Font::MONOSPACE, + code_point, + size: Some(iced::Pixels(12.0)), + spacing: 1.0, + side: text_input::Side::Right, + }) + .into(), + None => input.into(), + } +} + +/// Transparent rail with a white bar handle, for both slider directions. +fn handle_style(_theme: &Theme, _status: slider::Status) -> slider::Style { + slider::Style { + rail: slider::Rail { + backgrounds: (Color::TRANSPARENT.into(), Color::TRANSPARENT.into()), + width: TRACK, + border: Border::default(), + }, + handle: slider::Handle { + shape: slider::HandleShape::Rectangle { + width: 8, + border_radius: 3.0.into(), + }, + background: Color::WHITE.into(), + border_width: 1.0, + border_color: Color::from_rgba(0.0, 0.0, 0.0, 0.4), + }, + } +} + +/// A rounded box filled with a linear gradient. Angles run clockwise from +/// "bottom to top": a quarter turn is left to right, a half turn top to bottom. +fn gradient_box(angle: f32, stops: &[(f32, Color)]) -> container::Style { + let linear = stops.iter().fold( + iced::gradient::Linear::new(Radians(angle)), + |linear, &(at, color)| linear.add_stop(at, color), + ); + container::Style { + background: Some(Background::Gradient(Gradient::Linear(linear))), + border: Border { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.35), + width: 1.0, + radius: 5.0.into(), + }, + ..Default::default() + } +} + +fn hue_stops() -> Vec<(f32, Color)> { + (0..=6) + .map(|step| { + let at = step as f32 / 6.0; + (at, hsb_color(at * 360.0, 1.0, 1.0)) + }) + .collect() +} + +fn hsb_color(h: f32, s: f32, b: f32) -> Color { + rgb_color(math::hsb_to_rgb(h, s, b)) +} + +pub(super) fn rgb_color((r, g, b): (u8, u8, u8)) -> Color { + Color::from_rgb8(r, g, b) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sliders_produce_colours() { + let mut e = ColorEditor::new((255, 0, 0)); + assert!(e.update(EditorMsg::Hue(120.0))); + assert_eq!(e.rgb(), (0, 255, 0)); + e.update(EditorMsg::Red(10)); + assert_eq!(e.rgb(), (10, 255, 0)); + assert!(!e.update(EditorMsg::Mode(EditorMode::Rgb))); + } + + #[test] + fn hue_survives_a_trip_through_gray() { + let mut e = ColorEditor::new((0, 0, 255)); + e.update(EditorMsg::Saturation(0.0)); + e.update(EditorMsg::Saturation(100.0)); + assert_eq!(e.rgb(), (0, 0, 255)); + } + + #[test] + fn hex_applies_six_digits_while_typing() { + let mut e = ColorEditor::new((0, 0, 0)); + assert!(!e.update(EditorMsg::HexInput("#39BF".into()))); + assert!(e.update(EditorMsg::HexInput("#39BFFC".into()))); + assert_eq!(e.rgb(), (57, 191, 252)); + } + + #[test] + fn short_hex_waits_for_enter() { + let mut e = ColorEditor::new((0, 0, 0)); + assert!(!e.update(EditorMsg::HexInput("#3bf".into()))); + assert!(e.update(EditorMsg::HexSubmit)); + assert_eq!(e.rgb(), (0x33, 0xBB, 0xFF)); + } +} diff --git a/src/ui/color_select/eyedropper.rs b/src/ui/color_select/eyedropper.rs new file mode 100644 index 000000000..2d24822b9 --- /dev/null +++ b/src/ui/color_select/eyedropper.rs @@ -0,0 +1,184 @@ +//! Pick a colour from anywhere inside the application window. +//! +//! While active, a transparent layer covers the window with a crosshair. The +//! first mouse move after the panel is hidden asks iced for a screenshot of +//! the window; a magnifier beside the cursor shows the pixels around it from +//! that screenshot, and a click picks the centre pixel. + +use super::editor::rgb_color; +use super::{math, ColorPickerMsg}; +use crate::app::Message; +use iced::widget::{column, container, mouse_area, pin, row, stack, text, Space}; +use iced::window::Screenshot; +use iced::{Border, Color, Element, Length, Point, Theme}; + +/// Pixels shown across the magnifier (odd, so one sits in the centre). +const GRID: i32 = 11; +const CELL: f32 = 10.0; +/// Gap between the cursor and the magnifier. +const OFFSET: f32 = 20.0; + +#[derive(Debug, Clone)] +pub enum EyedropperMsg { + Start, + Move(Point), + Shot(Screenshot), + Pick, +} + +#[derive(Default)] +pub struct Eyedropper { + shot: Option, + requested: bool, + cursor: Option, +} + +impl Eyedropper { + /// Track the cursor. Returns true when the window screenshot should be + /// requested now — the panel is gone from the screen by then. + pub fn track(&mut self, cursor: Point) -> bool { + self.cursor = Some(cursor); + let request = self.shot.is_none() && !self.requested; + self.requested = true; + request + } + + pub fn set_shot(&mut self, shot: Screenshot) { + self.shot = Some(shot); + } + + /// The pixel under the cursor, once the screenshot has arrived. + pub fn picked(&self) -> Option<(u8, u8, u8)> { + sample(self.shot.as_ref()?, self.cursor?, 0, 0) + } + + pub fn view(&self) -> Element<'_, Message> { + let eye = |m| Message::ColorPicker(ColorPickerMsg::Eyedropper(m)); + let catcher = mouse_area(Space::new().width(Length::Fill).height(Length::Fill)) + .on_move(move |p| eye(EyedropperMsg::Move(p))) + .on_press(eye(EyedropperMsg::Pick)) + .interaction(iced::mouse::Interaction::Crosshair); + + let (Some(shot), Some(cursor)) = (self.shot.as_ref(), self.cursor) else { + return catcher.into(); + }; + + let half = GRID / 2; + let mut grid = column![]; + for dy in -half..=half { + let mut line = row![]; + for dx in -half..=half { + let fill = sample(shot, cursor, dx, dy).map_or(Color::TRANSPARENT, rgb_color); + let centre = dx == 0 && dy == 0; + line = line.push(container(Space::new()).width(CELL).height(CELL).style( + move |_theme: &Theme| container::Style { + background: Some(fill.into()), + border: Border { + color: if centre { + Color::WHITE + } else { + Color::TRANSPARENT + }, + width: if centre { 1.5 } else { 0.0 }, + radius: 0.0.into(), + }, + ..Default::default() + }, + )); + } + grid = grid.push(line); + } + let (r, g, b) = self.picked().unwrap_or((0, 0, 0)); + let loupe = container( + column![ + grid, + text(math::hex_string(r, g, b)) + .size(11) + .font(iced::Font::MONOSPACE), + ] + .spacing(4) + .align_x(iced::Center), + ) + .padding(4) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(palette.background.base.color.into()), + text_color: Some(palette.background.base.text), + border: Border { + color: palette.background.strong.color, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + } + }); + + // Keep the magnifier on screen: flip it to the other side of the + // cursor near the right and bottom edges. + let size = GRID as f32 * CELL + 8.0; + let window_w = shot.size.width as f32 / shot.scale_factor; + let window_h = shot.size.height as f32 / shot.scale_factor; + // In a very small window neither side fits; stay inside the top-left. + let x = if cursor.x + OFFSET + size > window_w { + cursor.x - OFFSET - size + } else { + cursor.x + OFFSET + } + .max(0.0); + let y = if cursor.y + OFFSET + size + 20.0 > window_h { + cursor.y - OFFSET - size - 20.0 + } else { + cursor.y + OFFSET + } + .max(0.0); + stack![catcher, pin(loupe).x(x).y(y)].into() + } +} + +/// The screenshot pixel `dx`, `dy` physical pixels away from a logical point. +fn sample(shot: &Screenshot, at: Point, dx: i32, dy: i32) -> Option<(u8, u8, u8)> { + let x = (at.x * shot.scale_factor) as i64 + i64::from(dx); + let y = (at.y * shot.scale_factor) as i64 + i64::from(dy); + let (w, h) = (i64::from(shot.size.width), i64::from(shot.size.height)); + if !(0..w).contains(&x) || !(0..h).contains(&y) { + return None; + } + let i = ((y * w + x) * 4) as usize; + let px = shot.rgba.get(i..i + 3)?; + Some((px[0], px[1], px[2])) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn shot() -> Screenshot { + // 4×2 physical pixels at scale 2: pixel (x, y) has red = x, green = y. + let mut rgba = Vec::new(); + for y in 0..2u8 { + for x in 0..4u8 { + rgba.extend([x, y, 9, 255]); + } + } + Screenshot::new(rgba, iced::Size::new(4, 2), 2.0) + } + + #[test] + fn samples_physical_pixels_from_logical_points() { + let s = shot(); + assert_eq!(sample(&s, Point::new(1.0, 0.5), 0, 0), Some((2, 1, 9))); + assert_eq!(sample(&s, Point::new(1.0, 0.5), 1, -1), Some((3, 0, 9))); + assert_eq!(sample(&s, Point::new(1.9, 0.0), 1, 0), None); + } + + #[test] + fn asks_for_one_screenshot_and_picks_after_it_arrives() { + let mut e = Eyedropper::default(); + assert!(e.track(Point::new(0.0, 0.0))); + assert!(!e.track(Point::new(1.0, 0.0))); + assert_eq!(e.picked(), None); + e.set_shot(shot()); + assert_eq!(e.picked(), Some((2, 0, 9))); + } +} diff --git a/src/ui/color_select/math.rs b/src/ui/color_select/math.rs new file mode 100644 index 000000000..b62250425 --- /dev/null +++ b/src/ui/color_select/math.rs @@ -0,0 +1,212 @@ +//! Colour maths and formatting for the Select Color panel. +//! +//! Everything here is pure so it can be unit tested without a UI: HSB/RGB +//! conversion, hex formatting and parsing, nearest-index lookup and the +//! short names the panel shows. + +use codec::types::Color as AcadColor; + +/// Hue in degrees (0–360), saturation and brightness in 0–1. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Hsb { + pub h: f32, + pub s: f32, + pub b: f32, +} + +impl Hsb { + pub fn to_rgb(self) -> (u8, u8, u8) { + hsb_to_rgb(self.h, self.s, self.b) + } +} + +/// Convert hue (degrees), saturation and brightness (0–1) to 8-bit RGB. +pub fn hsb_to_rgb(h: f32, s: f32, b: f32) -> (u8, u8, u8) { + let h = h.rem_euclid(360.0); + let s = s.clamp(0.0, 1.0); + let b = b.clamp(0.0, 1.0); + let channel = |n: f32| { + let k = (n + h / 60.0) % 6.0; + let v = b - b * s * k.min(4.0 - k).clamp(0.0, 1.0); + (v * 255.0).round() as u8 + }; + (channel(5.0), channel(3.0), channel(1.0)) +} + +/// Convert 8-bit RGB to HSB. Grays have no hue; they report 0. +pub fn rgb_to_hsb(r: u8, g: u8, b: u8) -> Hsb { + let (r, g, b) = ( + f32::from(r) / 255.0, + f32::from(g) / 255.0, + f32::from(b) / 255.0, + ); + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let delta = max - min; + let mut h = if delta == 0.0 { + 0.0 + } else if max == r { + 60.0 * (((g - b) / delta) % 6.0) + } else if max == g { + 60.0 * ((b - r) / delta + 2.0) + } else { + 60.0 * ((r - g) / delta + 4.0) + }; + if h < 0.0 { + h += 360.0; + } + let s = if max == 0.0 { 0.0 } else { delta / max }; + Hsb { h, s, b: max } +} + +/// Cyan, magenta, yellow and black in percent, by the plain device-free +/// formula (no ICC profile): screen values, not press values. +pub fn rgb_to_cmyk(r: u8, g: u8, b: u8) -> [f32; 4] { + let max = r.max(g).max(b); + if max == 0 { + return [0.0, 0.0, 0.0, 100.0]; + } + let max = f32::from(max); + let ink = |v: u8| (1.0 - f32::from(v) / max) * 100.0; + [ink(r), ink(g), ink(b), (1.0 - max / 255.0) * 100.0] +} + +pub fn cmyk_to_rgb([c, m, y, k]: [f32; 4]) -> (u8, u8, u8) { + let white = 255.0 * (1.0 - k.clamp(0.0, 100.0) / 100.0); + let channel = |ink: f32| (white * (1.0 - ink.clamp(0.0, 100.0) / 100.0)).round() as u8; + (channel(c), channel(m), channel(y)) +} + +/// RGB of an ACI entry, or `None` outside 1–255. +pub fn aci_rgb(index: u8) -> Option<(u8, u8, u8)> { + codec::types::aci_table::aci_to_rgb(index) +} + +/// The RGB a concrete colour displays as. Logical colours have none. +pub fn color_rgb(color: AcadColor) -> Option<(u8, u8, u8)> { + match color { + AcadColor::Index(i) => aci_rgb(i), + AcadColor::Rgb { r, g, b } => Some((r, g, b)), + _ => None, + } +} + +/// The closest index colour to an RGB value. +pub fn nearest_aci(r: u8, g: u8, b: u8) -> u8 { + codec::types::aci_table::nearest_aci(r, g, b) +} + +/// `#RRGGBB`, upper case — the same format the Options hex fields store. +pub fn hex_string(r: u8, g: u8, b: u8) -> String { + crate::app::config::rgb_to_hex([r, g, b]) +} + +/// Parse `#RRGGBB`, `RRGGBB`, `#RGB` or `RGB`. Unlike `config::parse_hex`, +/// which Options uses, this also accepts the short three-digit form. +pub fn parse_hex(text: &str) -> Option<(u8, u8, u8)> { + let digits = text.trim().trim_start_matches('#'); + if !digits.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let expanded: String = match digits.len() { + 3 => digits.chars().flat_map(|c| [c, c]).collect(), + 6 => digits.to_string(), + _ => return None, + }; + let value = u32::from_str_radix(&expanded, 16).ok()?; + Some(((value >> 16) as u8, (value >> 8) as u8, value as u8)) +} + +/// The short name shown on the Current / New swatch. +pub fn short_name(color: AcadColor) -> String { + match color { + AcadColor::Index(i) => format!("ACI {i}"), + AcadColor::Rgb { r, g, b } => hex_string(r, g, b), + AcadColor::ByLayer => "ByLayer".to_string(), + AcadColor::ByBlock => "ByBlock".to_string(), + AcadColor::None => "None".to_string(), + } +} + +/// One-line description for the panel's info line. +pub fn describe(color: AcadColor) -> String { + match color { + AcadColor::Index(i) => { + let (r, g, b) = aci_rgb(i).unwrap_or((128, 128, 128)); + format!("ACI {i} · RGB {r}, {g}, {b}") + } + AcadColor::Rgb { r, g, b } => format!("RGB {r}, {g}, {b} · {}", hex_string(r, g, b)), + other => short_name(other), + } +} + +/// Black or white text, whichever reads better on the given background. +pub fn ink_is_dark(r: u8, g: u8, b: u8) -> bool { + 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b) > 150.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hsb_round_trips_primary_and_mixed_colours() { + for (r, g, b) in [ + (255, 0, 0), + (0, 255, 0), + (0, 0, 255), + (57, 191, 252), + (120, 200, 80), + (128, 128, 128), + (0, 0, 0), + (255, 255, 255), + ] { + let hsb = rgb_to_hsb(r, g, b); + assert_eq!(hsb.to_rgb(), (r, g, b), "round trip of {r},{g},{b}"); + } + } + + #[test] + fn hue_of_pure_colours() { + assert_eq!(rgb_to_hsb(255, 0, 0).h, 0.0); + assert!((rgb_to_hsb(0, 255, 0).h - 120.0).abs() < 0.01); + assert!((rgb_to_hsb(0, 0, 255).h - 240.0).abs() < 0.01); + assert_eq!(rgb_to_hsb(80, 80, 80).s, 0.0); + } + + #[test] + fn hue_wraps_around() { + assert_eq!(hsb_to_rgb(360.0, 1.0, 1.0), (255, 0, 0)); + assert_eq!(hsb_to_rgb(-120.0, 1.0, 1.0), hsb_to_rgb(240.0, 1.0, 1.0)); + } + + #[test] + fn nearest_aci_comes_from_the_codec_table() { + assert_eq!(nearest_aci(255, 0, 0), 1); + assert_eq!(nearest_aci(250, 5, 5), 1); + } + + #[test] + fn short_names() { + assert_eq!(short_name(AcadColor::Index(140)), "ACI 140"); + assert_eq!( + short_name(AcadColor::Rgb { + r: 57, + g: 191, + b: 252 + }), + "#39BFFC" + ); + assert_eq!(short_name(AcadColor::ByLayer), "ByLayer"); + } + + #[test] + fn hex_formats_and_parses() { + assert_eq!(hex_string(57, 191, 252), "#39BFFC"); + assert_eq!(parse_hex("#39bffc"), Some((57, 191, 252))); + assert_eq!(parse_hex("39BFFC"), Some((57, 191, 252))); + assert_eq!(parse_hex("#3bf"), Some((0x33, 0xBB, 0xFF))); + assert_eq!(parse_hex("#39BFF"), None); + assert_eq!(parse_hex("zzzzzz"), None); + } +} diff --git a/src/ui/color_select.rs b/src/ui/color_select/mod.rs similarity index 58% rename from src/ui/color_select.rs rename to src/ui/color_select/mod.rs index 5b3c775cb..445a52bbc 100644 --- a/src/ui/color_select.rs +++ b/src/ui/color_select/mod.rs @@ -3,6 +3,17 @@ //! 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; +pub mod picker; +pub mod widgets; + +pub use math::nearest_aci; +pub use picker::{ColorPicker, ColorPickerMsg, Outcome}; + use crate::app::Message; use crate::t; use crate::ui::properties::acad_color_display; @@ -34,42 +45,6 @@ pub fn color_to_aci_string(c: AcadColor) -> String { } } -/// Convert an Iced colour chosen by `iced_aw::ColorPicker` into a DWG true -/// colour. ACI-only destinations map it to their closest indexed colour later. -pub fn iced_to_acad_color(color: Color) -> AcadColor { - // Truncate the way the picker labels its channels, so the stored colour - // is the one the dialog showed; rounding drifted dragged values by one - // (#1621). - let byte = |c: f32| (c * 255.0) as u8; - AcadColor::Rgb { - r: byte(color.r), - g: byte(color.g), - b: byte(color.b), - } -} - -/// Return the closest CAD Color Index for an RGB colour. -pub fn nearest_aci(r: u8, g: u8, b: u8) -> u8 { - let mut best = 7; - let mut best_distance = u32::MAX; - - for index in 1..=255 { - let Some((ar, ag, ab)) = codec::types::aci_table::aci_to_rgb(index) else { - continue; - }; - let dr = i32::from(r) - i32::from(ar); - let dg = i32::from(g) - i32::from(ag); - let db = i32::from(b) - i32::from(ab); - let distance = (dr * dr + dg * dg + db * db) as u32; - if distance < best_distance { - best = index; - best_distance = distance; - } - } - - best -} - /// Decode an ACI integer string back into an `AcadColor`. pub fn aci_string_to_color(s: &str) -> AcadColor { match s.trim().parse::().unwrap_or(256) { @@ -349,174 +324,3 @@ pub fn drop_down_below<'a>( } dd.into() } - -/// Full CAD indexed-colour page used by the standalone Select Color dialog. -/// -/// This only edits the pending colour. The caller commits it with -/// `Message::ColorWindowPick`, so choosing a swatch does not immediately close -/// the dialog. -pub fn index_color_page<'a>( - current: AcadColor, - recent_colors: &'a [AcadColor], -) -> Element<'a, Message> { - let swatch_button = |color: AcadColor, size: f32| -> Element<'a, Message> { - let (bg, _) = acad_color_display(color); - let selected = current == color; - - button( - text("") - .width(Length::Fixed(size)) - .height(Length::Fixed(size)), - ) - .on_press(Message::ColorPickerColorChanged(color)) - .style(move |theme: &Theme, status| { - let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed); - - button::Style { - background: Some(Background::Color(bg)), - border: Border { - color: if selected || hovered { - theme.palette().primary.base.color - } else { - theme.palette().background.neutral.color - }, - width: if selected { - 2.5 - } else if hovered { - 1.5 - } else { - 1.0 - }, - radius: 1.0.into(), - }, - ..Default::default() - } - }) - .padding(0) - .into() - }; - - // The first standard indexed CAD colours. - let mut standard = row![].spacing(4); - for idx in 1u8..=9 { - standard = standard.push(swatch_button(AcadColor::Index(idx), 22.0)); - } - - // ACI 10-249. - // - // Each group of ten indices belongs together. Arrange the 24 colour - // families horizontally and their ten shade/intensity variants vertically. - // This makes the palette read as a colour spectrum instead of a raw numeric - // sequence. - let mut palette_rows = column![].spacing(2); - - for variant in 0u8..10 { - let mut palette_row = row![].spacing(2); - - for family in 1u8..=24 { - let idx = family * 10 + variant; - palette_row = palette_row.push(swatch_button(AcadColor::Index(idx), 16.0)); - } - - palette_rows = palette_rows.push(palette_row); - } - - // ACI 250-255 are the grayscale entries and are shown separately. - let mut grayscale = row![].spacing(4); - - for idx in 250u8..=255 { - grayscale = grayscale.push(swatch_button(AcadColor::Index(idx), 22.0)); - } - - let recent: Element<'a, Message> = if recent_colors.is_empty() { - text(crate::t!("No recent colors yet")) - .size(10) - .style(|theme: &Theme| iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.55)), - }) - .into() - } else { - let mut recent_row = row![].spacing(4); - - for &color in recent_colors.iter().take(12) { - recent_row = recent_row.push(swatch_button(color, 22.0)); - } - - recent_row.into() - }; - - let selected_text = match current { - AcadColor::Index(i) => { - let (r, g, b) = codec::types::aci_table::aci_to_rgb(i).unwrap_or((128, 128, 128)); - format!("ACI {i} RGB {r}, {g}, {b}") - } - AcadColor::Rgb { r, g, b } => crate::tf!("True Color RGB {r}, {g}, {b}").into_owned(), - AcadColor::ByLayer => t!("ByLayer").into_owned(), - AcadColor::ByBlock => t!("ByBlock").into_owned(), - AcadColor::None => t!("None").into_owned(), - }; - - let tabs = row![ - button(text(crate::t!("Index Color")).size(12)) - .style(button::primary) - .padding([5, 14]), - button(text(crate::t!("True Color")).size(12)) - .on_press(Message::ColorPickerTabChanged( - crate::app::ColorPickerTab::TrueColor - )) - .style(button::secondary) - .padding([5, 14]), - ] - .spacing(4); - - let actions = container( - row![ - button(text(crate::t!("Cancel")).size(11)) - .on_press(Message::CloseColorPicker) - .style(button::secondary) - .padding([5, 14]), - button(text(crate::t!("OK")).size(11)) - .on_press(Message::ColorWindowPick(current)) - .style(button::primary) - .padding([5, 18]), - ] - .spacing(8), - ) - .width(Length::Fill) - .align_x(iced::alignment::Horizontal::Right); - - container( - column![ - tabs, - text(crate::t!("Standard colors")).size(11), - standard, - text(crate::t!("CAD Color Index (ACI) 10–249")).size(11), - text(crate::t!("Color family → · Shade / intensity ↓")) - .size(9) - .style(|theme: &Theme| iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.65)), - }), - container(palette_rows) - .padding(6) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.weakest.color)), - border: Border { - color: theme.palette().background.neutral.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }), - text(crate::t!("Grayscale 250–255")).size(11), - grayscale, - text(crate::t!("Recently used")).size(11), - recent, - text(selected_text).size(11), - actions, - ] - .spacing(9), - ) - .padding(10) - .width(Length::Fixed(470.0)) - .into() -} diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs new file mode 100644 index 000000000..8c0333820 --- /dev/null +++ b/src/ui/color_select/picker.rs @@ -0,0 +1,1756 @@ +//! The Select Color panel: state, update logic and view. +//! +//! The panel edits a pending colour and only reports it back on OK, so every +//! caller keeps applying colours through the same `ColorWindowPick` path. +//! 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, DocView}; +use super::editor::{rgb_color, ColorEditor, EditorMsg}; +use super::eyedropper::{Eyedropper, EyedropperMsg}; +use super::math; +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, 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 = 368.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; +/// Height of the search boxes, dropdowns and icon buttons. +const CONTROL_HEIGHT: f32 = 28.0; +/// Width of the page strip, the RGB values and the gap between them. +const SIDE_WIDTH: f32 = 26.0 + 10.0 + 104.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ColorPickerTab { + #[default] + Document, + Index, + TrueColor, + Books, +} + +#[derive(Debug, Clone)] +pub enum ColorPickerMsg { + Tab(ColorPickerTab), + /// A swatch was clicked. + Pick(AcadColor), + /// Set the new colour back to the current one. + Revert, + Editor(EditorMsg), + /// Replace the true colour with its nearest index colour. + UseNearest, + Arrow(ArrowKey), + Eyedropper(EyedropperMsg), + /// A colour of the Document tab, by its place in the list. + PickDocument(usize), + DocSort(DocSort), + DocView(DocView), + /// Filter the Document tab by name. + DocQuery(String), + /// 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, PartialEq)] +pub enum Outcome { + /// 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 { + pub target: ColorPickTarget, + /// The colour shown as "Current". For a mixed selection this is the colour + /// of the most recently drawn object. + 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, + doc_view: DocView, + doc_query: String, + /// 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, + /// What ByLayer looks like here: the layer's colour. + by_layer: Option, +} + +impl ColorPicker { + pub fn new(target: ColorPickTarget, current: AcadColor, varies: bool) -> Self { + Self { + target, + current, + varies, + pending: current, + current_name: None, + pending_name: None, + tab: ColorPickerTab::Document, + editor: ColorEditor::new(math::color_rgb(current).unwrap_or((255, 255, 255))), + eyedropper: None, + doc: Vec::new(), + doc_sort: DocSort::default(), + doc_view: DocView::default(), + doc_query: String::new(), + recent: Vec::new(), + books: Arc::default(), + book: 0, + book_page: 0, + book_query: String::new(), + status: None, + by_layer: None, + } + } + + /// Fill the Document and Color books tabs. `current_name` is the + /// `BOOK$NAME` of the current colour; the Color books tab shows its page. + /// `by_layer` is the colour of the layer the colour is for, so ByLayer + /// shows that colour instead of gray. + pub fn with_drawing( + mut self, + mut doc: Vec, + recent: &[AcadColor], + books: Arc>, + current_name: Option, + by_layer: Option, + ) -> Self { + self.by_layer = by_layer; + 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()) { + self.show_identity(&name); + 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`, if a loaded book has it. + fn show_identity(&mut self, identity: &str) { + let Some((book_name, name)) = identity.split_once('$') else { + return; + }; + 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; + } + } + } + + /// The Document tab's colours that match the search, by index in `doc`. + fn doc_visible(&self) -> Vec { + let query = self.doc_query.trim().to_lowercase(); + (0..self.doc.len()) + .filter(|&i| query.is_empty() || contains_ignore_case(&self.doc[i].label(), &query)) + .collect() + } + + /// The shown book's colours on screen, by index in the book: the page, + /// or the first matches while searching. + fn book_visible(&self) -> Vec { + let Some(book) = self.books.get(self.book) else { + return Vec::new(); + }; + let query = self.book_query.trim().to_lowercase(); + if query.is_empty() { + let (start, colors) = book.page(self.book_page); + return (start..start + colors.len()).collect(); + } + (0..book.colors.len()) + .filter(|&i| contains_ignore_case(&book.colors[i].name, &query)) + .take(PAGE_BARS) + .collect() + } + + /// 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 + } + + pub fn tab(&self) -> ColorPickerTab { + self.tab + } + + /// Interface colours (Options) only get the colour editor. + fn editor_only(&self) -> bool { + matches!(self.target, ColorPickTarget::Interface(_)) + } + + 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); + } + } + + fn rgb(&self) -> (u8, u8, u8) { + self.editor.rgb() + } + + pub fn update(&mut self, msg: ColorPickerMsg) -> Option { + // While picking from the screen only the eyedropper listens; Esc + // backs out of it rather than closing the panel. + if let Some(eye) = self.eyedropper.as_mut() { + match msg { + ColorPickerMsg::Eyedropper(EyedropperMsg::Move(p)) => { + return eye.track(p).then_some(Outcome::Screenshot); + } + ColorPickerMsg::Eyedropper(EyedropperMsg::Shot(shot)) => eye.set_shot(shot), + ColorPickerMsg::Eyedropper(EyedropperMsg::Pick) => { + if let Some((r, g, b)) = eye.picked() { + self.eyedropper = None; + self.set_pending(AcadColor::Rgb { r, g, b }); + } + } + // Esc leaves the eyedropper but keeps the panel open. + ColorPickerMsg::Cancel => self.eyedropper = None, + _ => {} + } + return None; + } + match msg { + ColorPickerMsg::Tab(tab) => self.tab = tab, + ColorPickerMsg::Pick(color) => self.set_pending(color), + 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) => match (self.tab, self.pending) { + (ColorPickerTab::Index, AcadColor::Index(index)) => { + if let Some(next) = step_index(index, direction) { + self.set_pending(AcadColor::Index(next)); + } + } + // Only over the colours on screen, so a search is respected. + (ColorPickerTab::Document, _) => { + let visible = self.doc_visible(); + let at = visible.iter().position(|&i| { + self.doc[i].color == self.pending && self.doc[i].name == self.pending_name + }); + if let Some(next) = step_list(at, visible.len(), DOC_COLUMNS, direction) { + self.update(ColorPickerMsg::PickDocument(visible[next])); + } + } + (ColorPickerTab::Books, _) => { + // Without a search the arrows walk the whole book, page + // after page. + let visible: Vec = if self.book_query.trim().is_empty() { + (0..self + .books + .get(self.book) + .map_or(0, |book| book.colors.len())) + .collect() + } else { + self.book_visible() + }; + let selected = self.book_selection(); + let at = visible.iter().position(|&i| Some(i) == selected); + if let Some(next) = step_list(at, visible.len(), 1, direction) { + self.pick_book(visible[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::DocView(view) => self.doc_view = view, + ColorPickerMsg::DocQuery(query) => self.doc_query = query, + 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()); + } + ColorPickerMsg::Eyedropper(_) => {} + 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, self.pending_name.clone())); + } + ColorPickerMsg::Cancel => return Some(Outcome::Cancel), + } + None + } + + /// How a colour looks; ByLayer takes the layer's colour when known. + fn shown(&self, color: AcadColor) -> Color { + match (color, self.by_layer) { + (AcadColor::ByLayer, Some(layer)) => acad_color_display(layer).0, + _ => acad_color_display(color).0, + } + } + + 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) + } + + pub fn view(&self) -> Element<'_, Message> { + let editor_only = self.editor_only(); + 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 { + // The other tabs fill the height themselves. + if self.tab == ColorPickerTab::Index { + body = body.push(Space::new().height(Length::Fill)); + } + let fill = self.shown(self.pending); + 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!("{} · RGB {r}, {g}, {b}", name.replacen('$', " - ", 1)) + } + (None, None) => math::describe(self.pending), + }; + let error = self.status.is_some(); + body = body.push(divider()).push( + row![ + container(Space::new()) + .width(12) + .height(12) + .style(move |_theme: &Theme| swatch_style(fill)), + 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), + ); + } + let body = container(body) + .padding(12) + .width(Length::Fill) + .height(if editor_only { + Length::Shrink + } else { + Length::Fixed(BODY_HEIGHT) + }) + .style(panel_style); + + let actions = row![ + Space::new().width(Length::Fill), + button(text(t!("Cancel")).size(12)) + .on_press(picker_msg(ColorPickerMsg::Cancel)) + .style(button::secondary) + .padding([6, 16]), + button(text(t!("OK")).size(12)) + .on_press(picker_msg(ColorPickerMsg::Ok)) + .style(button::primary) + .padding([6, 22]), + ] + .spacing(8); + + let mut panel = column![].spacing(12).width(PANEL_WIDTH); + if !editor_only { + panel = panel.push(self.tabs()); + } + container(panel.push(self.preview_row()).push(body).push(actions)) + .id(PANEL_ID) + .into() + } + + fn tabs(&self) -> Element<'_, Message> { + let tab = |label: String, tab: ColorPickerTab| { + let active = self.tab == tab; + button(container(text(label).size(12)).center_x(Length::Fill)) + .on_press(picker_msg(ColorPickerMsg::Tab(tab))) + .width(Length::Fill) + .padding([5, 4]) + .style(move |theme: &Theme, status| segment_style(theme, active, status)) + }; + 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), + ) + .padding(2) + .style(panel_style) + .into() + } + + /// Current and New side by side across the full width, the divider + /// 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 = ink(fill); + column![ + text(label).size(11).color(ink.scale_alpha(0.85)), + Space::new().height(Length::Fill), + text(value).size(11).font(iced::Font::MONOSPACE).color(ink), + ] + .padding([8, 10]) + .width(Length::Fill) + .height(Length::Fill) + }; + let current_bg = self.shown(self.current); + let new_bg = self.shown(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 { + name(self.current, &self.current_name) + }; + let current = button(half(t!("Current").into_owned(), current_value, current_bg)) + .on_press(picker_msg(ColorPickerMsg::Revert)) + .padding(0) + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme: &Theme, _status| button::Style { + background: Some(current_bg.into()), + border: Border { + radius: iced::border::radius(7.0).right(0.0), + ..Border::default() + }, + ..Default::default() + }); + let divider = container(Space::new()) + .width(1) + .height(Length::Fill) + .style(|_theme: &Theme| filled(Color::from_rgba(0.0, 0.0, 0.0, 0.35))); + let new = container(half( + t!("New").into_owned(), + name(self.pending, &self.pending_name), + new_bg, + )) + .style(move |_theme: &Theme| container::Style { + border: Border { + radius: iced::border::radius(7.0).left(0.0), + ..Border::default() + }, + ..filled(new_bg) + }); + container(row![current, divider, new]) + .width(Length::Fill) + .height(60) + .padding(1) + .style(|theme: &Theme| container::Style { + border: Border { + color: theme.palette().background.strong.color, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() + }) + .clip(true) + .into() + } + + fn index_tab(&self) -> Element<'_, Message> { + let section = |label: String| text(label).size(11); + let swatches = |range: std::ops::RangeInclusive| { + range.fold(row![].spacing(5), |line, index| { + line.push(self.index_swatch(index, 30.0, 26.0)) + }) + }; + // ACI 10–249 come in groups of ten: 24 hue families across, their ten + // shades down. + let grid = (0u8..10).fold(column![].spacing(2), |grid, shade| { + grid.push((1u8..=24).fold(row![].spacing(2), |line, family| { + line.push(self.index_swatch(family * 10 + shade, 13.0, 13.0)) + })) + }); + column![ + column![section(t!("Standard colors").into_owned()), swatches(1..=9)].spacing(6), + column![ + section(t!("CAD Color Index (ACI) 10–249").into_owned()), + grid + ] + .spacing(6), + column![ + section(t!("Grayscale 250–255").into_owned()), + swatches(250..=255) + ] + .spacing(6), + ] + .spacing(12) + .into() + } + + /// One index colour of the Index tab. + fn index_swatch(&self, index: u8, width: f32, height: f32) -> Element<'_, Message> { + let color = AcadColor::Index(index); + swatch( + fill_of(color), + self.pending == color, + ColorPickerMsg::Pick(color), + Length::Fixed(width), + Length::Fixed(height), + ) + } + + fn document_tab(&self) -> Element<'_, Message> { + // Search on the left half; sorting and the view buttons on the right. + let header = row![ + container(search_box(&self.doc_query, |query| { + ColorPickerMsg::DocQuery(query) + })) + .width(Length::FillPortion(1)), + row![ + pick_list(Some(self.doc_sort), &DocSort::ALL[..], DocSort::to_string) + .on_select(|by| picker_msg(ColorPickerMsg::DocSort(by))) + .text_size(12) + .padding([6, 8]) + .width(Length::Fill), + row![ + self.view_button(crate::ui::icons::GRID, DocView::Grid), + self.view_button(crate::ui::icons::LIST, DocView::List), + ] + .spacing(2), + ] + .spacing(8) + .align_y(iced::Center) + .width(Length::FillPortion(1)), + ] + .spacing(8) + .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(); + } + // Indices stay those of `doc`, which the pick messages refer to. + let shown: Vec<(usize, &DocColor)> = self + .doc_visible() + .into_iter() + .map(|i| (i, &self.doc[i])) + .collect(); + if shown.is_empty() { + return column![header, empty_note(t!("No colors match").into_owned())] + .spacing(10) + .into(); + } + let list: Element<'_, Message> = if self.doc_view == DocView::List { + shown + .iter() + .fold(column![].spacing(2), |list, &(i, entry)| { + let fill = fill_of(entry.color); + let selected = self.pending == entry.color && self.pending_name == entry.name; + list.push( + color_bar( + entry.label(), + fill, + selected, + ColorPickerMsg::PickDocument(i), + ) + .height(26), + ) + }) + .into() + } else { + self.doc_grid(&shown) + }; + column![ + header, + scrollable(list).height(Length::Fill).spacing(4).direction( + scrollable::Direction::Vertical( + scrollable::Scrollbar::new().width(4).scroller_width(4) + ) + ), + ] + .spacing(10) + .into() + } + + /// Grid or list toggle for the Document tab, styled like the app's other + /// toggles: light accent when active, its icon in that accent's text + /// colour. + fn view_button(&self, icon: &'static [u8], view: DocView) -> Element<'_, Message> { + let active = self.doc_view == view; + button( + container( + svg(crate::ui::icons::themed_handle(icon)) + .width(18) + .height(18) + .style(move |theme: &Theme, _status| svg::Style { + color: Some(if active { + theme.palette().primary.weak.text + } else { + theme.palette().background.base.text + }), + }), + ) + .center(Length::Fill), + ) + .on_press(picker_msg(ColorPickerMsg::DocView(view))) + .width(CONTROL_HEIGHT) + .height(CONTROL_HEIGHT) + .padding(0) + .style(move |theme: &Theme, status| toggle_style(theme, active, status)) + .into() + } + + fn doc_grid<'a>(&self, shown: &[(usize, &'a DocColor)]) -> Element<'a, Message> { + shown + .chunks(DOC_COLUMNS) + .fold(column![].spacing(4), |grid, chunk| { + let line = chunk.iter().fold(row![].spacing(4), |line, &(i, entry)| { + line.push(self.doc_swatch(i, entry)) + }); + let line = (chunk.len()..DOC_COLUMNS) + .fold(line, |line, _| line.push(Space::new().width(Length::Fill))); + grid.push(line) + }) + .into() + } + + fn doc_swatch<'a>(&self, i: usize, entry: &'a DocColor) -> Element<'a, Message> { + swatch( + fill_of(entry.color), + self.pending == entry.color && self.pending_name == entry.name, + ColorPickerMsg::PickDocument(i), + Length::Fill, + Length::Fixed(30.0), + ) + } + + fn books_tab(&self) -> Element<'_, Message> { + let load_button = |content: Element<'static, Message>| { + button(content) + .on_press(picker_msg(ColorPickerMsg::LoadBook)) + .style(tab_button_style) + }; + 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(); + }; + // The book list and its load button over the colour bars; the + // search box over the page strip and the RGB values, to the end. + let books = Arc::clone(&self.books); + let top = row![ + row![ + 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([6, 8]) + .width(Length::Fill), + load_button( + container( + tab_icon(crate::ui::icons::themed_handle( + crate::ui::icons::FOLDER_OPEN + )) + .width(16) + .height(16) + ) + .center(Length::Fill) + .into() + ) + .width(CONTROL_HEIGHT) + .height(CONTROL_HEIGHT) + .padding(0), + ] + .spacing(6) + .align_y(iced::Center) + .width(Length::Fill), + container(search_box(&self.book_query, |query| { + ColorPickerMsg::BookQuery(query) + })) + .width(SIDE_WIDTH), + ] + .spacing(10) + .align_y(iced::Center); + + // The page's colours, or the first matches while searching. + let selected = self.book_selection(); + let searching = !self.book_query.trim().is_empty(); + let visible = self.book_visible(); + let bars: Element<'_, Message> = if visible.is_empty() { + empty_note(t!("No colors match").into_owned()) + } else { + let bars = visible + .iter() + .fold(column![].spacing(2).height(Length::Fill), |bars, &i| { + bars.push(book_bar(i, &book.colors[i], selected)) + }); + 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 = !searching && 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.strong.text), + }), + ) + .on_press(picker_msg(ColorPickerMsg::BookPage(page))) + .width(Length::Fill) + .height(18) + .padding([6, 8]) + .style(tab_button_style) + }; + 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: theme.palette().background.strong.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }), + 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)), + divider(), + 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> { + // The eyedropper reads the window back from the GPU, which the web + // build cannot do. + let pipette: Option> = if cfg!(target_arch = "wasm32") { + None + } else { + Some( + // Just the icon, without a button frame. + button( + // Gray at rest; the accent colour while the pointer is on it. + iced::widget::svg(crate::ui::icons::themed_handle( + crate::ui::icons::EYEDROPPER, + )) + .width(24) + .height(24) + .style(|theme: &Theme, status| iced::widget::svg::Style { + color: Some(match status { + iced::widget::svg::Status::Hovered => { + theme.palette().primary.base.color + } + iced::widget::svg::Status::Idle => { + theme.palette().background.base.text.scale_alpha(0.5) + } + }), + }), + ) + .on_press(picker_msg(ColorPickerMsg::Eyedropper(EyedropperMsg::Start))) + .padding(0) + .style(|_theme: &Theme, _status| button::Style::default()) + .into(), + ) + }; + let nearest = (!self.editor_only()).then(|| self.nearest_button()); + self.editor.view(pipette, nearest, !self.editor_only()) + } + + fn nearest_button(&self) -> Element<'_, Message> { + let (r, g, b) = self.rgb(); + let nearest = math::nearest_aci(r, g, b); + let nearest_rgb = math::aci_rgb(nearest).unwrap_or((128, 128, 128)); + let exact = nearest_rgb == (r, g, b) && !matches!(self.pending, AcadColor::Index(_)); + let fill = rgb_color(nearest_rgb); + row![ + text(if exact { + t!("Exact index match") + } else { + t!("Nearest index") + }) + .size(11) + .style(move |theme: &Theme| text::Style { + color: Some(if exact { + theme.palette().success.base.color + } else { + theme.palette().background.base.text.scale_alpha(0.65) + }), + }), + button( + row![ + container(Space::new()) + .width(16) + .height(16) + .style(move |_theme: &Theme| swatch_style(fill)), + text(format!("ACI {nearest}")) + .size(12) + .font(iced::Font::MONOSPACE), + ] + .spacing(6) + .align_y(iced::Center), + ) + .on_press(picker_msg(ColorPickerMsg::UseNearest)) + .padding([4, 8]) + .style(move |theme: &Theme, status| toggle_style(theme, exact, status)), + ] + .spacing(8) + .align_y(iced::Center) + .into() + } +} + +/// Move through the index palette with the arrow keys: the 24×10 block moves +/// by family (left/right) and shade (up/down); the standard and gray rows +/// move along their row. +fn step_index(index: u8, direction: ArrowKey) -> Option { + let along_row = |first: u8, last: u8| match direction { + ArrowKey::Left if index > first => Some(index - 1), + ArrowKey::Right if index < last => Some(index + 1), + _ => None, + }; + match index { + 1..=9 => along_row(1, 9), + 250..=255 => along_row(250, 255), + 10..=249 => { + let (family, shade) = (index / 10, index % 10); + match direction { + ArrowKey::Left if family > 1 => Some(index - 10), + ArrowKey::Right if family < 24 => Some(index + 10), + ArrowKey::Up if shade > 0 => Some(index - 1), + ArrowKey::Down if shade < 9 => Some(index + 1), + _ => None, + } + } + _ => None, + } +} + +/// 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}; + let id = iced::widget::Id::new(PANEL_ID); + iced_runtime::task::effect(if forward { + iced_runtime::Action::widget(scope(id, focusable::focus_next())) + } else { + iced_runtime::Action::widget(scope(id, focusable::focus_previous())) + }) +} + +fn picker_msg(msg: ColorPickerMsg) -> Message { + Message::ColorPicker(msg) +} + +/// Whether `haystack` contains `needle`, ignoring case; `needle` is already +/// lower case. ASCII needles, the usual case, compare without allocating. +fn contains_ignore_case(haystack: &str, needle: &str) -> bool { + if needle.is_empty() { + true + } else if needle.is_ascii() { + haystack + .as_bytes() + .windows(needle.len().max(1)) + .any(|window| window.eq_ignore_ascii_case(needle.as_bytes())) + } else { + haystack.to_lowercase().contains(needle) + } +} + +/// Text that reads on top of `fill`. +fn ink(fill: Color) -> Color { + let [r, g, b, _] = fill.into_rgba8(); + if math::ink_is_dark(r, g, b) { + 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> { + color_bar( + &color.name, + rgb_color(color.rgb), + selected == Some(i), + ColorPickerMsg::PickBook(i), + ) + .height(Length::Fill) + .into() +} + +/// A full-width colour bar with a name: the Color books pages and the +/// Document tab's list. +fn color_bar<'a>( + name: impl text::IntoFragment<'a>, + fill: Color, + selected: bool, + on_press: ColorPickerMsg, +) -> button::Button<'a, Message> { + button( + container( + text(name) + .size(12) + .font(iced::Font::MONOSPACE) + .color(ink(fill)), + ) + .center(Length::Fill), + ) + .on_press(picker_msg(on_press)) + .padding(0) + .width(Length::Fill) + .style(move |theme: &Theme, status| swatch_button_style(theme, status, fill, selected)) +} + +/// A search field with a magnifier and a gray "Search" placeholder. +fn search_box<'a>( + value: &str, + on_input: impl Fn(String) -> ColorPickerMsg + 'a, +) -> Element<'a, Message> { + iced::widget::stack![ + text_input(&t!("Search"), value) + .on_input(move |query| picker_msg(on_input(query))) + .size(12) + .padding(iced::Padding { + top: 6.0, + right: 8.0, + bottom: 6.0, + left: 28.0, + }) + .width(Length::Fill), + container( + svg(crate::ui::icons::themed_handle(crate::ui::icons::SEARCH)) + .width(14) + .height(14) + .style(|theme: &Theme, _status| svg::Style { + color: Some(theme.palette().background.base.text.scale_alpha(0.5)), + }), + ) + .padding([0, 8]) + .height(Length::Fill) + .center_y(Length::Fill), + ] + .width(Length::Fill) + .into() +} + +/// A small button in the colours of the active tab: the load button and +/// the page arrows. +fn tab_button_style(theme: &Theme, status: button::Status) -> button::Style { + let mut style = segment_style(theme, true, status); + if matches!(status, button::Status::Hovered | button::Status::Pressed) { + style.background = Some(theme.palette().background.stronger.color.into()); + } + style +} + +/// An icon in the active tab's text colour, for `tab_button_style`. +fn tab_icon<'a>(handle: svg::Handle) -> svg::Svg<'a> { + svg(handle).style(|theme: &Theme, _status| svg::Style { + color: Some(theme.palette().background.strong.text), + }) +} + +/// A clickable colour swatch, ringed when selected or hovered. +fn swatch<'a>( + fill: Color, + selected: bool, + on_press: ColorPickerMsg, + width: Length, + height: Length, +) -> Element<'a, Message> { + button(Space::new()) + .on_press(picker_msg(on_press)) + .padding(0) + .width(width) + .height(height) + .style(move |theme: &Theme, status| swatch_button_style(theme, status, fill, selected)) + .into() +} + +/// How a concrete colour looks; gray for one without RGB. +fn fill_of(color: AcadColor) -> Color { + math::color_rgb(color).map_or(Color::from_rgb8(128, 128, 128), rgb_color) +} + +/// A 1 px separator line across the panel. +fn divider<'a>() -> Element<'a, Message> { + container(Space::new()) + .width(Length::Fill) + .height(1) + .style(|theme: &Theme| filled(theme.palette().background.weak.color)) + .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()), + ..Default::default() + } +} + +pub(super) fn muted_text(theme: &Theme) -> text::Style { + text::Style { + color: Some(theme.palette().background.base.text.scale_alpha(0.65)), + } +} + +fn swatch_style(fill: Color) -> container::Style { + container::Style { + background: Some(fill.into()), + border: Border { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.4), + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + } +} + +fn panel_style(theme: &Theme) -> container::Style { + let palette = theme.palette(); + container::Style { + background: Some(palette.background.weakest.color.into()), + border: Border { + color: palette.background.weak.color, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() + } +} + +fn segment_style(theme: &Theme, active: bool, status: button::Status) -> button::Style { + let palette = theme.palette(); + button::Style { + background: if active { + Some(palette.background.strong.color.into()) + } else if matches!(status, button::Status::Hovered) { + Some(palette.background.weak.color.into()) + } else { + None + }, + text_color: if active { + palette.background.strong.text + } else { + palette.background.base.text + }, + border: Border { + radius: 5.0.into(), + ..Border::default() + }, + ..Default::default() + } +} + +pub(super) fn toggle_style(theme: &Theme, active: bool, status: button::Status) -> button::Style { + let palette = theme.palette(); + button::Style { + background: Some(Background::Color(if active { + palette.primary.weak.color + } else if matches!(status, button::Status::Hovered) { + palette.background.weak.color + } else { + palette.background.base.color + })), + text_color: if active { + palette.primary.weak.text + } else { + palette.background.base.text + }, + border: Border { + color: if active { + palette.primary.base.color + } else { + palette.background.strong.color + }, + width: 1.0, + radius: 5.0.into(), + }, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::super::editor::EditorMode; + use super::*; + + fn picker(current: AcadColor) -> ColorPicker { + ColorPicker::new(ColorPickTarget::Ribbon, current, false) + } + + fn edit(p: &mut ColorPicker, msg: EditorMsg) { + p.update(ColorPickerMsg::Editor(msg)); + } + + #[test] + fn opens_on_the_document_tab() { + assert_eq!(picker(AcadColor::Index(30)).tab(), ColorPickerTab::Document); + assert_eq!( + picker(AcadColor::Rgb { r: 1, g: 2, b: 3 }).tab(), + ColorPickerTab::Document + ); + } + + #[test] + fn revert_restores_the_current_colour() { + let mut p = picker(AcadColor::Index(30)); + p.update(ColorPickerMsg::Pick(AcadColor::Index(140))); + assert_eq!(p.pending(), AcadColor::Index(140)); + p.update(ColorPickerMsg::Revert); + assert_eq!(p.pending(), AcadColor::Index(30)); + } + + #[test] + fn ok_commits_and_cancel_does_not() { + let mut p = picker(AcadColor::Index(30)); + p.update(ColorPickerMsg::Pick(AcadColor::Index(5))); + assert_eq!( + p.update(ColorPickerMsg::Ok), + Some(Outcome::Commit(AcadColor::Index(5), None)) + ); + assert_eq!(p.update(ColorPickerMsg::Cancel), Some(Outcome::Cancel)); + } + + #[test] + fn ok_applies_a_hex_value_still_being_typed() { + let mut p = picker(AcadColor::Index(30)); + edit(&mut p, EditorMsg::HexInput("#3bf".into())); + assert_eq!( + p.update(ColorPickerMsg::Ok), + Some(Outcome::Commit( + AcadColor::Rgb { + r: 0x33, + g: 0xBB, + b: 0xFF + }, + None + )) + ); + } + + #[test] + fn the_editor_produces_true_colours() { + let mut p = picker(AcadColor::Index(1)); + edit(&mut p, EditorMsg::Hue(120.0)); + assert_eq!(p.pending(), AcadColor::Rgb { r: 0, g: 255, b: 0 }); + edit(&mut p, EditorMsg::Mode(EditorMode::Rgb)); + assert_eq!(p.pending(), AcadColor::Rgb { r: 0, g: 255, b: 0 }); + } + + #[test] + fn nearest_turns_a_true_colour_into_an_index() { + let mut p = picker(AcadColor::Rgb { r: 250, g: 4, b: 4 }); + p.update(ColorPickerMsg::UseNearest); + assert_eq!(p.pending(), AcadColor::Index(1)); + } + + #[test] + fn arrow_keys_walk_the_index_grid() { + assert_eq!(step_index(140, ArrowKey::Right), Some(150)); + assert_eq!(step_index(140, ArrowKey::Left), Some(130)); + assert_eq!(step_index(140, ArrowKey::Down), Some(141)); + assert_eq!(step_index(140, ArrowKey::Up), None); + assert_eq!(step_index(249, ArrowKey::Right), None); + assert_eq!(step_index(5, ArrowKey::Right), Some(6)); + assert_eq!(step_index(255, ArrowKey::Right), None); + + let mut p = picker(AcadColor::Index(140)); + p.update(ColorPickerMsg::Tab(ColorPickerTab::Index)); + p.update(ColorPickerMsg::Arrow(ArrowKey::Right)); + assert_eq!(p.pending(), AcadColor::Index(150)); + } + + #[test] + fn eyedropper_picks_a_pixel_and_esc_only_leaves_it() { + let mut p = picker(AcadColor::Index(30)); + p.update(ColorPickerMsg::Eyedropper(EyedropperMsg::Start)); + assert_eq!( + p.update(ColorPickerMsg::Eyedropper(EyedropperMsg::Move( + iced::Point::ORIGIN + ))), + Some(Outcome::Screenshot) + ); + // Esc and Enter while picking never close the panel. + assert_eq!(p.update(ColorPickerMsg::Ok), None); + assert_eq!(p.update(ColorPickerMsg::Cancel), None); + assert!(p.eyedropper_view().is_none()); + + p.update(ColorPickerMsg::Eyedropper(EyedropperMsg::Start)); + p.update(ColorPickerMsg::Eyedropper(EyedropperMsg::Move( + iced::Point::ORIGIN, + ))); + let shot = iced::window::Screenshot::new(vec![10, 20, 30, 255], iced::Size::new(1, 1), 1.0); + p.update(ColorPickerMsg::Eyedropper(EyedropperMsg::Shot(shot))); + p.update(ColorPickerMsg::Eyedropper(EyedropperMsg::Pick)); + assert!(p.eyedropper_view().is_none()); + assert_eq!( + p.pending(), + AcadColor::Rgb { + r: 10, + g: 20, + b: 30 + } + ); + } + + 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, 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::Tab(ColorPickerTab::Books)); + 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()), + None, + ); + 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, 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 + // OCS_COLOR_PICKER_SNAPSHOTS to a folder also writes a PNG per view. + + fn simulator(picker: &ColorPicker) -> iced_test::simulator::Simulator<'_, Message> { + iced_test::simulator::Simulator::with_size( + iced::Settings { + fonts: vec![std::borrow::Cow::Borrowed(iced_aw::ICED_AW_FONT_BYTES)], + ..iced::Settings::default() + }, + (PANEL_WIDTH + 24.0, 560.0), + container(picker.view()).padding(12), + ) + } + + fn published(ui: iced_test::simulator::Simulator<'_, Message>) -> Vec { + ui.into_messages() + .filter_map(|message| match message { + Message::ColorPicker(msg) => Some(msg), + _ => None, + }) + .collect() + } + + #[test] + fn clicking_a_tab_switches_it() { + let p = picker(AcadColor::Index(30)); + let mut ui = simulator(&p); + ui.click(t!("True Color").as_ref()).expect("True Color tab"); + assert!(published(ui) + .iter() + .any(|msg| matches!(msg, ColorPickerMsg::Tab(ColorPickerTab::TrueColor)))); + } + + #[test] + fn dragging_a_slider_publishes_values() { + let mut p = picker(AcadColor::Rgb { + r: 57, + g: 191, + b: 252, + }); + p.update(ColorPickerMsg::Tab(ColorPickerTab::TrueColor)); + edit(&mut p, EditorMsg::Mode(EditorMode::Rgb)); + let mut ui = simulator(&p); + let label = ui.find("R").expect("R label"); + let bounds = label.visible_bounds().expect("R visible"); + ui.point_at(iced::Point::new(bounds.x + 40.0, bounds.center_y())); + ui.simulate(iced_test::simulator::click()); + assert!(published(ui) + .iter() + .any(|msg| matches!(msg, ColorPickerMsg::Editor(EditorMsg::Red(v)) if *v < 57))); + } + + #[test] + fn interface_colours_show_only_the_editor() { + let p = ColorPicker::new( + ColorPickTarget::Interface(crate::app::UiColor::ModelBackground), + AcadColor::Rgb { + r: 30, + g: 30, + b: 30, + }, + false, + ); + let mut ui = simulator(&p); + assert!(ui.find(t!("Index Color").as_ref()).is_err()); + assert!(ui.find(t!("Nearest index").as_ref()).is_err()); + assert!(ui.find("HSB").is_ok()); + } + + #[test] + fn every_view_renders() { + let folder = std::env::var_os("OCS_COLOR_PICKER_SNAPSHOTS").map(std::path::PathBuf::from); + let blue = AcadColor::Rgb { + r: 57, + g: 191, + b: 252, + }; + let views: [( + &str, + ColorPickTarget, + AcadColor, + ColorPickerTab, + EditorMode, + bool, + ); 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, + AcadColor::Index(140), + ColorPickerTab::Index, + EditorMode::Hsb, + false, + ), + ( + "varies", + ColorPickTarget::Properties, + AcadColor::Index(30), + ColorPickerTab::Index, + EditorMode::Hsb, + true, + ), + ( + "hsb", + ColorPickTarget::Properties, + blue, + ColorPickerTab::TrueColor, + EditorMode::Hsb, + false, + ), + ( + "rgb", + ColorPickTarget::Properties, + blue, + ColorPickerTab::TrueColor, + EditorMode::Rgb, + false, + ), + ( + "cmyk", + ColorPickTarget::Properties, + blue, + ColorPickerTab::TrueColor, + EditorMode::Cmyk, + false, + ), + ( + "exact", + ColorPickTarget::Properties, + AcadColor::Rgb { r: 255, g: 0, b: 0 }, + ColorPickerTab::TrueColor, + EditorMode::Hsb, + false, + ), + ( + "interface", + ColorPickTarget::Interface(crate::app::UiColor::ModelBackground), + AcadColor::Rgb { + r: 33, + g: 40, + b: 48, + }, + ColorPickerTab::TrueColor, + EditorMode::Hsb, + false, + ), + ]; + for theme in [ + crate::ui::style::fusion_theme::fusion_black(), + crate::ui::style::fusion_theme::fusion_white(), + ] { + for (name, target, color, tab, mode, varies) in views.clone() { + 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, + 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"); + if let Some(folder) = &folder { + let shade = if theme.palette().background.base.color.r < 0.5 { + "dark" + } else { + "light" + }; + let path = folder.join(format!("{shade}-{name}.png")); + let _ = std::fs::remove_file(&path); + snapshot.matches_image(&path).expect("write snapshot"); + } + } + } + } +} diff --git a/src/ui/color_select/widgets/color_area.rs b/src/ui/color_select/widgets/color_area.rs new file mode 100644 index 000000000..9ed22d1da --- /dev/null +++ b/src/ui/color_select/widgets/color_area.rs @@ -0,0 +1,258 @@ +//! Saturation/brightness field for one hue. +//! +//! Drawn as three layered quads instead of pixel by pixel: the solid hue, a +//! white-to-transparent wash from left to right, and a transparent-to-black +//! wash from top to bottom. Saturation runs left to right, brightness bottom +//! to top. Dragging anywhere moves the marker. + +use iced::advanced::layout::{self, Layout}; +use iced::advanced::widget::{self, tree, Widget}; +use iced::advanced::{mouse, renderer, Shell}; +use iced::gradient::Linear; +use iced::{ + Background, Border, Color, Element, Event, Gradient, Length, Point, Radians, Rectangle, + Renderer, Size, Theme, +}; + +const RADIUS: f32 = 5.0; +const MARKER: f32 = 16.0; + +pub struct ColorArea<'a, Message> { + /// Fully saturated, full-brightness colour of the current hue. + hue_color: Color, + /// The colour under the marker. + marker_color: Color, + saturation: f32, + brightness: f32, + on_change: Box Message + 'a>, + width: Length, + height: Length, +} + +#[derive(Default)] +struct State { + dragging: bool, +} + +impl<'a, Message> ColorArea<'a, Message> { + /// `saturation` and `brightness` are 0–1; `on_change` receives the same. + pub fn new( + hue_color: Color, + marker_color: Color, + saturation: f32, + brightness: f32, + on_change: impl Fn(f32, f32) -> Message + 'a, + ) -> Self { + Self { + hue_color, + marker_color, + saturation: saturation.clamp(0.0, 1.0), + brightness: brightness.clamp(0.0, 1.0), + on_change: Box::new(on_change), + width: Length::Fill, + height: Length::Fixed(150.0), + } + } + + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + fn values_at(bounds: Rectangle, cursor: Point) -> (f32, f32) { + let s = ((cursor.x - bounds.x) / bounds.width.max(1.0)).clamp(0.0, 1.0); + let b = 1.0 - ((cursor.y - bounds.y) / bounds.height.max(1.0)).clamp(0.0, 1.0); + (s, b) + } +} + +impl Widget for ColorArea<'_, Message> { + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn size(&self) -> Size { + Size::new(self.width, self.height) + } + + fn layout( + &mut self, + _tree: &mut widget::Tree, + _renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::atomic(limits, self.width, self.height) + } + + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &Renderer, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) { + let state = tree.state.downcast_mut::(); + let bounds = layout.bounds(); + let publish = |shell: &mut Shell<'_, Message>, position: Point| { + let (s, b) = Self::values_at(bounds, position); + shell.publish((self.on_change)(s, b)); + shell.capture_event(); + }; + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if let Some(position) = cursor.position_over(bounds) { + state.dragging = true; + publish(shell, position); + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => { + if let Some(position) = cursor.position() { + publish(shell, position); + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if state.dragging => { + state.dragging = false; + shell.capture_event(); + } + _ => {} + } + } + + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + if tree.state.downcast_ref::().dragging || cursor.is_over(layout.bounds()) { + mouse::Interaction::Crosshair + } else { + mouse::Interaction::default() + } + } + + fn draw( + &self, + _tree: &widget::Tree, + renderer: &mut Renderer, + _theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + use iced::advanced::Renderer as _; + + let bounds = layout.bounds(); + let rounded = renderer::Quad { + bounds, + border: Border { + radius: RADIUS.into(), + ..Border::default() + }, + ..renderer::Quad::default() + }; + let wash = |angle: f32, from: Color, to: Color| { + Background::Gradient(Gradient::Linear( + Linear::new(Radians(angle)) + .add_stop(0.0, from) + .add_stop(1.0, to), + )) + }; + + renderer.fill_quad(rounded, self.hue_color); + renderer.fill_quad( + rounded, + wash( + std::f32::consts::FRAC_PI_2, + Color::WHITE, + Color { + a: 0.0, + ..Color::WHITE + }, + ), + ); + renderer.fill_quad( + renderer::Quad { + border: Border { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.5), + width: 1.0, + radius: RADIUS.into(), + }, + ..rounded + }, + wash( + std::f32::consts::PI, + Color { + a: 0.0, + ..Color::BLACK + }, + Color::BLACK, + ), + ); + + let centre = Point::new( + bounds.x + bounds.width * self.saturation, + bounds.y + bounds.height * (1.0 - self.brightness), + ); + let marker = |size: f32| Rectangle { + x: centre.x - size / 2.0, + y: centre.y - size / 2.0, + width: size, + height: size, + }; + renderer.fill_quad( + renderer::Quad { + bounds: marker(MARKER + 2.0), + border: Border { + radius: ((MARKER + 2.0) / 2.0).into(), + ..Border::default() + }, + ..renderer::Quad::default() + }, + Color::from_rgba(0.0, 0.0, 0.0, 0.55), + ); + renderer.fill_quad( + renderer::Quad { + bounds: marker(MARKER), + border: Border { + color: Color::WHITE, + width: 2.0, + radius: (MARKER / 2.0).into(), + }, + ..renderer::Quad::default() + }, + self.marker_color, + ); + } +} + +impl<'a, Message: 'a> From> for Element<'a, Message> { + fn from(area: ColorArea<'a, Message>) -> Self { + Element::new(area) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn corners_map_to_saturation_and_brightness() { + let bounds = Rectangle::new(Point::new(10.0, 20.0), Size::new(200.0, 100.0)); + let at = |x, y| ColorArea::<()>::values_at(bounds, Point::new(x, y)); + assert_eq!(at(10.0, 20.0), (0.0, 1.0)); + assert_eq!(at(210.0, 120.0), (1.0, 0.0)); + assert_eq!(at(110.0, 70.0), (0.5, 0.5)); + assert_eq!(at(-5.0, 500.0), (0.0, 0.0)); + } +} diff --git a/src/ui/color_select/widgets/mod.rs b/src/ui/color_select/widgets/mod.rs new file mode 100644 index 000000000..f25fc5ffa --- /dev/null +++ b/src/ui/color_select/widgets/mod.rs @@ -0,0 +1,5 @@ +//! Widgets the Select Color panel needs that iced does not provide. + +mod color_area; + +pub use color_area::ColorArea; diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 02ca8dfd9..78971aad0 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -62,6 +62,7 @@ static LAY_ULK: &[u8] = include_bytes!("../../assets/icons/layers/layulk.svg"); // All are black-on-transparent; recolour them at the call site with [`tinted`]. pub static CHECK: &[u8] = include_bytes!("../../assets/icons/ui/check.svg"); pub static CLOSE: &[u8] = include_bytes!("../../assets/icons/ui/close.svg"); +pub static EYEDROPPER: &[u8] = include_bytes!("../../assets/icons/ui/eyedropper.svg"); pub static PLUS: &[u8] = include_bytes!("../../assets/icons/ui/plus.svg"); pub static REFRESH: &[u8] = include_bytes!("../../assets/icons/ui/refresh.svg"); pub static SWAP: &[u8] = include_bytes!("../../assets/icons/ui/swap.svg"); @@ -80,6 +81,8 @@ pub static PIN_ACTIVE: &[u8] = include_bytes!("../../assets/icons/ui/pin_active. pub static SPLIT_V: &[u8] = include_bytes!("../../assets/icons/ui/split_v.svg"); pub static SPLIT_H: &[u8] = include_bytes!("../../assets/icons/ui/split_h.svg"); pub static GRID: &[u8] = include_bytes!("../../assets/icons/ui/grid.svg"); +pub static LIST: &[u8] = include_bytes!("../../assets/icons/ui/list.svg"); +pub static SEARCH: &[u8] = include_bytes!("../../assets/icons/ui/search.svg"); pub static PREVIEW: &[u8] = include_bytes!("../../assets/icons/ui/preview.svg"); pub static SNAP: &[u8] = include_bytes!("../../assets/icons/ui/snap.svg"); pub static DOC_NEW: &[u8] = include_bytes!("../../assets/icons/ui/doc_new.svg"); diff --git a/src/ui/ribbon/color_dropdown.rs b/src/ui/ribbon/color_dropdown.rs index 7d070a186..2584310cb 100644 --- a/src/ui/ribbon/color_dropdown.rs +++ b/src/ui/ribbon/color_dropdown.rs @@ -157,12 +157,14 @@ fn divider<'a>() -> Element<'a, Message> { .into() } +/// `shown` overrides the swatch, so ByLayer can show the layer's colour. fn logical_row<'a>( color: AcadColor, cur_color: AcadColor, label: &'static str, + shown: Option, ) -> Element<'a, Message> { - let (bg, _) = acad_color_display(color); + let bg = shown.unwrap_or_else(|| acad_color_display(color).0); let selected = color == cur_color; let swatch = container( @@ -212,13 +214,15 @@ fn more_colors_row<'a>(active_color: AcadColor) -> Element<'a, Message> { } /// Builds the complete 5-section color dropdown panel element. +/// `layer_color` is the current layer's colour, shown on the ByLayer row. pub fn color_dropdown_panel<'a>( active_color: AcadColor, recent_colors: &[AcadColor], + layer_color: Option, ) -> Element<'a, Message> { // 1. ByLayer and ByBlock - let by_layer = logical_row(AcadColor::ByLayer, active_color, "ByLayer"); - let by_block = logical_row(AcadColor::ByBlock, active_color, "ByBlock"); + let by_layer = logical_row(AcadColor::ByLayer, active_color, "ByLayer", layer_color); + let by_block = logical_row(AcadColor::ByBlock, active_color, "ByBlock", None); let section1 = column![by_layer, by_block].spacing(2); // 2. 45-swatch Quick Pick Grid (5 rows × 9 columns) diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index dea6c8c64..ddf506d21 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -88,6 +88,9 @@ pub struct Ribbon { pub layer_filter: String, /// Active object color — ACI / ByLayer / ByBlock. pub active_color: AcadColor, + /// Colour-book identity (`BOOK$NAME`) of a colour picked from a colour + /// book, with the colour it was picked as; see `active_color_name`. + pub active_color_book: Option<(AcadColor, String)>, /// Active linetype override ("ByLayer", "Continuous", …). pub active_linetype: String, /// Active lineweight. @@ -145,6 +148,14 @@ pub struct LayerInfo { pub vp_frozen: Option<(usize, bool)>, } +/// The colour of layer `name` in the ribbon's layer list. +pub(super) fn layer_color(layers: &[LayerInfo], name: &str) -> Option { + layers + .iter() + .find(|layer| layer.name == name) + .map(|layer| layer.color) +} + /// Full-screen backdrop for an open ribbon dropdown: closes the dropdown when /// the user clicks outside the panel. (Cursor motion leaking to the viewport /// beneath is blocked in `on_viewport_move`, not here — iced 0.14's mouse_area @@ -183,6 +194,20 @@ fn position_ribbon_dropdown<'a>( } impl Ribbon { + /// The current layer's colour: what ByLayer looks like for new objects. + pub fn active_layer_color(&self) -> Option { + layer_color(&self.layer_infos, &self.active_layer) + } + + /// The colour-book identity of the active colour, while the active colour + /// is still the one picked from the book. + pub fn active_color_name(&self) -> Option { + self.active_color_book + .as_ref() + .filter(|(color, _)| *color == self.active_color) + .map(|(_, name)| name.clone()) + } + pub fn new() -> Self { Self { modules: all_ribbon_modules(), @@ -207,6 +232,7 @@ impl Ribbon { layer_infos: vec![], layer_filter: String::new(), active_color: AcadColor::ByLayer, + active_color_book: None, active_linetype: "ByLayer".to_string(), active_lineweight: LineWeight::ByLayer, available_linetypes: vec![LinetypeItem { @@ -1293,7 +1319,11 @@ impl Ribbon { win: (f32, f32), recent_colors: &[AcadColor], ) -> Option> { - let panel = color_dropdown::color_dropdown_panel(self.active_color, recent_colors); + let panel = color_dropdown::color_dropdown_panel( + self.active_color, + recent_colors, + self.active_layer_color(), + ); let (align_right, h_pad, top) = self.dd_anchor(PROP_COLOR_ID, color_dropdown::PANEL_W, win.0); diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index 46adb8d14..9c9fef882 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -1193,7 +1193,10 @@ pub(super) fn render_large<'a>(item: &RibbonItem, ctx: &RenderCtx<'_>) -> Elemen .width(Length::Fixed(PROP_W)) }; - let (color_swatch, _) = acad_color_display(active_color); + // ByLayer shows the current layer's colour. + let color_swatch = super::layer_color(layer_infos, active_layer) + .filter(|_| active_color == AcadColor::ByLayer) + .unwrap_or_else(|| acad_color_display(active_color).0); let color_row = prop_row( crate::ui::color_select::color_display_name(active_color), PROP_COLOR_ID, diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 6eccb5cff..29983c08c 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -108,6 +108,8 @@ pub struct Folders { /// Where `.shx` / `.ttf` fonts dropped in by the user (or downloaded for /// a drawing) are found. pub fonts: Option, + /// Where `.acb` colour books are kept and loaded ones copied to. + pub color_books: Option, } /// Values read from the current drawing's header rather than from preferences. @@ -165,22 +167,17 @@ impl fmt::Display for Labelled { } } -/// A background swatch that opens the colour wheel when clicked. +/// A colour swatch that opens the Select Color panel when clicked. /// -/// The wheel sits alongside the hex field rather than replacing it: typing +/// The swatch sits beside the hex field rather than replacing it: typing /// `#1E1E1E` stays the fastest way to reproduce an exact colour, while the -/// wheel is for choosing one by eye. -fn bg_swatch_picker<'a>( - target: crate::app::BgTarget, - rgb: [u8; 3], - open: Option, -) -> Element<'a, Message> { - let colour = iced::Color::from_rgb8(rgb[0], rgb[1], rgb[2]); +/// panel is for choosing one by eye. +fn color_swatch<'a>(target: crate::app::UiColor, [r, g, b]: [u8; 3]) -> Element<'a, Message> { let swatch = container(Space::new()) .width(28) .height(22) .style(move |theme: &Theme| container::Style { - background: Some(Background::Color(colour)), + background: Some(Background::Color(iced::Color::from_rgb8(r, g, b))), border: Border { color: theme.palette().background.strong.color, width: 1.0, @@ -188,18 +185,14 @@ fn bg_swatch_picker<'a>( }, ..Default::default() }); - let underlay = button(swatch) - .on_press(Message::BgPickerOpen(target)) + button(swatch) + .on_press(Message::OpenColorWindow( + crate::app::ColorPickTarget::Interface(target), + codec::types::Color::Rgb { r, g, b }, + )) .padding(0) - .style(button::text); - iced_aw::ColorPicker::new( - open == Some(target), - colour, - underlay, - Message::BgPickerCancel, - Message::BgPickerSubmit, - ) - .into() + .style(button::text) + .into() } #[allow(clippy::too_many_arguments)] @@ -235,7 +228,6 @@ pub fn view_window<'a>( model_bg_input: &'a str, paper_bg_input: &'a str, desk_bg_input: &'a str, - bg_picker: Option, dirty: bool, close_confirm: bool, sizing: crate::ui::modal::ModalSizing, @@ -315,18 +307,8 @@ pub fn view_window<'a>( let mut color_controls = column![].spacing(8); for (index, (label, color)) in colors.into_iter().enumerate() { - let swatch = container(Space::new()) - .width(28) - .height(22) - .style(move |theme: &Theme| container::Style { - background: Some(Background::Color(color)), - border: Border { - color: theme.palette().background.strong.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }); + let [r, g, bl, _] = color.into_rgba8(); + let swatch = color_swatch(crate::app::UiColor::Theme(index), [r, g, bl]); color_controls = color_controls.push( row![ text(label).size(12).width(110), @@ -536,61 +518,18 @@ pub fn view_window<'a>( .spacing(0) .width(sizing.width); - let crosshair_rgb = crosshair_color.unwrap_or([255, 255, 255]); - let crosshair_swatch = - container(Space::new()) - .width(28) - .height(22) - .style(move |theme: &Theme| container::Style { - background: Some(Background::Color(iced::Color::from_rgb8( - crosshair_rgb[0], - crosshair_rgb[1], - crosshair_rgb[2], - ))), - border: Border { - color: theme.palette().background.strong.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }); - - let command_rgb = command_text_color.unwrap_or([220, 220, 220]); - let command_swatch = - container(Space::new()) - .width(28) - .height(22) - .style(move |theme: &Theme| container::Style { - background: Some(Background::Color(iced::Color::from_rgb8( - command_rgb[0], - command_rgb[1], - command_rgb[2], - ))), - border: Border { - color: theme.palette().background.strong.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }); - - let snap_rgb = snap_marker_color.unwrap_or([255, 230, 26]); - let snap_swatch = container(Space::new()) - .width(28) - .height(22) - .style(move |theme: &Theme| container::Style { - background: Some(Background::Color(iced::Color::from_rgb8( - snap_rgb[0], - snap_rgb[1], - snap_rgb[2], - ))), - border: Border { - color: theme.palette().background.strong.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }); + let crosshair_swatch = color_swatch( + crate::app::UiColor::Crosshair, + crosshair_color.unwrap_or([255, 255, 255]), + ); + let command_swatch = color_swatch( + crate::app::UiColor::CommandText, + command_text_color.unwrap_or([220, 220, 220]), + ); + let snap_swatch = color_swatch( + crate::app::UiColor::SnapMarker, + snap_marker_color.unwrap_or([255, 230, 26]), + ); let model_bg_rgb = model_space .custom_bg @@ -750,7 +689,7 @@ pub fn view_window<'a>( display = display.push(Space::new().height(10)).push( row![ text(crate::t!("Model background")).size(12).width(140), - bg_swatch_picker(crate::app::BgTarget::Model, model_bg_rgb, bg_picker), + color_swatch(crate::app::UiColor::ModelBackground, model_bg_rgb), text_input("#RRGGBB", model_bg_input) .on_input(Message::ModelSpaceBgChanged) .width(150), @@ -763,7 +702,7 @@ pub fn view_window<'a>( display = display.push(Space::new().height(10)).push( row![ text(crate::t!("Paper background")).size(12).width(140), - bg_swatch_picker(crate::app::BgTarget::Paper, paper_bg_rgb, bg_picker), + color_swatch(crate::app::UiColor::PaperBackground, paper_bg_rgb), text_input("#RRGGBB", paper_bg_input) .on_input(Message::PaperSpaceBgChanged) .width(150), @@ -775,7 +714,7 @@ pub fn view_window<'a>( display = display.push(Space::new().height(10)).push( row![ text(crate::t!("Desk surround")).size(12).width(140), - bg_swatch_picker(crate::app::BgTarget::Desk, desk_bg_rgb, bg_picker), + color_swatch(crate::app::UiColor::DeskBackground, desk_bg_rgb), text_input("#RRGGBB", desk_bg_input) .on_input(Message::DeskSpaceBgChanged) .width(150), @@ -1853,6 +1792,11 @@ pub fn view_window<'a>( folder_row(crate::t!("Autosave files"), folders.autosave.clone()), Space::new().height(12), folder_row(crate::t!("Fonts"), folders.fonts.clone()), + Space::new().height(12), + folder_row( + crate::t!("Color book locations"), + folders.color_books.clone() + ), Space::new().height(16), text(crate::t!("Default template for new drawings")).size(12), Space::new().height(4),